From d7664f89d33c53bd47f0f9be33602524ba96829a Mon Sep 17 00:00:00 2001 From: tan Date: Sat, 19 Sep 2026 15:41:53 +0530 Subject: [PATCH] feat(client): escape_path_chars percent-encodes extra characters in path parameters Some routers (Rails in particular) end a dynamic path segment at a literal `.` and treat the remainder as a format suffix, so a value such as `acme.example.com-42` only routes when sent as `acme%2Eexample%2Ecom-42`. RFC 3986 leaves `.` unreserved, so the standard escaper never produces that spelling, and pre-encoded input is double-encoded. `Client(; escape_path_chars = ".")` names characters to percent-encode in every path parameter value after the standard escaping. Style delimiters and the template parameter name are left alone, and the option composes with `allowReserved`. `%` is rejected. The default is empty, so existing clients are unchanged. Fixes #120 --- MIGRATION.md | 1 + docs/src/clients.md | 25 +++++++++++ src/runtime.jl | 90 ++++++++++++++++++++++++++++--------- test/runtime.jl | 30 +++++++++++++ test/runtime_integration.jl | 36 +++++++++++++++ 5 files changed, 162 insertions(+), 20 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 6a43fb2..5c73796 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -47,6 +47,7 @@ OpenAPI.server("openapi.json"; name = "MyServer", path = "MyServer.jl") | 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 | +| `pre_request_hook` rewriting the path to send `%2E` for `.` | `Client(url; escape_path_chars = ".")` percent-encodes the listed characters in every path parameter value on top of the standard RFC 3986 escaping | | 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 759beb6..85b7573 100644 --- a/docs/src/clients.md +++ b/docs/src/clients.md @@ -79,6 +79,31 @@ stream cancellation closes one request connection. Set `protocol=:auto` or `:h2` in `request_options` when the caller accepts HTTP/2 stream lifecycle semantics. Buffered calls keep HTTP.jl's automatic protocol selection. +## Extra percent-encoding in path parameters + +Generated clients percent-encode path parameters per RFC 3986, which leaves +the unreserved characters `A-Z a-z 0-9 - _ . ~` as they are. Some servers +cannot route a path segment that contains a literal `.`: Rails, for example, +ends a dynamic segment at the first `.` and reads the rest as a format suffix, +so `GET /customers/acme.example.com-42` is a `404` while +`GET /customers/acme%2Eexample%2Ecom-42` matches. `escape_path_chars` names +characters to percent-encode in addition to the standard set: + +```julia +client = ExampleClient.Client("https://api.example.com"; escape_path_chars = ".") +ExampleClient.get_customer("acme.example.com-42"; client) +# GET /customers/acme%2Eexample%2Ecom-42 +``` + +The option applies to the values of every path parameter of every operation +and defaults to empty. Style delimiters (`.` for `label`, `;` and `=` for +`matrix`) and the parameter name from the path template are never touched. +RFC 3986 treats the encoded and unencoded spellings of an unreserved character +as the same identifier, so servers that decode before routing are unaffected. +This is independent of `allowReserved`, which removes escaping rather than +adding it, and it is not a substitute for it: a `/` in a value is still +encoded unless the parameter declares `allowReserved: true`. + ## HTTP behavior Generated clients support: diff --git a/src/runtime.jl b/src/runtime.jl index f3100db..fc78547 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -1162,6 +1162,7 @@ mutable struct Client media_decoders::Dict{String,Function} validate_requests::Bool validate_responses::Bool + escape_path_chars::String end function _normalize_media_codecs(codecs::AbstractDict) @@ -1187,8 +1188,14 @@ function Client( media_decoders::AbstractDict = Dict{String,Function}(), validate_requests::Bool = true, validate_responses::Bool = true, + escape_path_chars::AbstractString = "", ) server_index > 0 || throw(ArgumentError("server_index must be positive")) + occursin('%', escape_path_chars) && throw( + ArgumentError( + "escape_path_chars must not contain '%': it would re-encode the percent-encoding itself", + ), + ) normalized_credentials = credentials isa Dict{String,AbstractCredential} ? copy(credentials) : Dict{String,AbstractCredential}( @@ -1216,6 +1223,7 @@ function Client( _normalize_media_codecs(media_decoders), validate_requests, validate_responses, + String(escape_path_chars), ) end @@ -1320,21 +1328,58 @@ end # 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) +# +# `escape_chars` names characters to percent-encode on top of RFC 3986 escaping +# (`Client(escape_path_chars = ...)`). Unreserved characters such as `.` are +# never escaped by `_escape`, yet some routers cannot match a literal `.` in a +# dynamic segment (Rails treats it as a format suffix) and need `%2E` instead. +# RFC 3986 §2.3 makes the two spellings equivalent, so a server that decodes +# before routing is unaffected. It applies to values only, after escaping, so +# style delimiters (`.` for `label`, `;` and `=` for `matrix`) and the +# parameter name from the path template stay literal. +_path_scalar(value; allow_reserved::Bool = false, escape_chars::AbstractString = "") = + _percent_encode_chars(_escape(_scalar(value); allow_reserved), escape_chars) + +function _percent_encode_chars(text::String, chars::AbstractString) + isempty(chars) && return text + io = IOBuffer() + for char in text + if char in chars + for byte in codeunits(string(char)) + write(io, UInt8('%'), codeunit("0123456789ABCDEF", (byte >> 4) + 1), + codeunit("0123456789ABCDEF", (byte & 0x0f) + 1)) + end + else + write(io, char) + end + end + return String(take!(io)) +end -function _path_array(value, delimiter; allow_reserved::Bool = false) +function _path_array( + value, + delimiter; + allow_reserved::Bool = false, + escape_chars::AbstractString = "", +) value isa AbstractVector || value isa Tuple || throw(ArgumentError("parameter style requires an array value")) - return join((_path_scalar(item; allow_reserved) for item in value), delimiter) + return join((_path_scalar(item; allow_reserved, escape_chars) for item in value), delimiter) end -function _path_object(value, pair_delimiter, key_delimiter; allow_reserved::Bool = false) +function _path_object( + value, + pair_delimiter, + key_delimiter; + allow_reserved::Bool = false, + escape_chars::AbstractString = "", +) return join( ( string( - _path_scalar(key; allow_reserved), + _path_scalar(key; allow_reserved, escape_chars), key_delimiter, - _path_scalar(item; allow_reserved), + _path_scalar(item; allow_reserved, escape_chars), ) for (key, item) in _pairs(value) ), pair_delimiter, @@ -1347,6 +1392,7 @@ function _path_parameter( style::Symbol, explode::Bool; allow_reserved::Bool = false, + escape_chars::AbstractString = "", ) encoded = _encode(value) if encoded === nothing @@ -1356,29 +1402,29 @@ function _path_parameter( end if style === :simple encoded isa AbstractDict && return explode ? - _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) + _path_object(encoded, ",", "="; allow_reserved, escape_chars) : + _path_object(encoded, ",", ","; allow_reserved, escape_chars) + encoded isa AbstractVector && return _path_array(encoded, ","; allow_reserved, escape_chars) + return _path_scalar(encoded; allow_reserved, escape_chars) elseif style === :label encoded isa AbstractDict && return "." * (explode ? - _path_object(encoded, ".", "="; allow_reserved) : - _path_object(encoded, ",", ","; allow_reserved)) + _path_object(encoded, ".", "="; allow_reserved, escape_chars) : + _path_object(encoded, ",", ","; allow_reserved, escape_chars)) encoded isa AbstractVector && - return "." * _path_array(encoded, explode ? "." : ","; allow_reserved) - return "." * _path_scalar(encoded; allow_reserved) + return "." * _path_array(encoded, explode ? "." : ","; allow_reserved, escape_chars) + return "." * _path_scalar(encoded; allow_reserved, escape_chars) elseif style === :matrix encoded_name = _path_scalar(name) if encoded isa AbstractDict return explode ? - join((";" * _path_scalar(key) * "=" * _path_scalar(item; allow_reserved) for (key, item) in _pairs(encoded))) : - ";" * encoded_name * "=" * _path_object(encoded, ",", ","; allow_reserved) + join((";" * _path_scalar(key; escape_chars) * "=" * _path_scalar(item; allow_reserved, escape_chars) for (key, item) in _pairs(encoded))) : + ";" * encoded_name * "=" * _path_object(encoded, ",", ","; allow_reserved, escape_chars) elseif encoded isa AbstractVector return explode ? - join((";" * encoded_name * "=" * _path_scalar(item; allow_reserved) for item in encoded)) : - ";" * encoded_name * "=" * _path_array(encoded, ","; allow_reserved) + join((";" * encoded_name * "=" * _path_scalar(item; allow_reserved, escape_chars) for item in encoded)) : + ";" * encoded_name * "=" * _path_array(encoded, ","; allow_reserved, escape_chars) end - return ";" * encoded_name * "=" * _path_scalar(encoded; allow_reserved) + return ";" * encoded_name * "=" * _path_scalar(encoded; allow_reserved, escape_chars) end throw(ArgumentError("unsupported path parameter style $style")) end @@ -1574,7 +1620,10 @@ function _append_parameter!(client, path, query, headers, cookies, descriptor, v path, "{" * descriptor.name * "}" => ( preencoded ? serialized : - _escape(serialized; allow_reserved = descriptor.allow_reserved) + _percent_encode_chars( + _escape(serialized; allow_reserved = descriptor.allow_reserved), + client.escape_path_chars, + ) ), ) elseif location === :query @@ -1601,6 +1650,7 @@ function _append_parameter!(client, path, query, headers, cookies, descriptor, v style, explode; allow_reserved = descriptor.allow_reserved, + escape_chars = client.escape_path_chars, ) path = replace(path, "{" * descriptor.name * "}" => serialized) elseif location === :query diff --git a/test/runtime.jl b/test/runtime.jl index 738121a..58c6c7b 100644 --- a/test/runtime.jl +++ b/test/runtime.jl @@ -445,6 +445,36 @@ ".a/b" @test invoke(:_path_parameter, "path", "a/b", :matrix, false; allow_reserved = true) == ";path=a/b" + # escape_path_chars percent-encodes extra characters after standard + # escaping (Rails routes treat a literal `.` as a format suffix) + @test invoke(:_path_parameter, "id", "acme.example.com-42", :simple, false) == + "acme.example.com-42" + @test invoke(:_path_parameter, "id", "acme.example.com-42", :simple, false; + escape_chars = ".") == "acme%2Eexample%2Ecom-42" + @test invoke(:_path_parameter, "id", "a.b c", :simple, false; escape_chars = ".-") == + "a%2Eb%20c" + @test invoke(:_path_parameter, "id", ["a.b", "c.d"], :simple, false; + escape_chars = ".") == "a%2Eb,c%2Ed" + @test invoke(:_path_parameter, "id", Dict("k.1" => "v.2"), :simple, true; + escape_chars = ".") == "k%2E1=v%2E2" + # style delimiters and the template parameter name stay literal + @test invoke(:_path_parameter, "id", ["a.b", "c.d"], :label, true; + escape_chars = ".") == ".a%2Eb.c%2Ed" + @test invoke(:_path_parameter, "v.1", "a.b", :matrix, false; escape_chars = ".") == + ";v.1=a%2Eb" + @test invoke(:_path_parameter, "id", Dict("k.1" => "v.2"), :matrix, true; + escape_chars = ".") == ";k%2E1=v%2E2" + # composes with allowReserved: reserved characters pass, listed ones are encoded + @test invoke(:_path_parameter, "path", "a/b.c", :simple, false; + allow_reserved = true, escape_chars = ".") == "a/b%2Ec" + # a listed unreserved character that is already reserved-escaped is unaffected + @test invoke(:_path_parameter, "id", "a b", :simple, false; escape_chars = " ") == + "a%20b" + @test invoke(:_percent_encode_chars, "a.b", "") == "a.b" + @test invoke(:_percent_encode_chars, "a~b", "~") == "a%7Eb" + @test invoke(:Client).escape_path_chars == "" + @test invoke(:Client; escape_path_chars = ".").escape_path_chars == "." + @test_throws ArgumentError invoke(:Client; escape_path_chars = "%") @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 e71fcab..1c5179a 100644 --- a/test/runtime_integration.jl +++ b/test/runtime_integration.jl @@ -450,6 +450,17 @@ end ), ), ), + "/tenants/{external_id}" => OpenAPI.obj( + "get" => OpenAPI.obj( + "operationId" => "getTenant", + "parameters" => Any[ + runtime_parameter("external_id", "path", string_schema), + ], + "responses" => OpenAPI.obj( + "200" => runtime_response("application/json", OpenAPI.obj()), + ), + ), + ), "/status/{code}" => OpenAPI.obj( "get" => OpenAPI.obj( "operationId" => "statusResult", @@ -860,6 +871,31 @@ end @test result.body["ok"] === true end + @testset "escape_path_chars on the client" begin + # Rails-style routers end a dynamic segment at a literal `.`; a + # client configured with escape_path_chars = "." sends `%2E` + # instead, for every path parameter of every operation. The + # default client keeps RFC 3986 unreserved characters as-is. + call(:gettenant, "acme.example.com-42"; client) + @test take_request().target == "/tenants/acme.example.com-42" + + dotted_client = C.Client(; escape_path_chars = ".") + result = call( + :gettenant, + "acme.example.com-42"; + client = dotted_client, + with_http_info = true, + ) + request = take_request() + @test request.target == "/tenants/acme%2Eexample%2Ecom-42" + @test result.status == 200 + @test result.body["ok"] === true + + # composes with allowReserved: `/` still passes, `.` is encoded + call(:getdocument, "opa/v1.2/data"; client = dotted_client) + @test take_request().target == "/documents/opa/v1%2E2/data" + end + @testset "parameters, servers, and request overrides" begin result = call( :serializestyles,