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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ when correcting output that was wrong or incomplete on the wire.

## [Unreleased]

### Added

- Path parameters declared with `allowReserved: true` serialize with RFC 6570
reserved expansion: `:`, `@`, `[`, `]` and the sub-delimiters stay literal
and existing `%XX` triples pass through, while `/`, `?` and `#` remain
percent-encoded as path templating requires. A server that routes on the
literal form, such as `GET /prices/XLON:LLOY`, can now be described without
the client sending `%3A`. This is OpenAPI 3.2 behavior, also supported as a
compatibility extension for 3.0/3.1 specs, where the standard defines
`allowReserved` only for query parameters. Default path encoding is unchanged.

### Fixed

- Low-level client generation includes the reserved path encoder with operation
methods, so composing the public client-generation helpers works for path
parameters with `allowReserved: true`.

## [0.21.0] - 2026-10-06

### Added
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,14 @@ The generator accepts 3.0.x and 3.1.x specs; 3.2.x parses with an
unrecognized field may be ignored by analysis and code generation. Add a
focused fixture when relying on a less-common OpenAPI or JSON Schema keyword.

Path parameters with `allowReserved: true` use OpenAPI 3.2 reserved expansion:
reserved characters such as `:` and existing `%XX` triples pass through, while
`/`, `?`, and `#` remain percent-encoded. The generator also accepts this path
behavior in 3.0.x and 3.1.x specs as a compatibility extension; those versions
define `allowReserved` only for query parameters. Omitting the flag or setting
it to `false` preserves the default path encoding. Query `allowReserved` is
currently parsed but does not change generated query serialization.

**3.1 (JSON Schema 2020-12) keywords now modeled and read from typed fields:**

| Keyword group | Status |
Expand All @@ -742,6 +750,7 @@ focused fixture when relying on a less-common OpenAPI or JSON Schema keyword.

| Delta | Status |
|---|---|
| Path parameter `allowReserved` | emits reserved expansion; also supported as an extension in 3.0/3.1 |
| `query` HTTP method + `PathItem.additionalOperations` | parses + emits client methods via `reqwest::Method::from_bytes(...)` |
| OAuth `deviceAuthorization` flow + `oauth2MetadataUrl` | typed |
| `Server.name`, `Tag.parent`/`kind`/`summary` | typed |
Expand Down
7 changes: 7 additions & 0 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,11 @@ pub struct ParameterInfo {
/// generated/resolved for the object schema.
#[serde(skip_serializing_if = "Option::is_none")]
pub query_serialization: Option<QuerySerialization>,
/// `allowReserved: true` on the parameter: values use RFC 6570 reserved
/// expansion, so `:`, `@` and the sub-delimiters stay literal in a path
/// segment instead of being percent-encoded.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub allow_reserved: bool,
/// Original parameter schema retained for request validation. This is not
/// exposed by serialized operation listings.
#[serde(skip)]
Expand Down Expand Up @@ -8286,6 +8291,7 @@ impl SchemaAnalyzer {
enum_varnames: None,
rust_ident: None,
query_serialization: None,
allow_reserved: false,
validation_schema: None,
});
}
Expand Down Expand Up @@ -8819,6 +8825,7 @@ impl SchemaAnalyzer {
enum_varnames,
rust_ident: None,
query_serialization,
allow_reserved: param.allow_reserved.unwrap_or(false),
validation_schema,
}))
}
Expand Down
66 changes: 62 additions & 4 deletions src/client_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ impl CodeGenerator {
}
out
}

};

// Combine all parts
Expand Down Expand Up @@ -569,6 +570,7 @@ impl CodeGenerator {
/// Emits per-operation typed error enums (one variant per declared non-2xx
/// response with a body schema) BEFORE the `impl HttpClient` block so the
/// generated method signatures can reference them. This low-level helper
/// also emits operation-owned helpers such as the reserved path encoder.
/// intentionally emits every analyzed operation; use
/// [`Self::generate_http_client`] or [`Self::generate_all`] to honor the
/// configured `[client].operations` scope.
Expand All @@ -581,15 +583,64 @@ impl CodeGenerator {
CodeGenerator::new(config).generate_operation_methods_for(analysis, &operations)
}

/// RFC 6570 reserved-expansion encoder, emitted only when an operation has a
/// path parameter with `allowReserved: true`.
fn generate_reserved_path_encoder(
&self,
operations: &[&crate::analysis::OperationInfo],
) -> TokenStream {
let used = operations.iter().any(|op| {
op.parameters
.iter()
.any(|p| p.location == "path" && p.allow_reserved)
});
if !used {
return TokenStream::new();
}
quote! {
// RFC 6570 reserved expansion for path parameters with
// `allowReserved: true`: unreserved and reserved bytes and existing
// `%XX` triples pass through, except `/`, `?` and `#`, which OpenAPI
// path templating forbids unescaped in a parameter value.
fn __pct_encode_path_reserved(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
| b':' | b'@' | b'[' | b']' | b'!' | b'$' | b'&' | b'\'' | b'('
| b')' | b'*' | b'+' | b',' | b';' | b'=' => out.push(b as char),
b'%' if i + 2 < bytes.len()
&& bytes[i + 1].is_ascii_hexdigit()
&& bytes[i + 2].is_ascii_hexdigit() =>
{
out.push_str(&s[i..i + 3]);
i += 2;
}
_ => {
out.push('%');
out.push_str(&format!("{:02X}", b));
}
}
i += 1;
}
out
}
}
}

