From cefd23204f216ec579250b3950e5a5e43e59bad8 Mon Sep 17 00:00:00 2001 From: tan Date: Fri, 11 Sep 2026 08:58:33 +0530 Subject: [PATCH 1/2] 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. --- MIGRATION.md | 1 + Project.toml | 2 +- docs/src/clients.md | 5 ++- src/runtime.jl | 68 +++++++++++++++++++++++++------------ test/runtime.jl | 11 ++++++ test/runtime_integration.jl | 34 +++++++++++++++++++ 6 files changed, 97 insertions(+), 24 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index da129b0..ef8db75 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 | | 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` | diff --git a/Project.toml b/Project.toml index 7ce1747..1d13607 100644 --- a/Project.toml +++ b/Project.toml @@ -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" diff --git a/docs/src/clients.md b/docs/src/clients.md index 085c9a6..93d7667 100644 --- a/docs/src/clients.md +++ b/docs/src/clients.md @@ -88,7 +88,10 @@ 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; - JSON and structured-suffix JSON media types; - text and binary bodies; - `application/x-www-form-urlencoded` bodies; diff --git a/src/runtime.jl b/src/runtime.jl index 095f1e5..eea4cfd 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -1308,28 +1308,41 @@ function _join_object(value, pair_delimiter, key_delimiter) ) end -_path_scalar(value) = _escape(_scalar(value)) - -function _path_array(value, delimiter) +# `allow_reserved` is honoured for path parameters as well as query parameters. +# OAS scopes `allowReserved` to `in: query`, but documents for APIs whose path +# parameters are themselves slash-delimited paths (OPA data documents, proxied +# object paths) declare it on the path parameter, and the only useful reading +# of that is "send the value through without percent-encoding reserved +# characters". Without it every `/` becomes `%2F` and the server sees a single +# segment. +_path_scalar(value; allow_reserved::Bool = false) = _escape(_scalar(value); allow_reserved) + +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) @@ -1338,26 +1351,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 @@ -1550,8 +1566,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!( @@ -1571,7 +1589,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( diff --git a/test/runtime.jl b/test/runtime.jl index e9b0ac0..fbf91a8 100644 --- a/test/runtime.jl +++ b/test/runtime.jl @@ -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") diff --git a/test/runtime_integration.jl b/test/runtime_integration.jl index ff47e45..e71fcab 100644 --- a/test/runtime_integration.jl +++ b/test/runtime_integration.jl @@ -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 @@ -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", @@ -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, From f9d6eb7535f4f84ca01130724d82e84e27340c0c Mon Sep 17 00:00:00 2001 From: tan Date: Fri, 11 Sep 2026 11:59:36 +0530 Subject: [PATCH 2/2] 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. --- MIGRATION.md | 2 +- docs/src/clients.md | 5 ++++- docs/src/servers.md | 7 +++++++ src/runtime.jl | 17 +++++++++++------ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index ef8db75..6a43fb2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -46,7 +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 | +| `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` | diff --git a/docs/src/clients.md b/docs/src/clients.md index 93d7667..759beb6 100644 --- a/docs/src/clients.md +++ b/docs/src/clients.md @@ -91,7 +91,10 @@ Generated clients support: - `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; + 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; diff --git a/docs/src/servers.md b/docs/src/servers.md index aa66377..6125aba 100644 --- a/docs/src/servers.md +++ b/docs/src/servers.md @@ -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 diff --git a/src/runtime.jl b/src/runtime.jl index eea4cfd..3d6ea16 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -1309,12 +1309,17 @@ function _join_object(value, pair_delimiter, key_delimiter) end # `allow_reserved` is honoured for path parameters as well as query parameters. -# OAS scopes `allowReserved` to `in: query`, but documents for APIs whose path -# parameters are themselves slash-delimited paths (OPA data documents, proxied -# object paths) declare it on the path parameter, and the only useful reading -# of that is "send the value through without percent-encoding reserved -# characters". Without it every `/` becomes `%2F` and the server sees a single -# segment. +# 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; allow_reserved::Bool = false)