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/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/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..c9c5227 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) @@ -696,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( @@ -735,8 +746,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( 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