diff --git a/.changeset/const-is-a-value.md b/.changeset/const-is-a-value.md new file mode 100644 index 0000000..973c185 --- /dev/null +++ b/.changeset/const-is-a-value.md @@ -0,0 +1,9 @@ +--- +'fiber': patch +--- + +Read `const` when building a body from a schema. + +OpenAPI 3.1 uses JSON Schema 2020-12, where a literal is written as `const` and a literal union as `anyOf: [{const: "once"}, {const: "always"}]`. That is what 3.1 generators emit where 3.0 would have written an `enum`. Fiber read `example`, `default` and `enum`, but not `const` — so it walked into the first branch, found nothing to go on, and printed the `string` placeholder for a field whose only legal values were named right there. Sending that body back got it rejected by the very document it came from. + +One real spec this was found against writes 543 `const`s and not a single `enum`, so the existing `enum` handling never fired once across 658 paths. A `const` is now taken as the value it names, in request skeletons and in form fields alike. diff --git a/src-tauri/src/openapi.rs b/src-tauri/src/openapi.rs index 2729427..70f3526 100644 --- a/src-tauri/src/openapi.rs +++ b/src-tauri/src/openapi.rs @@ -345,6 +345,16 @@ fn example_string(schema: &serde_json::Value) -> String { schema .get("example") .or_else(|| schema.get("default")) + // Same reasoning as `skeleton`: a `const` is the value, stated. A form + // field whose schema pins it to one string should arrive holding it + // rather than empty. + .or_else(|| schema.get("const")) + .or_else(|| { + schema + .get("enum") + .and_then(|it| it.as_array()) + .and_then(|it| it.first()) + }) .map(|value| match value { serde_json::Value::String(text) => text.clone(), other => other.to_string(), @@ -597,7 +607,15 @@ fn skeleton( }; // Anything the document states outright beats anything we invent. - for key in ["example", "default"] { + // + // `const` is JSON Schema's single-value `enum`, and OpenAPI 3.1 documents + // lean on it heavily: a literal union is written as + // `anyOf: [{const: "once"}, {const: "always"}]`, which is what most 3.1 + // generators emit where 3.0 would have written an `enum`. Missing it meant + // walking into the first branch, finding nothing to go on, and printing the + // `string` placeholder for a field whose only legal values were right + // there — so the body came back rejected by the very schema it came from. + for key in ["example", "default", "const"] { if let Some(value) = object.get(key) { return value.clone(); } @@ -1100,6 +1118,57 @@ paths: assert!(body.contains("\"anything\": unknown"), "{body}"); } + /// How OpenAPI 3.1 writes a literal union — and 3.0's `enum` never appears + /// in documents that do. Emitting the `string` placeholder for a field + /// whose only legal values are named right there produced a body the + /// document's own validator rejected. + #[test] + fn a_const_is_the_value_it_names() { + let spec = r##"{ + "openapi": "3.1.1", + "info": { "title": "Acme", "version": "1" }, + "paths": { + "/agent/deny-tool-call": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["agentId", "seq", "scope"], + "properties": { + "agentId": { "type": "string" }, + "seq": { "type": "integer", "minimum": 1 }, + "scope": { + "anyOf": [ + { "type": "string", "const": "once" }, + { "type": "string", "const": "always" } + ] + }, + "kind": { "const": "denial" } + } + } + } + } + } + } + } + } + }"##; + + let import = parse(spec).unwrap(); + let body = &import.endpoints[0].body; + + // The first branch of the choice, and its value rather than its type. + assert!(body.contains("\"scope\": \"once\""), "{body}"); + // A bare `const`, with no choice around it. + assert!(body.contains("\"kind\": \"denial\""), "{body}"); + // Everything else still shows what to type. + assert!(body.contains("\"agentId\": string"), "{body}"); + assert!(body.contains("\"seq\": integer"), "{body}"); + } + /// The other spelling of nullable, and the one real specs in the wild use: /// a choice with a `null` branch in it. Which side it sits on is arbitrary, /// so the branch that says something wins either way.