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
1 change: 1 addition & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ OpenAPI.server("openapi.json"; name = "MyServer", path = "MyServer.jl")
| `pre_request_hook`, `get_return_type` | `request_headers` / `request_options` keywords; typed responses come from the document |
| Chunk readers (`LineChunkReader`, …) for streaming | `stream_to::Channel` keyword; framing follows the response media type, customizable with `codec!` |
| `httplib = Downloads` or `HTTP` backends | HTTP.jl only |
| `Client(url; escape_path_params = false)` | Declare `allowReserved: true` on the path parameter in the document; the generated client then leaves reserved characters such as `/` unescaped for that parameter. Requires a 3.0 or 3.2 document — 3.1 scopes `allowReserved` to query parameters and rejects it on a path parameter at load time |
| Constructor/`setproperty!` validation, `val_format` overloads | Full JSON Schema validation at encode/decode time; disable per client with `validate_requests` / `validate_responses` |
| `mutable struct` models, `haspropertyat` / `getpropertyat` | Immutable keyword-constructed structs; optional absent fields are `ABSENT` |

Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ keywords = ["Swagger", "OpenAPI", "REST"]
license = "MIT"
desc = "OpenAPI server and client helper for Julia"
authors = ["JuliaHub Inc."]
version = "1.1.0"
version = "1.1.1"

[deps]
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
Expand Down
8 changes: 7 additions & 1 deletion docs/src/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,13 @@ Generated clients support:
`deepObject` serialization where the specification permits each style,
plus the bracket-path `deepObject` extension for arrays and nested values
(see [deepObject bracket paths](@ref));
- `allowReserved`, `allowEmptyValue`, explode defaults, and parameter `content`;
- `allowReserved`, `allowEmptyValue`, explode defaults, and parameter `content`.
`allowReserved: true` is honoured on path parameters too, so a
slash-delimited value such as an OPA document path is sent as-is instead of
with every `/` percent-encoded. OAS 3.2 documents this for path parameters
and 3.0 tolerates it, but 3.1 allows `allowReserved` only on query
parameters, so a 3.1 document that declares it on a path parameter fails
validation when the document is loaded;
- JSON and structured-suffix JSON media types;
- text and binary bodies;
- `application/x-www-form-urlencoded` bodies;
Expand Down
7 changes: 7 additions & 0 deletions docs/src/servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ the selected JSON schema accepts null. A full `HTTP.Response` bypasses
generated status, header, and body validation. The handler owns that
validation.

One client capability has no server counterpart yet: a path parameter declared
`allowReserved: true`. Generated clients send such a value with its reserved
characters intact, so a slash-delimited value spans several path segments, but
generated servers register the path template as written and `HTTP.Router`
matches `{name}` against a single segment. Those requests reach the router as
`404`s rather than the handler.

### deepObject bracket paths

OAS 3.x defines `deepObject` only for objects whose property values are
Expand Down
71 changes: 50 additions & 21 deletions src/runtime.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1308,28 +1308,46 @@ function _join_object(value, pair_delimiter, key_delimiter)
)
end

_path_scalar(value) = _escape(_scalar(value))
# `allow_reserved` is honoured for path parameters as well as query parameters.
# OAS 3.2 lists `allowReserved` under the path-parameter branch of the Parameter
# Object (`styles-for-path` in `schemas/oas-3.2.json`), so this is conformant,
# not an extension: reserved characters go on the wire as-is. It matters for
# APIs whose path parameters are themselves slash-delimited paths (OPA data
# documents, proxied object paths) — without it every `/` becomes `%2F` and the
# server sees a single segment.
#
# Version caveat: 3.0 tolerates the field on a path parameter and 3.2 blesses
# it, but 3.1 scopes it to `in: query` under `unevaluatedProperties: false`, so
# a 3.1 document that declares it fails document validation outright (even with
# `strict = false`) rather than reaching this code.
_path_scalar(value; allow_reserved::Bool = false) = _escape(_scalar(value); allow_reserved)

function _path_array(value, delimiter)
function _path_array(value, delimiter; allow_reserved::Bool = false)
value isa AbstractVector || value isa Tuple ||
throw(ArgumentError("parameter style requires an array value"))
return join((_path_scalar(item) for item in value), delimiter)
return join((_path_scalar(item; allow_reserved) for item in value), delimiter)
end

function _path_object(value, pair_delimiter, key_delimiter)
function _path_object(value, pair_delimiter, key_delimiter; allow_reserved::Bool = false)
return join(
(
string(
_path_scalar(key),
_path_scalar(key; allow_reserved),
key_delimiter,
_path_scalar(item),
_path_scalar(item; allow_reserved),
) for (key, item) in _pairs(value)
),
pair_delimiter,
)
end

