diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a46d099..3dbc160 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"id":"openapi-generator-dpd","title":"Hybrid string-or-object discriminated unions deserialize-fail","description":"When an anyOf/oneOf union contains a discriminator AND a non-object branch (e.g. string-enum like ToolChoiceOptions), the generator emits a tagged enum that cannot deserialize the string form. Real-world hit: OpenAI ToolChoiceParam returns 'auto' in Response.tool_choice, but generated type is #[serde(tag=\"type\")] enum with no untagged String variant. Need to fall back to #[serde(untagged)] when the union mixes string/scalar branches with tagged-object branches, OR add a String fallback variant before the tagged variants.","notes":"Live repro: `ToolChoiceParam` from openai.yaml line 52518. anyOf has 8 branches; the first (`ToolChoiceOptions`) is a string-enum (\"none\"|\"auto\"|\"required\"), the rest are objects with discriminator propertyName=type. Generator emits `#[serde(tag=\"type\")] enum ToolChoiceParam { ToolChoiceOptions(ToolChoiceOptions), ... }` which cannot deserialize the string \"auto\" because serde tries to read a \"type\" field from a JSON string. Fix: when an anyOf/oneOf branch is a non-object schema (string/number/etc), the generator must emit `#[serde(untagged)]` with the scalar branch first OR add a String variant before the tagged variants. Hit on real OpenAI Responses API `Response.tool_choice` field.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-10T23:47:35Z","created_by":"James Lal","updated_at":"2026-05-11T00:12:46Z","started_at":"2026-05-10T23:54:10Z","closed_at":"2026-05-11T00:12:46Z","close_reason":"Fixed in src/analysis.rs: (dpd) analyze_oneof_union now downgrades to untagged when any branch is non-object — verified live against OpenAI Response.tool_choice='auto' which now deserializes as ToolChoiceParam::ToolChoiceOptions(Auto). (bgo) merge_schema_into_properties now ORs in is_nullable_pattern() for allOf-merged props — verified live against OpenAI Response.incomplete_details which is now Option\u003cResponseIncompleteDetails\u003e and deserializes null cleanly. All 4 smoke tests (OpenAI+Anthropic, sync+stream) pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"openapi-generator-76h","title":"Add ByteStrategy::Base64UrlUnpadded for format: byte","description":"Add a new ByteStrategy variant emitting RFC 7515 §2 url-safe unpadded base64 in the inlined base64_serde helper. Needed by gpu-cli portal-schema work (JWK/DPoP thumbprints, sealing pubkeys) so format: byte fields can round-trip without falling back to plain String + spec-side documentation. Spec-global config knob, not per-field.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-16T17:58:36Z","created_by":"James Lal","updated_at":"2026-05-16T18:29:10Z","started_at":"2026-05-16T17:58:50Z","closed_at":"2026-05-16T18:29:10Z","close_reason":"Shipped on worktree-base64url-unpadded (commit afc8c88). 16 typed_scalars_test, 23 type_mapping unit tests, 51-binary cargo test suite all green; clippy --lib clean.","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-9ek","title":"[Server P6] Snapshot tests + integration smoke test against openai.yaml","description":"End-to-end validation of server codegen.\n\nSnapshot coverage:\n- createChatCompletion alone (canonical case, both non-streaming and SSE variants)\n- createEmbedding alone (single non-streaming op, different tag)\n- tag:Files (multi-op single tag)\n- Mixed: createChatCompletion + tag:Embeddings (multi-tag router)\n\nFor each: snapshot the full generated tree (server/api.rs, server/router.rs, server/errors.rs, models.rs).\n\nIntegration smoke test:\n- Compile a small consumer crate under tests/conformance/server/ that has a hand-written impl ChatApi for TestState returning canned responses.\n- Boot the router on a random port via tokio::spawn.\n- Hit POST /v1/chat/completions with a sample body, assert response shape.\n- Hit with stream=true, assert SSE event sequence.\n\nDoc updates:\n- README section 'Server codegen' with the 3-command DevX loop (list → add → generate).\n- Example in examples/openai_server.rs showing the user-side impl + main().","design":"Tests live alongside existing snapshot tests. Snapshots in src/snapshots/. Integration test uses reqwest (already a dep) to hit the in-process server.","acceptance_criteria":"- [ ] insta snapshots for all four scenarios above, reviewed and accepted.\n- [ ] Integration smoke test passes in CI.\n- [ ] Regenerating after a spec rename produces a clear error (manually tested).\n- [ ] README and example present.","notes":"Canonical test cases (per user direction 2026-05-10):\n- OpenAI Responses API: operationId 'createResponse' in specs/openai.yaml (POST /v1/responses). Streaming via stream:true in body.\n- Anthropic Messages API: operationId 'messages_post' in specs/anthropic.yaml (POST /v1/messages). Streaming via stream:true in body.\n\nBoth are the production drivers for server codegen — every phase should keep these two working as the bar for 'done'.","status":"open","priority":2,"issue_type":"task","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:24Z","created_by":"James Lal","updated_at":"2026-05-11T01:44:50Z","dependencies":[{"issue_id":"openapi-generator-9ek","depends_on_id":"openapi-generator-cdx","type":"blocks","created_at":"2026-05-10T19:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"id":"openapi-generator-cdx","title":"[Server P5] Router factory + extractors + model pruning","description":"Wire the traits from P4 to an Axum Router and prune models.\n\nRouter factory (combined when ops span multiple tags):\n pub fn router\u003cC, E\u003e(chat: C, embeddings: E) -\u003e axum::Router\n where C: ChatApi + Clone, E: EmbeddingsApi + Clone, ...\n\nSingle tag collapses to single generic. Per-operation Axum handler wraps trait call, performs JSON/path/query extraction.\n\nExtractors:\n- BearerAuth (when securityScheme=bearer is used by picked ops)\n- ApiKeyHeader (when securityScheme=apiKey in header)\n- Typed wrappers for required headers/query/path parameters\n\nModel pruning:\n- Compute transitive reachability from picked operations' request/response types.\n- Emit only reachable schemas in models.rs (the rest are not generated for server even if client gen would emit them).\n- If client gen is ALSO enabled, models.rs is the union of both reachability sets.\n\nStreaming:\n- text/event-stream responses → variant carries Sse\u003cBoxStream\u003c'static, Result\u003cEvent, Infallible\u003e\u003e\u003e.\n- Conditional streaming (stream:bool in body) → both Ok(...) and OkStream(Sse\u003c...\u003e) variants.","design":"Extends src/server_generator.rs with router emission. Reuses streaming.rs SSE infrastructure. Model pruning reuses (or factors out) the reachability walk already in client_generator.rs. New files emitted: generated/server/{mod,api,router,extractors,errors}.rs.","acceptance_criteria":"- [ ] Generated router compiles and serves picked endpoints.\n- [ ] Path/query/header parameters extracted with correct types.\n- [ ] Streaming endpoint (createChatCompletion with stream=true) returns SSE.\n- [ ] models.rs contains only types reachable from picked ops when only server gen is enabled.\n- [ ] Round-trip: hit the endpoint with a request body, get back the expected response shape.\n- [ ] No unused imports or dead code warnings in generated server/.","notes":"P5 partial — router factory emitted in P4 commit. Remaining for full P5: typed extractors for query/header params, multi-trait combined router, model pruning to picked-ops reachability. Current state generates working code for canonical specs (createResponse + messages_post) end-to-end.","status":"open","priority":2,"issue_type":"feature","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:16Z","created_by":"James Lal","updated_at":"2026-05-11T02:41:31Z","dependencies":[{"issue_id":"openapi-generator-cdx","depends_on_id":"openapi-generator-jih","type":"blocks","created_at":"2026-05-10T19:00:37Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"id":"openapi-generator-jih","title":"[Server P4] Trait + typed response-enum emitter","description":"Emit the trait-and-response-enum core of server codegen.\n\nFor each picked operation:\n- A trait method on the per-tag trait (or single ServerApi trait if untagged).\n- A response enum: one variant per documented status code, variant payload = documented body type.\n- An IntoResponse impl mapping each variant → (StatusCode, Json).\n\nTrait shape:\n #[axum::async_trait]\n pub trait ChatApi: Send + Sync + 'static {\n async fn create_chat_completion(\n \u0026self,\n auth: BearerAuth,\n body: CreateChatCompletionRequest,\n ) -\u003e CreateChatCompletionResponse;\n }\n\nResponse enum:\n pub enum CreateChatCompletionResponse {\n Ok(ChatCompletionResponse),\n BadRequest(ErrorResponse),\n TooManyRequests(ErrorResponse),\n }\n\nTrait grouping: one trait per tag in operation order. Operations without a tag land on a default 'ServerApi' trait.","design":"New module src/server_generator.rs paralleling client_generator.rs. Reuses type_mapping.rs for body/parameter types. Reuses analysis.rs response metadata. Status-code → variant name mapping via heck (200 Ok, 400 BadRequest, 404 NotFound, default Default, etc.).","acceptance_criteria":"- [ ] Generated trait method signature matches design.\n- [ ] Response enum has one variant per status documented in the operation's responses map.\n- [ ] Default response (responses.default) → variant 'Default'.\n- [ ] IntoResponse impl returns correct StatusCode + Json body per variant.\n- [ ] No #[validate(...)] attrs, no validator dep usage (project rule).\n- [ ] Compiles against axum 0.7 (or 0.8 if upgrading) and serde_json.\n- [ ] Snapshot test for trait+enums emitted from createChatCompletion.","status":"closed","priority":2,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-05-11T01:00:09Z","created_by":"James Lal","updated_at":"2026-05-11T02:41:23Z","started_at":"2026-05-11T02:13:54Z","closed_at":"2026-05-11T02:41:23Z","close_reason":"P4 trait+response-enum emitter + P5-lite router factory shipped together. Emits server/{mod,api,errors,router}.rs. Trait per tag with snake_case methods; typed response enums with status-code variants + IntoResponse impl; SSE OkStream variant when supports_streaming; ServerEventStream alias for the SSE payload. Router factory takes T: Trait+Clone, wires routes via axum::routing::*, handles JSON+Path extraction. Both example crates exercise unary+SSE branches; cargo test passes for both.","dependencies":[{"issue_id":"openapi-generator-jih","depends_on_id":"openapi-generator-lyo","type":"blocks","created_at":"2026-05-10T19:00:36Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} diff --git a/src/generator.rs b/src/generator.rs index 98f8759..94c4e70 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -398,27 +398,38 @@ impl CodeGenerator { .used_type_features .contains(crate::type_mapping::TypeFeature::Base64) { + // Pick the alphabet once at emit time. URL-safe unpadded + // is RFC 7515 §2 (JWK thumbprints, DPoP, JWS); standard + // padded is the historical default. The codec module + // name stays `base64_serde` either way so per-field + // `#[serde(with = ...)]` attributes don't need to know. + let engine_ident = match self.config.types.byte { + crate::type_mapping::ByteStrategy::Base64UrlUnpadded => { + quote::format_ident!("URL_SAFE_NO_PAD") + } + _ => quote::format_ident!("STANDARD"), + }; quote! { /// base64 codec for `Vec` fields produced from /// `format: byte`. Used via `#[serde(with = "base64_serde")]` /// for required/non-null fields; `with = "base64_serde::option"` /// for the Option> case. mod base64_serde { - use base64::{Engine as _, engine::general_purpose::STANDARD}; + use base64::{Engine as _, engine::general_purpose::#engine_ident as ENGINE}; use serde::{Deserialize, Deserializer, Serializer}; pub fn serialize( bytes: &Vec, ser: S, ) -> Result { - ser.serialize_str(&STANDARD.encode(bytes)) + ser.serialize_str(&ENGINE.encode(bytes)) } pub fn deserialize<'de, D: Deserializer<'de>>( de: D, ) -> Result, D::Error> { let s = String::deserialize(de)?; - STANDARD + ENGINE .decode(s.as_bytes()) .map_err(serde::de::Error::custom) } @@ -447,7 +458,7 @@ impl CodeGenerator { ) -> Result>, D::Error> { let opt = Option::::deserialize(de)?; opt.map(|s| { - STANDARD + ENGINE .decode(s.as_bytes()) .map_err(serde::de::Error::custom) }) diff --git a/src/lib.rs b/src/lib.rs index c7171c2..88da499 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,8 @@ pub use http_config::{AuthConfig, HttpClientConfig, RetryConfig}; pub use http_error::{ApiError, ApiOpError, HttpError, HttpResult}; pub use openapi::{OpenApiSpec, Schema, SchemaType}; pub use type_mapping::{ - DepRequirement, MappedType, TypeFeature, TypeMapper, TypeMappingConfig, UsedFeatures, + ByteStrategy, DepRequirement, MappedType, TypeFeature, TypeMapper, TypeMappingConfig, + UsedFeatures, }; pub type Result = std::result::Result; diff --git a/src/type_mapping.rs b/src/type_mapping.rs index 16b69e6..50ea2b9 100644 --- a/src/type_mapping.rs +++ b/src/type_mapping.rs @@ -269,9 +269,15 @@ pub enum UuidStrategy { pub enum ByteStrategy { String, /// `Vec` round-tripped via an inlined `base64_serde` module - /// (default). + /// using the standard padded alphabet (default). #[default] Base64, + /// `Vec` round-tripped via the same `base64_serde` module + /// but using the URL-safe, unpadded alphabet (RFC 7515 §2). + /// Required for JWKs, DPoP thumbprints, and other JOSE-adjacent + /// fields. Spec-global: a single generated module is emitted, so + /// all `format: byte` fields in the spec share this alphabet. + Base64UrlUnpadded, /// `Vec` with no codec (caller responsible for encoding). VecU8, } @@ -708,11 +714,13 @@ impl TypeMapper { match strat { ByteStrategy::String => MappedType::plain("String"), ByteStrategy::VecU8 => MappedType::plain("Vec"), - ByteStrategy::Base64 => { + ByteStrategy::Base64 | ByteStrategy::Base64UrlUnpadded => { self.record(TypeFeature::Base64); // Path is resolved relative to the generated // module; the helper module is emitted as - // `base64_serde` at the top of `types.rs`. + // `base64_serde` at the top of `types.rs`. The + // alphabet (standard vs url-unpadded) is picked + // once at codec-emit time from the config. MappedType::with_codec("Vec", "base64_serde", TypeFeature::Base64) } } @@ -873,6 +881,33 @@ mod tests { assert_eq!(mt.feature, Some(TypeFeature::Base64)); } + #[test] + fn byte_url_unpadded_strategy_reuses_base64_codec_path() { + // The new variant uses the same codec module name and + // TypeFeature as the default — the alphabet difference is + // resolved at codec-emit time in generator.rs, not here. + let mut cfg = TypeMappingConfig::default(); + cfg.byte = ByteStrategy::Base64UrlUnpadded; + let m = TypeMapper::new(cfg); + let mt = m.string_format(Some("byte")); + assert_eq!(mt.rust_type, "Vec"); + assert_eq!(mt.serde_with.as_deref(), Some("base64_serde")); + assert_eq!(mt.feature, Some(TypeFeature::Base64)); + } + + #[test] + fn byte_strategy_parses_url_unpadded_via_toml() { + // Locks the snake_case key (`base64_url_unpadded`) that + // consumers will write in `[generator.types]`. + let cfg: TypeMappingConfig = + toml::from_str(r#"byte = "base64_url_unpadded""#).expect("parse"); + assert_eq!(cfg.byte, ByteStrategy::Base64UrlUnpadded); + + // Default key still parses unchanged. + let cfg: TypeMappingConfig = toml::from_str(r#"byte = "base64""#).expect("parse"); + assert_eq!(cfg.byte, ByteStrategy::Base64); + } + #[test] fn conservative_config_collapses_everything_to_string() { let m = TypeMapper::new(TypeMappingConfig::conservative()); diff --git a/tests/typed_scalars_test.rs b/tests/typed_scalars_test.rs index 4f54193..bf73dbd 100644 --- a/tests/typed_scalars_test.rs +++ b/tests/typed_scalars_test.rs @@ -12,7 +12,7 @@ //! `SchemaType::Primitive.serde_with`. use openapi_to_rust::{ - CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, + ByteStrategy, CodeGenerator, GeneratorConfig, SchemaAnalyzer, TypeMapper, TypeMappingConfig, }; use serde_json::json; @@ -145,6 +145,59 @@ fn byte_default_emits_vec_u8_with_base64_codec() { ); } +#[test] +fn byte_url_unpadded_emits_url_safe_no_pad_engine() { + // ByteStrategy::Base64UrlUnpadded must swap the alphabet in + // the inlined `base64_serde` helper to RFC 7515 §2 (URL-safe, + // unpadded). Per-field codec attribute stays `base64_serde` + // so the variant is opaque to call sites. + let mut types = TypeMappingConfig::default(); + types.byte = ByteStrategy::Base64UrlUnpadded; + let mapper = TypeMapper::new(types.clone()); + + let mut analyzer = + SchemaAnalyzer::with_type_mapper(spec_with_format("byte"), mapper).expect("analyzer"); + let mut analysis = analyzer.analyze().expect("analyze"); + let cfg = GeneratorConfig { + module_name: "sample".into(), + types, + ..Default::default() + }; + let codegen = CodeGenerator::new(cfg); + let code = codegen.generate(&mut analysis).expect("generate"); + + assert!( + code.contains("URL_SAFE_NO_PAD"), + "url-unpadded strategy must reference URL_SAFE_NO_PAD. Code:\n{code}" + ); + assert!( + !code.contains("STANDARD"), + "url-unpadded strategy must not reference STANDARD. Code:\n{code}" + ); + assert!( + code.contains(r#"with = "base64_serde""#), + "per-field attribute stays `base64_serde`. Code:\n{code}" + ); +} + +#[test] +fn byte_default_emits_standard_engine() { + // Sanity: the default `Base64` variant must still emit STANDARD + // (padded). Locks the historical default in place. + let code = generate( + spec_with_format("byte"), + TypeMapper::new(TypeMappingConfig::default()), + ); + assert!( + code.contains("STANDARD"), + "default byte strategy must reference STANDARD. Code:\n{code}" + ); + assert!( + !code.contains("URL_SAFE_NO_PAD"), + "default byte strategy must not reference URL_SAFE_NO_PAD. Code:\n{code}" + ); +} + #[test] fn no_byte_format_no_base64_helper_emitted() { // Sanity: helper module is gated on actual usage, so a spec