From 8ba89e7cee289e5486d87e46369ad9caab961787 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 20:32:44 +0900 Subject: [PATCH] fix(repository-api): reject unsupported schema keywords --- .../src/repository_openapi_typescript.rs | 150 +++++++++++++++++- web/workspace/src/lib/generated/README.md | 2 +- .../src/lib/generated/repository-api.ts | 2 +- 3 files changed, 144 insertions(+), 10 deletions(-) diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index dc7a9d12e..df3441e3e 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -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,30 @@ fn collect_schema_references( Ok(()) } +fn ensure_schema_keywords( + schema: &Map, + 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 render_schema(value: &Value, context: &str) -> Result { let schema = object(value, context)?; if schema.contains_key("$ref") { @@ -299,9 +322,24 @@ fn render_schema(value: &Value, context: &str) -> Result Result Result Result { 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) + || (format == Some("uint64") && minimum.is_none_or(|minimum| minimum < 0.0)) { return Err(GenerationError::invalid(format!( - "{context} integer range exceeds JavaScript safe integers" + "{context} integer range exceeds 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 +664,19 @@ 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"]["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 +705,29 @@ 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_is_bounded_to_the_repository_operation_contract() { let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); diff --git a/web/workspace/src/lib/generated/README.md b/web/workspace/src/lib/generated/README.md index dc9467dbe..18d58e70d 100644 --- a/web/workspace/src/lib/generated/README.md +++ b/web/workspace/src/lib/generated/README.md @@ -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 diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 4e6a1fe75..aa6a289fa 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -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