Skip to content
Open
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 .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
19 changes: 15 additions & 4 deletions src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>` fields produced from
/// `format: byte`. Used via `#[serde(with = "base64_serde")]`
/// for required/non-null fields; `with = "base64_serde::option"`
/// for the Option<Vec<u8>> 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<S: Serializer>(
bytes: &Vec<u8>,
ser: S,
) -> Result<S::Ok, S::Error> {
ser.serialize_str(&STANDARD.encode(bytes))
ser.serialize_str(&ENGINE.encode(bytes))
}

pub fn deserialize<'de, D: Deserializer<'de>>(
de: D,
) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(de)?;
STANDARD
ENGINE
.decode(s.as_bytes())
.map_err(serde::de::Error::custom)
}
Expand Down Expand Up @@ -447,7 +458,7 @@ impl CodeGenerator {
) -> Result<Option<Vec<u8>>, D::Error> {
let opt = Option::<String>::deserialize(de)?;
opt.map(|s| {
STANDARD
ENGINE
.decode(s.as_bytes())
.map_err(serde::de::Error::custom)
})
Expand Down
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = std::result::Result<T, GeneratorError>;
41 changes: 38 additions & 3 deletions src/type_mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,9 +269,15 @@ pub enum UuidStrategy {
pub enum ByteStrategy {
String,
/// `Vec<u8>` round-tripped via an inlined `base64_serde` module
/// (default).
/// using the standard padded alphabet (default).
#[default]
Base64,
/// `Vec<u8>` 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<u8>` with no codec (caller responsible for encoding).
VecU8,
}
Expand Down Expand Up @@ -708,11 +714,13 @@ impl TypeMapper {
match strat {
ByteStrategy::String => MappedType::plain("String"),
ByteStrategy::VecU8 => MappedType::plain("Vec<u8>"),
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<u8>", "base64_serde", TypeFeature::Base64)
}
}
Expand Down Expand Up @@ -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<u8>");
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());
Expand Down
55 changes: 54 additions & 1 deletion tests/typed_scalars_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Loading