fix(repository-api): preserve optional fields and schema combinations
This commit is contained in:
@@ -308,6 +308,25 @@ fn ensure_schema_keywords(
|
|||||||
Ok(())
|
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> {
|
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") {
|
||||||
@@ -327,10 +346,7 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
|
|||||||
let kind = type_value.as_str().ok_or_else(|| {
|
let kind = type_value.as_str().ok_or_else(|| {
|
||||||
GenerationError::invalid(format!("{context} const type must be a string"))
|
GenerationError::invalid(format!("{context} const type must be a string"))
|
||||||
})?;
|
})?;
|
||||||
if !matches!(
|
if !literal_matches_type(kind, constant) {
|
||||||
(kind, constant),
|
|
||||||
("string", Value::String(_)) | ("boolean", Value::Bool(_)) | ("null", Value::Null)
|
|
||||||
) {
|
|
||||||
return Err(GenerationError::invalid(format!(
|
return Err(GenerationError::invalid(format!(
|
||||||
"{context} const literal does not match type `{kind}`"
|
"{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(|| {
|
let kind = type_value.as_str().ok_or_else(|| {
|
||||||
GenerationError::invalid(format!("{context} enum type must be a string"))
|
GenerationError::invalid(format!("{context} enum type must be a string"))
|
||||||
})?;
|
})?;
|
||||||
let matches_type = |value: &Value| {
|
if !values.iter().all(|value| literal_matches_type(kind, value)) {
|
||||||
matches!(
|
|
||||||
(kind, value),
|
|
||||||
("string", Value::String(_))
|
|
||||||
| ("boolean", Value::Bool(_))
|
|
||||||
| ("null", Value::Null)
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if !values.iter().all(matches_type) {
|
|
||||||
return Err(GenerationError::invalid(format!(
|
return Err(GenerationError::invalid(format!(
|
||||||
"{context} enum literals do not match type `{kind}`"
|
"{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") {
|
if let Some(branches) = schema.get("oneOf") {
|
||||||
ensure_schema_keywords(schema, &["oneOf", "type"], context)?;
|
ensure_schema_keywords(schema, &["oneOf", "type"], context)?;
|
||||||
if schema
|
let outer_type = match schema.get("type") {
|
||||||
.get("type")
|
Some(Value::String(kind)) if kind == "string" => Some(kind.as_str()),
|
||||||
.is_some_and(|kind| kind.as_str() != Some("string"))
|
Some(_) => {
|
||||||
{
|
return Err(GenerationError::invalid(format!(
|
||||||
return Err(GenerationError::invalid(format!(
|
"{context} contains an unsupported oneOf type"
|
||||||
"{context} contains an unsupported oneOf type"
|
)));
|
||||||
)));
|
}
|
||||||
}
|
None => None,
|
||||||
|
};
|
||||||
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"))
|
||||||
})?;
|
})?;
|
||||||
@@ -402,6 +411,13 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
|
|||||||
"{context} contains unsupported ambiguous oneOf semantics"
|
"{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)?);
|
rendered.push(render_schema(branch, context)?);
|
||||||
}
|
}
|
||||||
return Ok(rendered.join(" | "));
|
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"
|
"{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
|
let branch = branches
|
||||||
.iter()
|
.iter()
|
||||||
.find(|branch| branch.get("type").and_then(Value::as_str) != Some("null"))
|
.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 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)
|
let expected_minimum = if format == Some("uint64") {
|
||||||
|| maximum.is_none_or(|maximum| maximum > MAX_SAFE_INTEGER)
|
0.0
|
||||||
|| (format == Some("uint64") && minimum.is_none_or(|minimum| minimum < 0.0))
|
} else {
|
||||||
{
|
MIN_SAFE_INTEGER
|
||||||
|
};
|
||||||
|
if minimum != Some(expected_minimum) || maximum != Some(MAX_SAFE_INTEGER) {
|
||||||
return Err(GenerationError::invalid(format!(
|
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())
|
Ok("number".to_owned())
|
||||||
@@ -672,6 +699,12 @@ mod tests {
|
|||||||
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-number"));
|
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();
|
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||||
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] =
|
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] =
|
||||||
serde_json::json!("int64");
|
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]
|
#[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();
|
||||||
|
|||||||
@@ -235,8 +235,8 @@ function gitSummary(value: unknown, path: string): GitRepositorySummary {
|
|||||||
exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path);
|
exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path);
|
||||||
return {
|
return {
|
||||||
status: repositoryString(git.status, `${path}.status`),
|
status: repositoryString(git.status, `${path}.status`),
|
||||||
head: nullableRepositoryString(git.head, `${path}.head`),
|
head: optionalNullableRepositoryString(git.head, `${path}.head`),
|
||||||
branch: nullableRepositoryString(git.branch, `${path}.branch`),
|
branch: optionalNullableRepositoryString(git.branch, `${path}.branch`),
|
||||||
dirty: boolean(git.dirty, `${path}.dirty`),
|
dirty: boolean(git.dirty, `${path}.dirty`),
|
||||||
remotes: repositoryArray(git.remotes, `${path}.remotes`).map((
|
remotes: repositoryArray(git.remotes, `${path}.remotes`).map((
|
||||||
item,
|
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", () => {
|
Deno.test("plain HTTP repository source kind fails closed at the JSON boundary", () => {
|
||||||
const stale = structuredClone(repositoryList) as Record<string, unknown>;
|
const stale = structuredClone(repositoryList) as Record<string, unknown>;
|
||||||
const items = stale.items as Array<Record<string, unknown>>;
|
const items = stale.items as Array<Record<string, unknown>>;
|
||||||
|
|||||||
Reference in New Issue
Block a user