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 @@ -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` |

Expand Down
25 changes: 25 additions & 0 deletions docs/src/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
90 changes: 70 additions & 20 deletions src/runtime.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}(
Expand Down Expand Up @@ -1216,6 +1223,7 @@ function Client(
_normalize_media_codecs(media_decoders),
validate_requests,
validate_responses,
String(escape_path_chars),
)
end

Expand Down Expand Up @@ -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,
Expand All @@ -1347,6 +1392,7 @@ function _path_parameter(
style::Symbol,
explode::Bool;
allow_reserved::Bool = false,
escape_chars::AbstractString = "",
)
encoded = _encode(value)
if encoded === nothing
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions test/runtime.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
36 changes: 36 additions & 0 deletions test/runtime_integration.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
Loading