fix: fail closed on lossy Repository schemas

This commit is contained in:
2026-09-18 21:02:37 +09:00
5 changed files with 292 additions and 15 deletions
@@ -4,8 +4,7 @@ use serde_json::{Map, Value};
pub const GENERATOR_NAME: &str = "yoi-repository-openapi-typescript";
pub const GENERATOR_VERSION: &str = "1";
pub const GENERATOR_OPTIONS: &str =
"repository-operation-schema-closure,openapi-3.1,strict-safe-integers,sorted-output";
pub const GENERATOR_OPTIONS: &str = "repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output";
pub const INPUT_PATH: &str = "openapi/server-api.json";
pub const OUTPUT_PATH: &str = "web/workspace/src/lib/generated/repository-api.ts";
@@ -285,6 +284,49 @@ fn collect_schema_references(
Ok(())
}
fn ensure_schema_keywords(
schema: &Map<String, Value>,
allowed: &[&str],
context: &str,
) -> Result<(), GenerationError> {
const ALLOWED_ANNOTATIONS: &[&str] = &[
"$comment",
"default",
"deprecated",
"description",
"example",
"examples",
"title",
];
if let Some(keyword) = schema.keys().find(|keyword| {
!allowed.contains(&keyword.as_str()) && !ALLOWED_ANNOTATIONS.contains(&keyword.as_str())
}) {
return Err(GenerationError::invalid(format!(
"{context} contains unsupported schema keyword `{keyword}`"
)));
}
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") {
@@ -299,9 +341,21 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
});
}
if let Some(constant) = schema.get("const") {
ensure_schema_keywords(schema, &["const", "type"], context)?;
if let Some(type_value) = schema.get("type") {
let kind = type_value.as_str().ok_or_else(|| {
GenerationError::invalid(format!("{context} const type must be a string"))
})?;
if !literal_matches_type(kind, constant) {
return Err(GenerationError::invalid(format!(
"{context} const literal does not match type `{kind}`"
)));
}
}
return render_literal(constant, context);
}
if let Some(enumeration) = schema.get("enum") {
ensure_schema_keywords(schema, &["enum", "type"], context)?;
let values = enumeration.as_array().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-array enum"))
})?;
@@ -310,6 +364,16 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
"{context} contains an empty enum"
)));
}
if let Some(type_value) = schema.get("type") {
let kind = type_value.as_str().ok_or_else(|| {
GenerationError::invalid(format!("{context} enum type must be a string"))
})?;
if !values.iter().all(|value| literal_matches_type(kind, value)) {
return Err(GenerationError::invalid(format!(
"{context} enum literals do not match type `{kind}`"
)));
}
}
return values
.iter()
.map(|value| render_literal(value, context))
@@ -317,9 +381,20 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
.map(|values| values.join(" | "));
}
if let Some(branches) = schema.get("anyOf") {
ensure_schema_keywords(schema, &["anyOf"], context)?;
return render_nullable_union(branches, context);
}
if let Some(branches) = schema.get("oneOf") {
ensure_schema_keywords(schema, &["oneOf", "type"], context)?;
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"))
})?;
@@ -329,6 +404,7 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
)));
}
let mut rendered = Vec::with_capacity(branches.len());
let mut seen_literals = BTreeSet::new();
for branch in branches {
let branch_object = object(branch, context)?;
if !branch_object.contains_key("enum") && !branch_object.contains_key("const") {
@@ -336,6 +412,39 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
"{context} contains unsupported ambiguous oneOf semantics"
)));
}
let literals = if let Some(constant) = branch_object.get("const") {
std::slice::from_ref(constant)
} else {
branch_object
.get("enum")
.and_then(Value::as_array)
.ok_or_else(|| {
GenerationError::invalid(format!(
"{context} contains a non-array oneOf enum"
))
})?
.as_slice()
};
for literal in literals {
render_literal(literal, context)?;
let normalized = serde_json::to_string(literal).map_err(|error| {
GenerationError::invalid(format!(
"failed to normalize {context} oneOf literal: {error}"
))
})?;
if !seen_literals.insert(normalized) {
return Err(GenerationError::invalid(format!(
"{context} contains overlapping oneOf literal branches"
)));
}
}
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(" | "));
@@ -384,6 +493,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"))
@@ -397,28 +515,63 @@ fn render_typed_schema(
context: &str,
) -> Result<String, GenerationError> {
match kind {
"string" => Ok("string".to_owned()),
"boolean" => Ok("boolean".to_owned()),
"null" => Ok("null".to_owned()),
"string" => {
ensure_schema_keywords(schema, &["type"], context)?;
Ok("string".to_owned())
}
"boolean" => {
ensure_schema_keywords(schema, &["type"], context)?;
Ok("boolean".to_owned())
}
"null" => {
ensure_schema_keywords(schema, &["type"], context)?;
Ok("null".to_owned())
}
"integer" => {
ensure_schema_keywords(schema, &["format", "maximum", "minimum", "type"], context)?;
let format = match schema.get("format") {
Some(Value::String(format)) if format == "uint64" => Some(format.as_str()),
Some(Value::String(format)) => {
return Err(GenerationError::invalid(format!(
"{context} uses unsupported integer format `{format}`"
)));
}
Some(_) => {
return Err(GenerationError::invalid(format!(
"{context} integer format must be a string"
)));
}
None => None,
};
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)
{
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 JavaScript safe integers"
"{context} integer range does not match its JavaScript safe-number mapping"
)));
}
Ok("number".to_owned())
}
"array" => {
ensure_schema_keywords(schema, &["items", "type"], context)?;
let items = schema.get("items").ok_or_else(|| {
GenerationError::invalid(format!("{context} array has no item schema"))
})?;
Ok(format!("Array<{}>", render_schema(items, context)?))
}
"object" => render_object(schema, context),
"object" => {
ensure_schema_keywords(
schema,
&["additionalProperties", "properties", "required", "type"],
context,
)?;
render_object(schema, context)
}
other => Err(GenerationError::invalid(format!(
"{context} uses unsupported type `{other}`"
))),
@@ -565,7 +718,25 @@ mod tests {
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
serde_json::json!(9_007_199_254_740_992_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe integers"));
assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] =
serde_json::json!(-1);
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");
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("unsupported integer format"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
@@ -594,6 +765,78 @@ mod tests {
assert!(error.to_string().contains("unsupported `$ref` sibling"));
}
#[test]
fn generator_rejects_unimplemented_ordinary_schema_keywords() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["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"]["RepositoryListResponse"]["properties"]["items"]["maxItems"] =
serde_json::json!(1);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(
error
.to_string()
.contains("unsupported schema keyword `maxItems`")
);
}
#[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")
);
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySourceKind"]["oneOf"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({"const": "invalid", "type": "string"}));
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("overlapping oneOf literal"));
}
#[test]
fn generator_is_bounded_to_the_repository_operation_contract() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
+1 -1
View File
@@ -13,7 +13,7 @@ cargo run -q -p server-api --example generate_repository_openapi_types
cargo run -q -p server-api --example generate_repository_openapi_types -- --check
```
The generator fails closed on unsupported or lossy OpenAPI constructs, including ambiguous nullable unions, external references, unsupported enum literals, and integer ranges that cannot be represented safely by JavaScript numbers. The legacy TypeScript generator imports the two shared Repository source projection types it still needs and no longer declares the migrated Repository list/detail types.
The generator fails closed on unsupported or lossy OpenAPI constructs, including ambiguous nullable unions, external references, semantic `$ref` siblings, unimplemented ordinary schema keywords, unsupported enum literals/formats, and integer ranges that cannot be represented safely by JavaScript numbers. Its one explicit format mapping is canonical `uint64` to a nonnegative JavaScript safe `number`; the runtime parser enforces the same range. The legacy TypeScript generator imports the two shared Repository source projection types it still needs and no longer declares the migrated Repository list/detail types.
## Other generated contracts
@@ -1,7 +1,7 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef)
// Options: repository-operation-schema-closure,openapi-3.1,strict-safe-integers,sorted-output
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -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>>;