Skip to content

Commit c20dd51

Browse files
feat: integrate URL-safe byte encoding
1 parent 7624aea commit c20dd51

6 files changed

Lines changed: 132 additions & 14 deletions

File tree

.beads/issues.jsonl

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

src/config.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@
128128
//!
129129
//! # Examples
130130
//!
131-
//! See the [examples](https://github.com/your-repo/examples) directory for complete examples:
131+
//! See the [examples](https://github.com/gpu-cli/openapi-to-rust/tree/main/examples) directory for complete examples:
132132
//! - `toml_config_example.rs` - Various configuration patterns
133133
//! - `complete_workflow.rs` - Full generation workflow with TOML
134134
//!
@@ -581,7 +581,7 @@ fn inspect_type_config_layout(value: &toml::Value) -> Result<(), GeneratorError>
581581
.is_some()
582582
{
583583
return Err(GeneratorError::ValidationError(
584-
"[generator.types.strategies] is obsolete. Move its fields directly under [generator.types]. Use snake_case keys such as date_time (not date-time); valid byte values are string, base64, and vec_u8 (for example: byte = \"base64\")."
584+
"[generator.types.strategies] is obsolete. Move its fields directly under [generator.types]. Use snake_case keys such as date_time (not date-time); valid byte values are string, base64, base64_url_unpadded, and vec_u8 (for example: byte = \"base64\")."
585585
.to_string(),
586586
));
587587
}