function _path_parameter(name, value, style::Symbol, explode::Bool)
function _path_parameter(
name,
value,
style::Symbol,
explode::Bool;
allow_reserved::Bool = false,
)
encoded = _encode(value)
if encoded === nothing
style === :matrix && return ";" * _path_scalar(name)
Expand All @@ -1338,26 +1356,29 @@ function _path_parameter(name, value, style::Symbol, explode::Bool)
end
if style === :simple
encoded isa AbstractDict && return explode ?
_path_object(encoded, ",", "=") : _path_object(encoded, ",", ",")
encoded isa AbstractVector && return _path_array(encoded, ",")
return _path_scalar(encoded)
_path_object(encoded, ",", "="; allow_reserved) :
_path_object(encoded, ",", ","; allow_reserved)
encoded isa AbstractVector && return _path_array(encoded, ","; allow_reserved)
return _path_scalar(encoded; allow_reserved)
elseif style === :label
encoded isa AbstractDict && return "." * (explode ?
_path_object(encoded, ".", "=") : _path_object(encoded, ",", ","))
encoded isa AbstractVector && return "." * _path_array(encoded, explode ? "." : ",")
return "." * _path_scalar(encoded)
_path_object(encoded, ".", "="; allow_reserved) :
_path_object(encoded, ",", ","; allow_reserved))
encoded isa AbstractVector &&
return "." * _path_array(encoded, explode ? "." : ","; allow_reserved)
return "." * _path_scalar(encoded; allow_reserved)
elseif style === :matrix
encoded_name = _path_scalar(name)
if encoded isa AbstractDict
return explode ?
join((";" * _path_scalar(key) * "=" * _path_scalar(item) for (key, item) in _pairs(encoded))) :
";" * encoded_name * "=" * _path_object(encoded, ",", ",")
join((";" * _path_scalar(key) * "=" * _path_scalar(item; allow_reserved) for (key, item) in _pairs(encoded))) :
";" * encoded_name * "=" * _path_object(encoded, ",", ","; allow_reserved)
elseif encoded isa AbstractVector
return explode ?
join((";" * encoded_name * "=" * _path_scalar(item) for item in encoded)) :
";" * encoded_name * "=" * _path_array(encoded, ",")
join((";" * encoded_name * "=" * _path_scalar(item; allow_reserved) for item in encoded)) :
";" * encoded_name * "=" * _path_array(encoded, ","; allow_reserved)
end
return ";" * encoded_name * "=" * _path_scalar(encoded)
return ";" * encoded_name * "=" * _path_scalar(encoded; allow_reserved)
end
throw(ArgumentError("unsupported path parameter style $style"))
end
Expand Down Expand Up @@ -1550,8 +1571,10 @@ function _append_parameter!(client, path, query, headers, cookies, descriptor, v
if location === :path
path = replace(
path,
"{" * descriptor.name * "}" =>
(preencoded ? serialized : _escape(serialized)),
"{" * descriptor.name * "}" => (
preencoded ? serialized :
_escape(serialized; allow_reserved = descriptor.allow_reserved)
),
)
elseif location === :query
push!(
Expand All @@ -1571,7 +1594,13 @@ function _append_parameter!(client, path, query, headers, cookies, descriptor, v
throw(ArgumentError("unsupported parameter location $location"))
end
elseif location === :path
serialized = _path_parameter(descriptor.name, value, style, explode)
serialized = _path_parameter(
descriptor.name,
value,
style,
explode;
allow_reserved = descriptor.allow_reserved,
)
path = replace(path, "{" * descriptor.name * "}" => serialized)
elseif location === :query
for (name, item, preencoded) in _query_parameter(
Expand Down
11 changes: 11 additions & 0 deletions test/runtime.jl
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,17 @@
@test invoke(:_escape, reserved; allow_reserved = true) == reserved
@test invoke(:_escape, "%2F"; allow_reserved = true) == "%2F"
@test invoke(:_escape, "a b") == "a%20b"
# allowReserved on a path parameter keeps slash-delimited values intact
@test invoke(:_path_parameter, "path", "opa/examples/public servers", :simple, false) ==
"opa%2Fexamples%2Fpublic%20servers"
@test invoke(:_path_parameter, "path", "opa/examples/public servers", :simple, false;
allow_reserved = true) == "opa/examples/public%20servers"
@test invoke(:_path_parameter, "path", ["a/b", "c d"], :simple, false;
allow_reserved = true) == "a/b,c%20d"
@test invoke(:_path_parameter, "path", "a/b", :label, false; allow_reserved = true) ==
".a/b"
@test invoke(:_path_parameter, "path", "a/b", :matrix, false; allow_reserved = true) ==
";path=a/b"
@test invoke(:_safe_header, "X-Test", "ok") == ("X-Test" => "ok")
@test_throws ArgumentError invoke(:_safe_header, "Bad Header", "ok")
@test_throws ArgumentError invoke(:_safe_header, "X-Test", "ok\r\nInjected: x")
Expand Down
34 changes: 34 additions & 0 deletions test/runtime_integration.jl
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ end
return HTTP.Response(200, ["Content-Type" => "application/json"], body)
elseif startswith(path, "/form") || startswith(path, "/multipart")
return HTTP.Response(200, ["Content-Type" => "text/plain"], "accepted")
elseif startswith(path, "/documents/")
return HTTP.Response(200, ["Content-Type" => "application/json"], """{"ok":true}""")
elseif startswith(path, "/secure")
return HTTP.Response(200, ["Content-Type" => "text/plain"], "authorized")
end
Expand Down Expand Up @@ -432,6 +434,22 @@ end
),
),
),
"/documents/{path}" => OpenAPI.obj(
"get" => OpenAPI.obj(
"operationId" => "getDocument",
"parameters" => Any[
runtime_parameter(
"path",
"path",
string_schema;
allow_reserved = true,
),
],
"responses" => OpenAPI.obj(
"200" => runtime_response("application/json", OpenAPI.obj()),
),
),
),
"/status/{code}" => OpenAPI.obj(
"get" => OpenAPI.obj(
"operationId" => "statusResult",
Expand Down Expand Up @@ -826,6 +844,22 @@ end
take_request() = take!(captures)
client = C.Client()

@testset "allowReserved on a path parameter" begin
# A slash-delimited document path (OPA style) must reach the server
# as path segments, not as one %2F-joined segment; other unsafe
# characters are still percent-encoded.
result = call(
:getdocument,
"opa/examples/public servers";
client,
with_http_info = true,
)
request = take_request()
@test request.target == "/documents/opa/examples/public%20servers"
@test result.status == 200
@test result.body["ok"] === true
end

@testset "parameters, servers, and request overrides" begin
result = call(
:serializestyles,
Expand Down
Loading