Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = ",
Expand Down
32 changes: 26 additions & 6 deletions src/normalize.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
21 changes: 21 additions & 0 deletions src/planning.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 25 additions & 8 deletions src/runtime.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
41 changes: 33 additions & 8 deletions src/servergen.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
64 changes: 64 additions & 0 deletions test/semantics.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading