fix(repository-api): preserve optional fields and schema combinations

This commit is contained in:
2026-09-18 20:44:45 +09:00
parent 8ba89e7cee
commit e6d3905dc6
3 changed files with 136 additions and 28 deletions
@@ -308,6 +308,25 @@ fn ensure_schema_keywords(
Ok(())
}
fn literal_matches_type(kind: &str, value: &Value) -> bool {
matches!(
(kind, value),
("string", Value::String(_)) | ("boolean", Value::Bool(_)) | ("null", Value::Null)
)
}
fn literal_schema_matches_type(schema: &Map<String, Value>, kind: &str) -> bool {
if let Some(constant) = schema.get("const") {
return literal_matches_type(kind, constant);
}
schema
.get("enum")
.and_then(Value::as_array)
.is_some_and(|values| {
!values.is_empty() && values.iter().all(|value| literal_matches_type(kind, value))
})
}
fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError> {
let schema = object(value, context)?;
if schema.contains_key("$ref") {
@@ -327,10 +346,7 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
let kind = type_value.as_str().ok_or_else(|| {
GenerationError::invalid(format!("{context} const type must be a string"))
})?;
if !matches!(
(kind, constant),
("string", Value::String(_)) | ("boolean", Value::Bool(_)) | ("null", Value::Null)
) {
if !literal_matches_type(kind, constant) {
return Err(GenerationError::invalid(format!(
"{context} const literal does not match type `{kind}`"
)));
@@ -352,15 +368,7 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
let kind = type_value.as_str().ok_or_else(|| {
GenerationError::invalid(format!("{context} enum type must be a string"))
})?;
let matches_type = |value: &Value| {
matches!(
(kind, value),
("string", Value::String(_))
| ("boolean", Value::Bool(_))
| ("null", Value::Null)
)
};
if !values.iter().all(matches_type) {
if !values.iter().all(|value| literal_matches_type(kind, value)) {
return Err(GenerationError::invalid(format!(
"{context} enum literals do not match type `{kind}`"
)));
@@ -378,14 +386,15 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
}
if let Some(branches) = schema.get("oneOf") {
ensure_schema_keywords(schema, &["oneOf", "type"], context)?;
if schema
.get("type")
.is_some_and(|kind| kind.as_str() != Some("string"))
{
let outer_type = match schema.get("type") {
Some(Value::String(kind)) if kind == "string" => Some(kind.as_str()),
Some(_) => {
return Err(GenerationError::invalid(format!(
"{context} contains an unsupported oneOf type"
)));
}
None => None,
};
let branches = branches.as_array().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-array oneOf"))
})?;
@@ -402,6 +411,13 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
"{context} contains unsupported ambiguous oneOf semantics"
)));
}
if let Some(kind) = outer_type {
if !literal_schema_matches_type(branch_object, kind) {
return Err(GenerationError::invalid(format!(
"{context} oneOf branch does not match outer type `{kind}`"
)));
}
}
rendered.push(render_schema(branch, context)?);
}
return Ok(rendered.join(" | "));
@@ -450,6 +466,15 @@ fn render_nullable_union(value: &Value, context: &str) -> Result<String, Generat
"{context} contains unsupported ambiguous anyOf semantics"
)));
}
let null_branch = branches
.iter()
.find(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
.expect("validated one null branch");
if render_schema(null_branch, context)? != "null" {
return Err(GenerationError::invalid(format!(
"{context} contains an invalid nullable null branch"
)));
}
let branch = branches
.iter()
.find(|branch| branch.get("type").and_then(Value::as_str) != Some("null"))
@@ -493,12 +518,14 @@ fn render_typed_schema(
};
let minimum = schema.get("minimum").and_then(Value::as_f64);
let maximum = schema.get("maximum").and_then(Value::as_f64);
if minimum.is_none_or(|minimum| minimum < MIN_SAFE_INTEGER)
|| maximum.is_none_or(|maximum| maximum > MAX_SAFE_INTEGER)
|| (format == Some("uint64") && minimum.is_none_or(|minimum| minimum < 0.0))
{
let expected_minimum = if format == Some("uint64") {
0.0
} else {
MIN_SAFE_INTEGER
};
if minimum != Some(expected_minimum) || maximum != Some(MAX_SAFE_INTEGER) {
return Err(GenerationError::invalid(format!(
"{context} integer range exceeds its JavaScript safe-number mapping"
"{context} integer range does not match its JavaScript safe-number mapping"
)));
}
Ok("number".to_owned())
@@ -672,6 +699,12 @@ mod tests {
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
serde_json::json!(9_007_199_254_740_990_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] =
serde_json::json!("int64");
@@ -728,6 +761,47 @@ mod tests {
);
}
#[test]
fn generator_rejects_discarded_combined_schema_constraints() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["DiagnosticSeverity"]["const"] =
serde_json::json!("info");
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(
error
.to_string()
.contains("unsupported schema keyword `enum`")
);
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
let null_branch =
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
.as_array_mut()
.unwrap()
.iter_mut()
.find(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
.unwrap();
null_branch["maxLength"] = serde_json::json!(1);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(
error
.to_string()
.contains("unsupported schema keyword `maxLength`")
);
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySourceKind"]["type"] =
serde_json::json!("string");
document["components"]["schemas"]["RepositorySourceKind"]["oneOf"][0] =
serde_json::json!({"const": true, "type": "boolean"});
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(
error
.to_string()
.contains("oneOf branch does not match outer type")
);
}
#[test]
fn generator_is_bounded_to_the_repository_operation_contract() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
@@ -235,8 +235,8 @@ function gitSummary(value: unknown, path: string): GitRepositorySummary {
exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path);
return {
status: repositoryString(git.status, `${path}.status`),
head: nullableRepositoryString(git.head, `${path}.head`),
branch: nullableRepositoryString(git.branch, `${path}.branch`),
head: optionalNullableRepositoryString(git.head, `${path}.head`),
branch: optionalNullableRepositoryString(git.branch, `${path}.branch`),
dirty: boolean(git.dirty, `${path}.dirty`),
remotes: repositoryArray(git.remotes, `${path}.remotes`).map((
item,
@@ -50,6 +50,40 @@ Deno.test("generated repository wrapper validates current Backend JSON", () => {
}
});
Deno.test("repository git optional nullable fields accept omission and null", () => {
const omitted = structuredClone(repositoryList) as Record<string, unknown>;
const omittedItems = omitted.items as Array<Record<string, unknown>>;
omittedItems[0].git = {
status: "available",
dirty: false,
remotes: [],
};
const omittedParsed = parseRepositoryListResponse(omitted);
if (
omittedParsed.items[0]?.git?.head !== undefined ||
omittedParsed.items[0]?.git?.branch !== undefined
) {
throw new Error("omitted git fields were not preserved");
}
const nullable = structuredClone(repositoryList) as Record<string, unknown>;
const nullableItems = nullable.items as Array<Record<string, unknown>>;
nullableItems[0].git = {
status: "available",
head: null,
branch: null,
dirty: false,
remotes: [],
};
const nullableParsed = parseRepositoryListResponse(nullable);
if (
nullableParsed.items[0]?.git?.head !== null ||
nullableParsed.items[0]?.git?.branch !== null
) {
throw new Error("null git fields were not preserved");
}
});
Deno.test("plain HTTP repository source kind fails closed at the JSON boundary", () => {
const stale = structuredClone(repositoryList) as Record<string, unknown>;
const items = stale.items as Array<Record<string, unknown>>;