fix(repository-api): reject unsupported schema keywords
This commit is contained in:
@@ -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<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> {
|
||||
let schema = object(value, context)?;
|
||||
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") {
|
||||
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);
|
||||
}
|
||||
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 +348,24 @@ 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"))
|
||||
})?;
|
||||
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
|
||||
.iter()
|
||||
.map(|value| render_literal(value, context))
|
||||
@@ -317,9 +373,19 @@ 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)?;
|
||||
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(|| {
|
||||
GenerationError::invalid(format!("{context} contains a non-array oneOf"))
|
||||
})?;
|
||||
@@ -397,28 +463,61 @@ 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)
|
||||
|| (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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user