fix(repository-api): preserve optional fields and schema combinations
This commit is contained in:
@@ -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<String, Value>, 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<String, GenerationError> {
|
||||
let schema = object(value, context)?;
|
||||
if schema.contains_key("$ref") {
|
||||
@@ -327,10 +346,7 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
|
||||
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)
|
||||
) {
|
||||
if !literal_matches_type(kind, constant) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} const literal does not match type `{kind}`"
|
||||
)));
|
||||
@@ -352,15 +368,7 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
|
||||
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) {
|
||||
if !values.iter().all(|value| literal_matches_type(kind, value)) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} enum literals do not match type `{kind}`"
|
||||
)));
|
||||
@@ -378,14 +386,15 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
|
||||
}
|
||||
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 outer_type = match schema.get("type") {
|
||||
Some(Value::String(kind)) if kind == "string" => 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<String, GenerationError
|
||||
"{context} contains unsupported ambiguous oneOf semantics"
|
||||
)));
|
||||
}
|
||||
if let Some(kind) = outer_type {
|
||||
if !literal_schema_matches_type(branch_object, kind) {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} oneOf branch does not match outer type `{kind}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
rendered.push(render_schema(branch, context)?);
|
||||
}
|
||||
return Ok(rendered.join(" | "));
|
||||
@@ -450,6 +466,15 @@ fn render_nullable_union(value: &Value, context: &str) -> Result<String, Generat
|
||||
"{context} contains unsupported ambiguous anyOf semantics"
|
||||
)));
|
||||
}
|
||||
let null_branch = branches
|
||||
.iter()
|
||||
.find(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
|
||||
.expect("validated one null branch");
|
||||
if render_schema(null_branch, context)? != "null" {
|
||||
return Err(GenerationError::invalid(format!(
|
||||
"{context} contains an invalid nullable null branch"
|
||||
)));
|
||||
}
|
||||
let branch = branches
|
||||
.iter()
|
||||
.find(|branch| branch.get("type").and_then(Value::as_str) != Some("null"))
|
||||
@@ -493,12 +518,14 @@ fn render_typed_schema(
|
||||
};
|
||||
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))
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const omittedItems = omitted.items as Array<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
const nullableItems = nullable.items as Array<Record<string, unknown>>;
|
||||
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<string, unknown>;
|
||||
const items = stale.items as Array<Record<string, unknown>>;
|
||||
|
||||
Reference in New Issue
Block a user