src/generator.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -501,27 +501,33 @@ impl CodeGenerator {
501501
.used_type_features
502502
.contains(crate::type_mapping::TypeFeature::Base64)
503503
{
504+
let engine = match self.config.types.byte {
505+
crate::type_mapping::ByteStrategy::Base64UrlUnpadded => {
506+
quote::format_ident!("URL_SAFE_NO_PAD")
507+
}
508+
_ => quote::format_ident!("STANDARD"),
509+
};
504510
quote! {
505511
/// base64 codec for `Vec<u8>` fields produced from
506512
/// `format: byte`. Used via `#[serde(with = "base64_serde")]`
507513
/// for required/non-null fields; `with = "base64_serde::option"`
508514
/// for the Option<Vec<u8>> case.
509515
mod base64_serde {
510-
use base64::{Engine as _, engine::general_purpose::STANDARD};
516+
use base64::{Engine as _, engine::general_purpose::#engine as ENGINE};
511517
use serde::{Deserialize, Deserializer, Serializer};
512518

513519
pub fn serialize<S: Serializer>(
514520
bytes: &Vec<u8>,
515521
ser: S,
516522
) -> Result<S::Ok, S::Error> {
517-
ser.serialize_str(&STANDARD.encode(bytes))
523+
ser.serialize_str(&ENGINE.encode(bytes))
518524
}
519525

520526
pub fn deserialize<'de, D: Deserializer<'de>>(
521527
de: D,
522528
) -> Result<Vec<u8>, D::Error> {
523529
let s = String::deserialize(de)?;
524-
STANDARD
530+
ENGINE
525531
.decode(s.as_bytes())
526532
.map_err(serde::de::Error::custom)
527533
}
@@ -550,7 +556,7 @@ impl CodeGenerator {
550556
) -> Result<Option<Vec<u8>>, D::Error> {
551557
let opt = Option::<String>::deserialize(de)?;
552558
opt.map(|s| {
553-
STANDARD
559+
ENGINE
554560
.decode(s.as_bytes())
555561
.map_err(serde::de::Error::custom)
556562
})

src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ pub use http_config::{AuthConfig, HttpClientConfig, RetryConfig};
3030
pub use http_error::{ApiError, ApiOpError, HttpError, HttpResult};
3131
pub use openapi::{OpenApiSpec, Schema, SchemaType};
3232
pub use type_mapping::{
33-
DepRequirement, MappedType, TypeFeature, TypeMapper, TypeMappingConfig, UsedFeatures,
33+
ByteStrategy, DepRequirement, MappedType, TypeFeature, TypeMapper, TypeMappingConfig,
34+
UsedFeatures,
3435
};
3536

3637
pub type Result<T> = std::result::Result<T, GeneratorError>;

src/type_mapping.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,9 +477,13 @@ pub enum UuidStrategy {
477477
pub enum ByteStrategy {
478478
String,
479479
/// `Vec<u8>` round-tripped via an inlined `base64_serde` module
480-
/// (default).
480+
/// using the standard padded alphabet (default).
481481
#[default]
482482
Base64,
483+
/// `Vec<u8>` round-tripped with the URL-safe, unpadded alphabet
484+
/// from RFC 7515 section 2. This setting applies to every
485+
/// `format: byte` field in the generated module.
486+
Base64UrlUnpadded,
483487
/// `Vec<u8>` with no codec (caller responsible for encoding).
484488
VecU8,
485489
}
@@ -921,11 +925,12 @@ impl TypeMapper {
921925
match strat {
922926
ByteStrategy::String => MappedType::plain("String"),
923927
ByteStrategy::VecU8 => MappedType::plain("Vec<u8>"),
924-
ByteStrategy::Base64 => {
928+
ByteStrategy::Base64 | ByteStrategy::Base64UrlUnpadded => {
925929
self.record(TypeFeature::Base64);
926930
// Path is resolved relative to the generated
927931
// module; the helper module is emitted as
928-
// `base64_serde` at the top of `types.rs`.
932+
// `base64_serde` at the top of `types.rs`. Its
933+
// alphabet is selected once during code generation.
929934
MappedType::with_codec("Vec<u8>", "base64_serde", TypeFeature::Base64)
930935
}
931936
}
@@ -1086,6 +1091,25 @@ mod tests {
10861091
assert_eq!(mt.feature, Some(TypeFeature::Base64));
10871092
}
10881093

1094+
#[test]
1095+
fn byte_url_unpadded_reuses_base64_codec() {
1096+
let mapper = TypeMapper::new(TypeMappingConfig {
1097+
byte: ByteStrategy::Base64UrlUnpadded,
1098+
..TypeMappingConfig::default()
1099+
});
1100+
let mapped = mapper.string_format(Some("byte"));
1101+
assert_eq!(mapped.rust_type, "Vec<u8>");
1102+
assert_eq!(mapped.serde_with.as_deref(), Some("base64_serde"));
1103+
assert_eq!(mapped.feature, Some(TypeFeature::Base64));
1104+
}
1105+
1106+
#[test]
1107+
fn byte_url_unpadded_parses_from_toml() {
1108+
let config: TypeMappingConfig =
1109+
toml::from_str(r#"byte = "base64_url_unpadded""#).expect("parse type config");
1110+
assert_eq!(config.byte, ByteStrategy::Base64UrlUnpadded);
1111+
}
1112+
10891113
#[test]
10901114
fn conservative_config_collapses_everything_to_string() {
10911115
let m = TypeMapper::new(TypeMappingConfig::conservative());

tests/typed_scalars_test.rs

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
//! `SchemaType::Primitive.serde_with`.
1313
1414
use openapi_to_rust::{
15-
CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig,
15+
ByteStrategy, CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig,
1616
};
1717
use serde_json::json;
1818

@@ -47,6 +47,19 @@ fn generate(spec: serde_json::Value, mapper: TypeMapper) -> String {
4747
codegen.generate(&mut analysis).expect("generate")
4848
}
4949

50+
fn generate_with_types(spec: serde_json::Value, types: TypeMappingConfig) -> String {
51+
let mut analyzer =
52+
SchemaAnalyzer::with_type_mapper(spec, TypeMapper::new(types.clone())).expect("analyzer");
53+
let mut analysis = analyzer.analyze().expect("analyze");
54+
let codegen = CodeGenerator::new(GeneratorConfig {
55+
module_name: "sample".into(),
56+
enable_async_client: false,
57+
types,
58+
..Default::default()
59+
});
60+
codegen.generate(&mut analysis).expect("generate")
61+
}
62+
5063
#[test]
5164
fn date_time_default_emits_chrono_datetime() {
5265
let code = generate(
@@ -144,6 +157,80 @@ fn byte_default_emits_vec_u8_with_base64_codec() {
144157
code.contains("mod base64_serde"),
145158
"Generated file should include the base64_serde helper module. Code:\n{code}"
146159
);
160+
assert!(code.contains("STANDARD as ENGINE"), "Code:\n{code}");
161+
assert!(!code.contains("URL_SAFE_NO_PAD"), "Code:\n{code}");
162+
}
163+
164+
#[test]
165+
fn byte_url_unpadded_emits_rfc7515_codec() {
166+
let code = generate_with_types(
167+
spec_with_format("byte"),
168+
TypeMappingConfig {
169+
byte: ByteStrategy::Base64UrlUnpadded,
170+
..TypeMappingConfig::default()
171+
},
172+
);
173+
assert!(code.contains("URL_SAFE_NO_PAD as ENGINE"), "Code:\n{code}");
174+
assert!(!code.contains("STANDARD as ENGINE"), "Code:\n{code}");
175+
}
176+
177+
#[test]
178+
fn generated_byte_url_unpadded_codec_round_trips() {
179+
let code = generate_with_types(
180+
spec_with_format("byte"),
181+
TypeMappingConfig {
182+
byte: ByteStrategy::Base64UrlUnpadded,
183+
..TypeMappingConfig::default()
184+
},
185+
);
186+
let temp = tempfile::TempDir::new().expect("scratch crate");
187+
std::fs::create_dir_all(temp.path().join("src")).expect("scratch src");
188+
std::fs::write(temp.path().join("src/generated.rs"), code).expect("generated module");
189+
std::fs::write(
190+
temp.path().join("Cargo.toml"),
191+
r#"[package]
192+
name = "byte-url-unpadded-roundtrip"
193+
version = "0.0.0"
194+
edition = "2024"
195+
publish = false
196+
197+
[dependencies]
198+
base64 = "0.22"
199+
serde = { version = "1", features = ["derive"] }
200+
serde_json = "1"
201+
"#,
202+
)
203+
.expect("scratch manifest");
204+
std::fs::write(
205+
temp.path().join("src/main.rs"),
206+
r##"#![allow(dead_code)]
207+
mod generated;
208+
209+
fn main() {
210+
let value = generated::Sample { value: vec![251, 255] };
211+
let json = serde_json::to_string(&value).unwrap();
212+
assert_eq!(json, r#"{"value":"-_8"}"#);
213+
let decoded: generated::Sample = serde_json::from_str(&json).unwrap();
214+
assert_eq!(decoded.value, vec![251, 255]);
215+
}
216+
"##,
217+
)
218+
.expect("scratch main");
219+
220+
let status = std::process::Command::new("cargo")
221+
.args(["run", "--quiet", "--offline"])
222+
.current_dir(temp.path())
223+
.env(
224+
"CARGO_TARGET_DIR",
225+
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
226+
.join("target/generated-byte-roundtrip"),
227+
)
228+
.status()
229+
.expect("cargo run");
230+
assert!(
231+
status.success(),
232+
"generated URL-safe codec did not round-trip"
233+
);
147234
}
148235

149236
#[test]

0 commit comments

Comments
 (0)