From 8ba89e7cee289e5486d87e46369ad9caab961787 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 20:32:44 +0900 Subject: [PATCH 1/3] 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 From e6d3905dc682bac0565a72e7ce4729b47df46d55 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 20:44:45 +0900 Subject: [PATCH 2/3] fix(repository-api): preserve optional fields and schema combinations --- .../src/repository_openapi_typescript.rs | 126 ++++++++++++++---- .../src/lib/workspace/api/workspace-model.ts | 4 +- web/workspace/tests/workspace-model.test.ts | 34 +++++ 3 files changed, 136 insertions(+), 28 deletions(-) diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index df3441e3e..24556e344 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -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, 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 { let schema = object(value, context)?; if schema.contains_key("$ref") { @@ -327,10 +346,7 @@ fn render_schema(value: &Value, context: &str) -> Result Result Result 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 Result 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(); diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index 3f6d51cdb..0665f423b 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -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, diff --git a/web/workspace/tests/workspace-model.test.ts b/web/workspace/tests/workspace-model.test.ts index de7aa3e30..7ae598119 100644 --- a/web/workspace/tests/workspace-model.test.ts +++ b/web/workspace/tests/workspace-model.test.ts @@ -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; + const omittedItems = omitted.items as Array>; + 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; + const nullableItems = nullable.items as Array>; + 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; const items = stale.items as Array>; From b0ad3689d034398d1c003b052eead86c76d2daba Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 20:53:48 +0900 Subject: [PATCH 3/3] fix(repository-api): reject overlapping oneof literals --- .../src/repository_openapi_typescript.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 24556e344..ac459aa91 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -404,6 +404,7 @@ fn render_schema(value: &Value, context: &str) -> Result Result