diff --git a/MIGRATION.md b/MIGRATION.md index da129b0..6a43fb2 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. 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/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..759beb6 100644 --- a/docs/src/clients.md +++ b/docs/src/clients.md @@ -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; 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 095f1e5..3d6ea16 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -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) @@ -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 @@ -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!( @@ -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( 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,