Skip to content

Commit 62818a5

Browse files
authored
fix(runtime): honour allowReserved on path parameters (#112)
* fix(runtime): honour allowReserved on path 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 honouring it there is conformant rather than an extension: reserved characters go on the wire as-is. It matters for documents whose path parameters are themselves slash-delimited paths (OPA data documents, proxied object paths). Until now _path_parameter ignored the flag, so every '/' became %2F and the server saw a single segment; the planner already recorded allow_reserved on the descriptor, so no regeneration is needed. Version caveat: 3.0 tolerates the field on a path parameter and 3.2 blesses it, but 3.1 scopes it to query parameters under unevaluatedProperties:false, so a 3.1 document declaring it fails to load at all, even with strict = false. Thread allow_reserved through _path_scalar/_path_array/_path_object and the parameter-content branch, keep percent-encoding everything else, and cover it with unit and HTTP integration tests. Document the behaviour and the 0.2.x escape_path_params migration path. Bump to 1.1.1. * docs(allowReserved): document the version caveat and the server gap The src/runtime.jl comment introduced with the path-parameter allowReserved fix framed the behaviour as a pragmatic deviation from the spec. It is not: OAS 3.2 lists allowReserved under the path-parameter branch of the Parameter Object (styles-for-path in schemas/oas-3.2.json). Rewrite the comment to cite the schema file rather than assert a deviation. The real constraint is a version caveat, verified against all three bundled schemas by generating a client and a server from a document declaring allowReserved on a path parameter, under the default strict = true: 3.0 -> accepted (generic Parameter property; PathParameter does not forbid it) 3.1 -> rejected; scoped to styles-for-query under unevaluatedProperties:false, so the document fails to load at all, even with strict = false 3.2 -> accepted, explicitly That matters most for the MIGRATION.md row, which targets people coming off 0.2.x escape_path_params = false and who are likely to hold a 3.1 spec; they would hit a load error rather than the feature. Add the caveat there and in docs/src/clients.md. Also record in docs/src/servers.md that generated servers cannot yet route such a value: they register the path template as written and HTTP.Router matches {name} against a single segment, so a request carrying an unescaped '/' 404s before reaching the handler. Client and server generated from one document therefore cannot talk to each other for that parameter. Tracked in #113. Comment and docs only; no behaviour change.
1 parent 23482d6 commit 62818a5

7 files changed

Lines changed: 111 additions & 23 deletions

File tree

‎MIGRATION.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ OpenAPI.server("openapi.json"; name = "MyServer", path = "MyServer.jl")
4646
| `pre_request_hook`, `get_return_type` | `request_headers` / `request_options` keywords; typed responses come from the document |
4747
| Chunk readers (`LineChunkReader`, …) for streaming | `stream_to::Channel` keyword; framing follows the response media type, customizable with `codec!` |
4848
| `httplib = Downloads` or `HTTP` backends | HTTP.jl only |
49+
| `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 |
4950
| Constructor/`setproperty!` validation, `val_format` overloads | Full JSON Schema validation at encode/decode time; disable per client with `validate_requests` / `validate_responses` |
5051
| `mutable struct` models, `haspropertyat` / `getpropertyat` | Immutable keyword-constructed structs; optional absent fields are `ABSENT` |
5152

‎Project.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ keywords = ["Swagger", "OpenAPI", "REST"]
44
license = "MIT"
55
desc = "OpenAPI server and client helper for Julia"
66
authors = ["JuliaHub Inc."]
7-
version = "1.1.0"
7+
version = "1.1.1"
88

99
[deps]
1010
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"

‎docs/src/clients.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,13 @@ Generated clients support:
8888
`deepObject` serialization where the specification permits each style,
8989
plus the bracket-path `deepObject` extension for arrays and nested values
9090
(see [deepObject bracket paths](@ref));
91-
- `allowReserved`, `allowEmptyValue`, explode defaults, and parameter `content`;
91+
- `allowReserved`, `allowEmptyValue`, explode defaults, and parameter `content`.
92+
`allowReserved: true` is honoured on path parameters too, so a
93+
slash-delimited value such as an OPA document path is sent as-is instead of
94+
with every `/` percent-encoded. OAS 3.2 documents this for path parameters
95+
and 3.0 tolerates it, but 3.1 allows `allowReserved` only on query
96+
parameters, so a 3.1 document that declares it on a path parameter fails
97+
validation when the document is loaded;
9298
- JSON and structured-suffix JSON media types;
9399
- text and binary bodies;
94100
- `application/x-www-form-urlencoded` bodies;

‎docs/src/servers.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ the selected JSON schema accepts null. A full `HTTP.Response` bypasses
121121
generated status, header, and body validation. The handler owns that
122122
validation.
123123

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

126133
OAS 3.x defines `deepObject` only for objects whose property values are

‎src/runtime.jl‎

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,28 +1308,46 @@ function _join_object(value, pair_delimiter, key_delimiter)
13081308
)
13091309
end
13101310

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

1313-
function _path_array(value, delimiter)
1325+
function _path_array(value, delimiter; allow_reserved::Bool = false)
13141326
value isa AbstractVector || value isa Tuple ||
13151327
throw(ArgumentError("parameter style requires an array value"))
1316-
return join((_path_scalar(item) for item in value), delimiter)
1328+
return join((_path_scalar(item; allow_reserved) for item in value), delimiter)
13171329
end
13181330

1319-
function _path_object(value, pair_delimiter, key_delimiter)
1331+
function _path_object(value, pair_delimiter, key_delimiter; allow_reserved::Bool = false)
13201332
return join(
13211333
(
13221334
string(
1323-
_path_scalar(key),
1335+
_path_scalar(key; allow_reserved),
13241336
key_delimiter,
1325-
_path_scalar(item),
1337+
_path_scalar(item; allow_reserved),
13261338
) for (key, item) in _pairs(value)
13271339
),
13281340
pair_delimiter,
13291341
)
13301342
end
13311343

1332-
function _path_parameter(name, value, style::Symbol, explode::Bool)
1344+
function _path_parameter(
1345+
name,
1346+
value,
1347+
style::Symbol,
1348+
explode::Bool;
1349+
allow_reserved::Bool = false,
1350+
)
13331351
encoded = _encode(value)
13341352
if encoded === nothing
13351353
style === :matrix && return ";" * _path_scalar(name)
@@ -1338,26 +1356,29 @@ function _path_parameter(name, value, style::Symbol, explode::Bool)
13381356
end
13391357
if style === :simple
13401358
encoded isa AbstractDict && return explode ?
1341-
_path_object(encoded, ",", "=") : _path_object(encoded, ",", ",")
1342-
encoded isa AbstractVector && return _path_array(encoded, ",")
1343-
return _path_scalar(encoded)
1359+
_path_object(encoded, ",", "="; allow_reserved) :
1360+
_path_object(encoded, ",", ","; allow_reserved)
1361+
encoded isa AbstractVector && return _path_array(encoded, ","; allow_reserved)
1362+
return _path_scalar(encoded; allow_reserved)
13441363
elseif style === :label
13451364
encoded isa AbstractDict && return "." * (explode ?
1346-
_path_object(encoded, ".", "=") : _path_object(encoded, ",", ","))
1347-
encoded isa AbstractVector && return "." * _path_array(encoded, explode ? "." : ",")
1348-
return "." * _path_scalar(encoded)
1365+
_path_object(encoded, ".", "="; allow_reserved) :
1366+
_path_object(encoded, ",", ","; allow_reserved))
1367+
encoded isa AbstractVector &&
1368+
return "." * _path_array(encoded, explode ? "." : ","; allow_reserved)
1369+
return "." * _path_scalar(encoded; allow_reserved)
13491370
elseif style === :matrix
13501371
encoded_name = _path_scalar(name)
13511372
if encoded isa AbstractDict
13521373
return explode ?
1353-
join((";" * _path_scalar(key) * "=" * _path_scalar(item) for (key, item) in _pairs(encoded))) :
1354-
";" * encoded_name * "=" * _path_object(encoded, ",", ",")
1374+
join((";" * _path_scalar(key) * "=" * _path_scalar(item; allow_reserved) for (key, item) in _pairs(encoded))) :
1375+
";" * encoded_name * "=" * _path_object(encoded, ",", ","; allow_reserved)
13551376
elseif encoded isa AbstractVector
13561377
return explode ?
1357-
join((";" * encoded_name * "=" * _path_scalar(item) for item in encoded)) :
1358-
";" * encoded_name * "=" * _path_array(encoded, ",")
1378+
join((";" * encoded_name * "=" * _path_scalar(item; allow_reserved) for item in encoded)) :
1379+
";" * encoded_name * "=" * _path_array(encoded, ","; allow_reserved)
13591380
end
1360-
return ";" * encoded_name * "=" * _path_scalar(encoded)
1381+
return ";" * encoded_name * "=" * _path_scalar(encoded; allow_reserved)
13611382
end
13621383
throw(ArgumentError("unsupported path parameter style $style"))
13631384
end
@@ -1550,8 +1571,10 @@ function _append_parameter!(client, path, query, headers, cookies, descriptor, v
15501571
if location === :path
15511572
path = replace(
15521573
path,
1553-
"{" * descriptor.name * "}" =>
1554-
(preencoded ? serialized : _escape(serialized)),
1574+
"{" * descriptor.name * "}" => (
1575+
preencoded ? serialized :
1576+
_escape(serialized; allow_reserved = descriptor.allow_reserved)
1577+
),
15551578
)
15561579
elseif location === :query
15571580
push!(
@@ -1571,7 +1594,13 @@ function _append_parameter!(client, path, query, headers, cookies, descriptor, v
15711594
throw(ArgumentError("unsupported parameter location $location"))
15721595
end
15731596
elseif location === :path
1574-
serialized = _path_parameter(descriptor.name, value, style, explode)
1597+
serialized = _path_parameter(
1598+
descriptor.name,
1599+
value,
1600+
style,
1601+
explode;
1602+
allow_reserved = descriptor.allow_reserved,
1603+
)
15751604
path = replace(path, "{" * descriptor.name * "}" => serialized)
15761605
elseif location === :query
15771606
for (name, item, preencoded) in _query_parameter(

‎test/runtime.jl‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,17 @@
424424
@test invoke(:_escape, reserved; allow_reserved = true) == reserved
425425
@test invoke(:_escape, "%2F"; allow_reserved = true) == "%2F"
426426
@test invoke(:_escape, "a b") == "a%20b"
427+
# allowReserved on a path parameter keeps slash-delimited values intact
428+
@test invoke(:_path_parameter, "path", "opa/examples/public servers", :simple, false) ==
429+
"opa%2Fexamples%2Fpublic%20servers"
430+
@test invoke(:_path_parameter, "path", "opa/examples/public servers", :simple, false;
431+
allow_reserved = true) == "opa/examples/public%20servers"
432+
@test invoke(:_path_parameter, "path", ["a/b", "c d"], :simple, false;
433+
allow_reserved = true) == "a/b,c%20d"
434+
@test invoke(:_path_parameter, "path", "a/b", :label, false; allow_reserved = true) ==
435+
".a/b"
436+
@test invoke(:_path_parameter, "path", "a/b", :matrix, false; allow_reserved = true) ==
437+
";path=a/b"
427438
@test invoke(:_safe_header, "X-Test", "ok") == ("X-Test" => "ok")
428439
@test_throws ArgumentError invoke(:_safe_header, "Bad Header", "ok")
429440
@test_throws ArgumentError invoke(:_safe_header, "X-Test", "ok\r\nInjected: x")

‎test/runtime_integration.jl‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ end
137137
return HTTP.Response(200, ["Content-Type" => "application/json"], body)
138138
elseif startswith(path, "/form") || startswith(path, "/multipart")
139139
return HTTP.Response(200, ["Content-Type" => "text/plain"], "accepted")
140+
elseif startswith(path, "/documents/")
141+
return HTTP.Response(200, ["Content-Type" => "application/json"], """{"ok":true}""")
140142
elseif startswith(path, "/secure")
141143
return HTTP.Response(200, ["Content-Type" => "text/plain"], "authorized")
142144
end
@@ -432,6 +434,22 @@ end
432434
),
433435
),
434436
),
437+
"/documents/{path}" => OpenAPI.obj(
438+
"get" => OpenAPI.obj(
439+
"operationId" => "getDocument",
440+
"parameters" => Any[
441+
runtime_parameter(
442+
"path",
443+
"path",
444+
string_schema;
445+
allow_reserved = true,
446+
),
447+
],
448+
"responses" => OpenAPI.obj(
449+
"200" => runtime_response("application/json", OpenAPI.obj()),
450+
),
451+
),
452+
),
435453
"/status/{code}" => OpenAPI.obj(
436454
"get" => OpenAPI.obj(
437455
"operationId" => "statusResult",
@@ -826,6 +844,22 @@ end
826844
take_request() = take!(captures)
827845
client = C.Client()
828846

847+
@testset "allowReserved on a path parameter" begin
848+
# A slash-delimited document path (OPA style) must reach the server
849+
# as path segments, not as one %2F-joined segment; other unsafe
850+
# characters are still percent-encoded.
851+
result = call(
852+
:getdocument,
853+
"opa/examples/public servers";
854+
client,
855+
with_http_info = true,
856+
)
857+
request = take_request()
858+
@test request.target == "/documents/opa/examples/public%20servers"
859+
@test result.status == 200
860+
@test result.body["ok"] === true
861+
end
862+
829863
@testset "parameters, servers, and request overrides" begin
830864
result = call(
831865
:serializestyles,

0 commit comments

Comments
 (0)