fix(repository-api): reject unsupported schema keywords

This commit is contained in:
2026-09-18 20:32:44 +09:00
parent ca305f0ff1
commit 8ba89e7cee
3 changed files with 144 additions and 10 deletions
@@ -4,8 +4,7 @@ use serde_json::{Map, Value};
pub const GENERATOR_NAME: &str = "yoi-repository-openapi-typescript"; pub const GENERATOR_NAME: &str = "yoi-repository-openapi-typescript";
pub const GENERATOR_VERSION: &str = "1"; pub const GENERATOR_VERSION: &str = "1";
pub const GENERATOR_OPTIONS: &str = pub const GENERATOR_OPTIONS: &str = "repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output";
"repository-operation-schema-closure,openapi-3.1,strict-safe-integers,sorted-output";
pub const INPUT_PATH: &str = "openapi/server-api.json"; pub const INPUT_PATH: &str = "openapi/server-api.json";
pub const OUTPUT_PATH: &str = "web/workspace/src/lib/generated/repository-api.ts"; pub const OUTPUT_PATH: &str = "web/workspace/src/lib/generated/repository-api.ts";
@@ -285,6 +284,30 @@ fn collect_schema_references(
Ok(()) 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 render_schema(value: &Value, context: &str) -> Result<String, GenerationError> { fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError> {
let schema = object(value, context)?; let schema = object(value, context)?;
if schema.contains_key("$ref") { if schema.contains_key("$ref") {
@@ -299,9 +322,24 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
}); });
} }
if let Some(constant) = schema.get("const") { 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 !matches!(
(kind, constant),
("string", Value::String(_)) | ("boolean", Value::Bool(_)) | ("null", Value::Null)
) {
return Err(GenerationError::invalid(format!(
"{context} const literal does not match type `{kind}`"
)));
}
}
return render_literal(constant, context); return render_literal(constant, context);
} }
if let Some(enumeration) = schema.get("enum") { if let Some(enumeration) = schema.get("enum") {
ensure_schema_keywords(schema, &["enum", "type"], context)?;
let values = enumeration.as_array().ok_or_else(|| { let values = enumeration.as_array().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-array enum")) GenerationError::invalid(format!("{context} contains a non-array enum"))
})?; })?;
@@ -310,6 +348,24 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
"{context} contains an empty enum" "{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"))
})?;
let matches_type = |value: &Value| {
matches!(
(kind, value),
("string", Value::String(_))
| ("boolean", Value::Bool(_))
| ("null", Value::Null)
)
};
if !values.iter().all(matches_type) {
return Err(GenerationError::invalid(format!(
"{context} enum literals do not match type `{kind}`"
)));
}
}
return values return values
.iter() .iter()
.map(|value| render_literal(value, context)) .map(|value| render_literal(value, context))
@@ -317,9 +373,19 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
.map(|values| values.join(" | ")); .map(|values| values.join(" | "));
} }
if let Some(branches) = schema.get("anyOf") { if let Some(branches) = schema.get("anyOf") {
ensure_schema_keywords(schema, &["anyOf"], context)?;
return render_nullable_union(branches, context); return render_nullable_union(branches, context);
} }
if let Some(branches) = schema.get("oneOf") { 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"))
{
return Err(GenerationError::invalid(format!(
"{context} contains an unsupported oneOf type"
)));
}
let branches = branches.as_array().ok_or_else(|| { let branches = branches.as_array().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-array oneOf")) GenerationError::invalid(format!("{context} contains a non-array oneOf"))
})?; })?;
@@ -397,28 +463,61 @@ fn render_typed_schema(
context: &str, context: &str,
) -> Result<String, GenerationError> { ) -> Result<String, GenerationError> {
match kind { match kind {
"string" => Ok("string".to_owned()), "string" => {
"boolean" => Ok("boolean".to_owned()), ensure_schema_keywords(schema, &["type"], context)?;
"null" => Ok("null".to_owned()), 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" => { "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 minimum = schema.get("minimum").and_then(Value::as_f64);
let maximum = schema.get("maximum").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) if minimum.is_none_or(|minimum| minimum < MIN_SAFE_INTEGER)
|| maximum.is_none_or(|maximum| maximum > MAX_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!( 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()) Ok("number".to_owned())
} }
"array" => { "array" => {
ensure_schema_keywords(schema, &["items", "type"], context)?;
let items = schema.get("items").ok_or_else(|| { let items = schema.get("items").ok_or_else(|| {
GenerationError::invalid(format!("{context} array has no item schema")) GenerationError::invalid(format!("{context} array has no item schema"))
})?; })?;
Ok(format!("Array<{}>", render_schema(items, context)?)) 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!( other => Err(GenerationError::invalid(format!(
"{context} uses unsupported type `{other}`" "{context} uses unsupported type `{other}`"
))), ))),
@@ -565,7 +664,19 @@ mod tests {
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
serde_json::json!(9_007_199_254_740_992_u64); serde_json::json!(9_007_199_254_740_992_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err(); 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(); let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"] document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
@@ -594,6 +705,29 @@ mod tests {
assert!(error.to_string().contains("unsupported `$ref` sibling")); 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] #[test]
fn generator_is_bounded_to_the_repository_operation_contract() { fn generator_is_bounded_to_the_repository_operation_contract() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); 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 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 ## Other generated contracts
@@ -1,7 +1,7 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef) // 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 // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check