/// Generate every operation-owned client artifact from one resolved
/// operation slice. This keeps methods, parameter enums, and typed error
/// enums in lockstep for selective clients.
/// operation slice, including any required path encoder. This keeps helpers,
/// methods, parameter enums, and typed errors in lockstep for selective clients.
pub(crate) fn generate_operation_methods_for(
&self,
analysis: &SchemaAnalysis,
operations: &[&OperationInfo],
) -> TokenStream {
let param_enums = self.generate_param_enum_types(operations);
let reserved_path_encoder = self.generate_reserved_path_encoder(operations);

let op_error_enums: Vec<TokenStream> = operations
.iter()
Expand All @@ -606,6 +657,8 @@ impl CodeGenerator {
self.generate_operation_builders(analysis, &plans);

quote! {
#reserved_path_encoder

#param_enums

#(#op_error_enums)*
Expand Down Expand Up @@ -4145,13 +4198,18 @@ impl CodeGenerator {
format_string.push_str("{}");
let param_name_snake = self.operation_param_ident(op, param);
let param_ident = Self::to_field_ident(&param_name_snake);
let encoder = if param.allow_reserved {
quote! { __pct_encode_path_reserved }
} else {
quote! { __pct_encode_path_segment }
};
if Self::param_uses_as_ref_str(param) {
format_args.push(quote! {
__pct_encode_path_segment(#param_ident.as_ref())
#encoder(#param_ident.as_ref())
});
} else {
format_args.push(quote! {
__pct_encode_path_segment(&#param_ident.to_string())
#encoder(&#param_ident.to_string())
});
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/server/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1380,6 +1380,7 @@ mod tests {
enum_varnames: None,
rust_ident: None,
query_serialization: None,
allow_reserved: false,
validation_schema: Some(json!({"$ref": "#/components/schemas/Payload"})),
}],
..Default::default()
Expand Down Expand Up @@ -1439,6 +1440,7 @@ mod tests {
enum_varnames: None,
rust_ident: None,
query_serialization: None,
allow_reserved: false,
validation_schema: Some(json!({
"$ref": "#/components/schemas/Tag~1Kind/allOf/0"
})),
Expand Down Expand Up @@ -1486,6 +1488,7 @@ mod tests {
enum_varnames: None,
rust_ident: None,
query_serialization: None,
allow_reserved: false,
validation_schema: Some(json!({"const": literal})),
}],
..Default::default()
Expand Down Expand Up @@ -1529,6 +1532,7 @@ mod tests {
enum_varnames: None,
rust_ident: None,
query_serialization: None,
allow_reserved: false,
validation_schema: Some(json!({
"type": "string",
"pattern": "[^/:|\\000-\\037]+"
Expand Down Expand Up @@ -1643,6 +1647,7 @@ mod tests {
enum_varnames: None,
rust_ident: None,
query_serialization: None,
allow_reserved: false,
validation_schema: Some(json!({"type": "string", "maxLength": 4})),
}],
..Default::default()
Expand Down
4 changes: 2 additions & 2 deletions tests/corpus-manifest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ cerebras/client.rs 42274 1129 d6c9699
cerebras/mod.rs 442 17 71f9d1b20905ad16
cerebras/types.rs 231216 5923 aad1193fbff6a57a
circleci/REQUIRED_DEPS.toml 626 15 082a47f05075776b
circleci/client.rs 625906 16205 5c274d507425cf33
circleci/client.rs 626997 16248 8e903224f1455d23
circleci/mod.rs 442 17 7a8b5691342a9d62
circleci/types.rs 384614 10072 6f277085e1ad9894
cloudflare/REQUIRED_DEPS.toml 787 19 09cccc57db93029b
Expand Down Expand Up @@ -235,4 +235,4 @@ writer/client.rs 191302 5007 33061e5
writer/mod.rs 438 17 0435bf1552e493d1
writer/types.rs 198394 4888 800dd8afe5798530
#
# specs: 56 files: 224 bytes: 330620818
# specs: 56 files: 224 bytes: 330621909
Loading