diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index dc7a9d12e..ac459aa91 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,49 @@ 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 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") { @@ -299,9 +341,21 @@ 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")) })?; @@ -329,6 +404,7 @@ 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) - { + 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(); 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 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>;