From cbadad479457d71a70cf900556e947eaf728e2fa Mon Sep 17 00:00:00 2001 From: tan Date: Sat, 29 Aug 2026 08:41:53 +0530 Subject: [PATCH 1/3] fix(server,client): decode typed parameters and sequential JSON correctly Smoke-test findings against live generated servers: - Emitted _finish_parameter re-decoded already-constructed generated value types (string enums and other named scalar types), validating the Julia struct against its raw JSON schema; every request with such a parameter failed with 400. Decode from the encoded wire form instead. - Sequential JSON media (ndjson, json-seq) can be documented either with an array schema covering the whole sequence (planned as a vector type) or with the schema of one record (planned as the item type, as the streaming path already supports via _stream_plan). The emitted server response encoder and the buffered client decoder only handled the array form: per-record declarations failed schema validation on both sides, and the client would then mis-decode the vector as a single item. Discriminate on the planned type and handle both, mirroring _stream_plan. --- src/runtime.jl | 33 +++++++++++++++++++++++++-------- src/servergen.jl | 24 +++++++++++++++++++++--- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/runtime.jl b/src/runtime.jl index 844d4bb..3e17689 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -1553,15 +1553,32 @@ function _decode_body(client::Client, type, content_type, body, schema) ) return _decode(type, value, client.validate_responses) elseif _is_sequential_json_media(media) + # Sequential media is documented either with an array schema covering + # the whole sequence (planned as a vector type) or with the schema of + # one record (planned as the item type); mirror _stream_plan and + # support both. value = _decode_sequential_json(body, media) - client.validate_responses && - _validate_schema(client.spec, - schema, - value, - "decoding a sequential JSON response"; - direction = :output, - ) - return _decode(type, value, client.validate_responses) + if type <: AbstractVector && type !== Vector{UInt8} + client.validate_responses && + _validate_schema(client.spec, + schema, + value, + "decoding a sequential JSON response"; + direction = :output, + ) + return _decode(type, value, client.validate_responses) + end + if client.validate_responses + for item in value + _validate_schema(client.spec, + schema, + item, + "decoding a sequential JSON response"; + direction = :output, + ) + end + end + return [_decode(type, item, client.validate_responses) for item in value] elseif startswith(media, "text/") || type === String isvalid(String, body) || throw(DecodeError("response text is not UTF-8")) value = String(copy(body)) diff --git a/src/servergen.jl b/src/servergen.jl index d60717e..1170a02 100644 --- a/src/servergen.jl +++ b/src/servergen.jl @@ -129,8 +129,12 @@ function _object_from_alternating(tokens, context; decode::Bool = false) end function _finish_parameter(descriptor, value, context) - _validate_schema(_SPEC, descriptor.schema, _encode(value), context; direction = :input) - return _decode(descriptor.type, value) + # _typed_scalar/_typed_array already construct generated value types (via + # _header_scalar), so decode from the encoded wire form: decoding the typed + # value itself would validate a Julia struct against its raw JSON schema. + encoded = _encode(value) + _validate_schema(_SPEC, descriptor.schema, encoded, context; direction = :input) + return _decode(descriptor.type, encoded) end function _decode_parameter_content(descriptor, text, context) @@ -735,8 +739,22 @@ function _server_response(operation, result) _validate_schema(_SPEC, entry.schema, lowered, context; direction = :output) payload = Vector{UInt8}(codeunits(JSON.json(lowered))) elseif _is_sequential_json_media(media) + # Sequential media is documented either with an array schema covering + # the whole sequence (planned as a vector type) or with the schema of + # one record (planned as the item type); validate accordingly. lowered = _encode(result) - _validate_schema(_SPEC, entry.schema, lowered, context; direction = :output) + lowered isa AbstractVector || lowered isa Tuple || throw(ArgumentError(string( + "operation ", + operation.id, + " documents a sequential JSON response; return an array or tuple of items", + ))) + if entry.type <: AbstractVector && entry.type !== Vector{UInt8} + _validate_schema(_SPEC, entry.schema, lowered, context; direction = :output) + else + for item in lowered + _validate_schema(_SPEC, entry.schema, item, context; direction = :output) + end + end payload = Vector{UInt8}(codeunits(_encode_sequential_json(result, media))) elseif startswith(media, "text/") result isa AbstractString || throw(ArgumentError(string( From d3649b31f2b3a61602c927f224ae3e4300fb080f Mon Sep 17 00:00:00 2001 From: tan Date: Sat, 29 Aug 2026 16:30:15 +0530 Subject: [PATCH 2/3] feat(server): tolerate operations that document no success response The OAS Responses Object MUST only contain one response code and merely SHOULD cover a successful response; common documents (petstore's addPet, updatePet, and five more) list only error codes. Generated servers previously answered such operations with a 500 at response-encoding time, after the handler had already run. Following the specification's stance that response documentation is non-exhaustive: - serverplan now emits a :missing_success_response warning per affected operation at planning time, instead of the failure surfacing per-request. - The emitted _server_response answers a handler's `nothing` with an empty 200 (matching what 0.2-lane clients expect); a typed return value still errors since there is no documented media to encode it against, and handlers can return a framework response for full control. --- src/planning.jl | 21 +++++++++++++++++++++ src/servergen.jl | 17 ++++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/planning.jl b/src/planning.jl index dcf9dc1..2e3ff39 100644 --- a/src/planning.jl +++ b/src/planning.jl @@ -1682,6 +1682,27 @@ end function _check_server_generation_support!(context::PlanningContext, strict::Bool) for operation in context.api.operations operation.direction === :request || continue + # The OAS Responses Object SHOULD cover a successful operation + # response; documents regularly violate that (petstore's addPet lists + # only 405). Handlers of such operations answer `nothing` with an + # empty 200 at runtime — surface that here instead of per-request. + if !any(operation.responses) do response + selector = uppercase(response.selector) + (!isempty(selector) && all(isdigit, selector) && startswith(selector, '2')) || + selector == "2XX" || selector == "DEFAULT" + end + _warning!( + context.bag, + :missing_success_response, + "operation $(repr(operation.id)) documents no success response; " * + "handlers returning `nothing` are answered with an empty 200, " * + "and any other response needs an explicit framework response", + SourceLocation( + operation.provenance.node.resource, + operation.provenance.node.pointer, + ), + ) + end # A form-style exploded object query or cookie parameter consumes # arbitrary wire names. One such parameter per location decodes from the # pairs no other declared parameter claimed; two or more are ambiguous. diff --git a/src/servergen.jl b/src/servergen.jl index 1170a02..c9c5227 100644 --- a/src/servergen.jl +++ b/src/servergen.jl @@ -700,11 +700,18 @@ end function _server_response(operation, result) descriptor = _success_response(operation.responses) - descriptor === nothing && throw(ArgumentError(string( - "operation ", - operation.id, - " documents no success response", - ))) + if descriptor === nothing + # The OAS Responses Object is non-exhaustive documentation, and some + # documents cover only error codes (flagged at planning time as + # :missing_success_response). Answer `nothing` with an empty 200; a + # typed value has no documented media to encode against. + result === nothing && return (200, Pair{String,String}[], UInt8[]) + throw(ArgumentError(string( + "operation ", + operation.id, + " documents no success response; return `nothing` for an empty 200 or a framework response", + ))) + end status = _selector_status(descriptor.selector) if isempty(descriptor.media) result === nothing || throw(ArgumentError(string( From 469e897107ef044ef27d9afda97eb0326d14ecf1 Mon Sep 17 00:00:00 2001 From: tan Date: Sat, 29 Aug 2026 17:31:15 +0530 Subject: [PATCH 3/3] feat(normalize): tolerate externally declared security schemes permissively The Security Requirement Object's names MUST correspond to schemes declared under Components, and strict mode continues to enforce that. But the specification's multi-document pattern explicitly supports a referenced document naming schemes its entry document declares (implicit connections are resolved from the entry document, 'treated as an interface for referenced documents to access'). Real service documents regularly ship this shape with the schemes held by a gateway; the previous behavior made such documents unprocessable even permissively. Under strict=false, :unknown_security_scheme is now a warning and the scheme is treated as externally declared: generation excludes it from operation security descriptors (the runtime cannot construct credentials for a scheme it cannot see), leaving a requirement group that named only external schemes as an anonymous alternative. Callers supply external auth explicitly, e.g. via request_headers. Verified live against the JuliaHub Job Secrets REST API document, which names openId/bearerAuth without declaring them: permissive client and server generation now succeed and the full lifecycle passes end to end. --- src/client.jl | 12 ++++++--- src/normalize.jl | 32 +++++++++++++++++++----- test/semantics.jl | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/src/client.jl b/src/client.jl index 583c611..b310e16 100644 --- a/src/client.jl +++ b/src/client.jl @@ -1070,14 +1070,19 @@ function _header_descriptor(header::NormalizedHeader, type::String) ", content = " * content * ")" end -function _security_descriptor(requirements) +function _security_descriptor(requirements, known_schemes) Base.@nospecialize requirements alternatives = String[] for requirement in requirements + # Permissive normalization keeps requirements naming schemes declared + # outside this document (warned as :unknown_security_scheme). The + # runtime cannot construct credentials for a scheme it cannot see, so + # exclude them; a group left empty is an anonymous alternative and the + # caller supplies external auth explicitly. entries = String[ "(name = " * repr(name) * ", scopes = " * _julia_literal(scopes) * ")" for - (name, scopes) in requirement.alternatives + (name, scopes) in requirement.alternatives if name in known_schemes ] push!(alternatives, "(" * join(entries, ',') * (length(entries) == 1 ? "," : "") * ")") end @@ -1131,7 +1136,8 @@ function _emit_operation_descriptor(io, operation::OperationPlan, api::Normalize ) end println(io, " ),") - println(io, " security = ", _security_descriptor(operation.operation.security), ",") + known_schemes = Set{String}(scheme.name for scheme in api.security_schemes) + println(io, " security = ", _security_descriptor(operation.operation.security, known_schemes), ",") println( io, " servers = ", diff --git a/src/normalize.jl b/src/normalize.jl index ea38921..3a17341 100644 --- a/src/normalize.jl +++ b/src/normalize.jl @@ -1748,12 +1748,32 @@ function _validate_security_references!(context, requirements, schemes) end end scheme === nothing && begin - _reference_error!( - context.resolver, - :unknown_security_scheme, - "security requirement refers to unknown scheme $(repr(name))", - requirement.provenance.node, - ) + # Strict mode enforces the Security Requirement Object's MUST + # (the name corresponds to a declared scheme). Permissive mode + # supports the specification's multi-document pattern, where a + # referenced document names schemes its entry document + # declares: the scheme is treated as externally declared, and + # generation excludes it from credential enforcement. + if context.resolver.strict + _reference_error!( + context.resolver, + :unknown_security_scheme, + "security requirement refers to unknown scheme $(repr(name))", + requirement.provenance.node, + ) + else + _warning!( + context.resolver.bag, + :unknown_security_scheme, + "security requirement refers to unknown scheme $(repr(name)); " * + "treating it as externally declared and excluding it from " * + "generated credential enforcement", + SourceLocation( + requirement.provenance.node.resource, + requirement.provenance.node.pointer, + ), + ) + end continue end if version.minor < 2 && diff --git a/test/semantics.jl b/test/semantics.jl index 19e642f..ab532a6 100644 --- a/test/semantics.jl +++ b/test/semantics.jl @@ -470,4 +470,68 @@ end @test :unknown_oauth_scope in codes @test :duplicate_server_name in codes end + + @testset "externally declared security schemes" begin + # The Security Requirement Object's names MUST correspond to declared + # schemes, but the specification's multi-document pattern lets a + # referenced document name schemes its entry document declares. Strict + # mode enforces the MUST; permissive mode warns and generation excludes + # the unknown scheme from credential enforcement. + document = minimal_openapi( + "3.0.3", + OpenAPI.obj( + "/things" => OpenAPI.obj( + "get" => semantic_operation( + "listThings"; + responses = OpenAPI.obj( + "200" => OpenAPI.obj( + "description" => "ok", + "content" => OpenAPI.obj( + "application/json" => OpenAPI.obj( + "schema" => OpenAPI.obj("type" => "string"), + ), + ), + ), + ), + security = Any[ + OpenAPI.obj("gatewayAuth" => Any[]), + OpenAPI.obj("localKey" => Any[]), + ], + ), + ), + ), + ) + document["components"] = OpenAPI.obj( + "securitySchemes" => OpenAPI.obj( + "localKey" => OpenAPI.obj( + "type" => "apiKey", + "name" => "X-Key", + "in" => "header", + ), + ), + ) + + strict_error = @test_throws OpenAPI.OpenAPIError OpenAPI.normalize(document) + @test any( + diagnostic -> diagnostic.code === :unknown_security_scheme, + strict_error.value.diagnostics, + ) + + permissive = OpenAPI.normalize(document; strict = false) + warning = only( + diagnostic for diagnostic in permissive.diagnostics if + diagnostic.code === :unknown_security_scheme + ) + @test warning.severity === :warning + + # the unknown scheme is excluded from the generated operation security: + # its alternative becomes anonymous, and the declared scheme survives + source = OpenAPI.client(permissive; name = "ExternalAuthClient", strict = false) + @test occursin("security = ((),((name = \"localKey\", scopes = ()),)),", source) + # the unknown scheme survives only inside the embedded raw document, + # never as an enforceable descriptor or scheme entry + @test !occursin("name = \"gatewayAuth\"", source) + @test !occursin("\"gatewayAuth\" =>", source) + @test occursin("\"localKey\" => (type = :apikey", source) + end end