diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ade5878..67b31ba 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"id":"openapi-generator-cqf","title":"Fix schema-less client bodies and release 0.9.1","description":"Remote spec-compile regressed for Cloudflare, GitHub, and Lithic because schema-less JSON request bodies are rejected during shared operation analysis. Preserve client generation compatibility, keep validated Axum server generation fail-closed, bump patch metadata to 0.9.1, and publish through a PR.","acceptance_criteria":"Cloudflare, GitHub, and Lithic parse-only generation passes; schema-less bodies remain representable for clients; validated server generation rejects schema-less selected request content with an actionable error; 0.9.1 metadata and changelog are updated; relevant full gates pass.","notes":"Implemented a shared SchemaLess request-body marker: client and registry generation preserve historical no-body operations, while Axum server generation fails closed with operation/media context. Bumped Cargo metadata and changelog to 0.9.1. Verified focused tests 38/38; cloudflare/github/lithic generation 3/3; full corpus 54 pass + 1 Swagger 2.0 skip; Anthropic/OpenAI generated outputs compile; cargo test --all-features; clippy -D warnings; rustdoc -D warnings; fmt/diff checks; official openai-python 2.45.0 compatibility smoke.","status":"closed","priority":1,"issue_type":"bug","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T20:24:00Z","created_by":"James Lal","updated_at":"2026-07-26T20:35:14Z","started_at":"2026-07-26T20:24:09Z","closed_at":"2026-07-26T20:35:14Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-xov.9","title":"Always-run OpenAI generated client/server compatibility smoke suite","description":"Generate a selected Axum server and Reqwest client from the checked-in OpenAI API specification and exercise representative JSON RPC, path/query parameter, typed Problem Details, and SSE operations. Research whether the official OpenAI Python client offers a stable reusable mock/contract suite; keep the required gate hermetic and offline.","design":"Prefer a small selected-operation subset and existing checked-in spec. No external API calls, credentials, fixed ports, or downloads in the required test.","acceptance_criteria":"A hermetic always-run test generates both sides from specs/openai.yaml, starts an ephemeral server, and verifies representative basic RPC, query/path parameters, errors, and SSE; official-client reuse is either implemented as a stable gate or documented/filed as an optional follow-up with evidence.","notes":"Implemented an every-PR CI job using openai-python==2.45.0 with OpenAI(base_url=\u003cephemeral loopback\u003e, api_key=test, admin_api_key=test-admin, max_retries=0). Live final gate passed unary Responses, typed SSE deltas plus [DONE], input-items path/query, organization costs query, and same-spec generated Rust client compilation.","status":"closed","priority":1,"issue_type":"feature","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T17:48:44Z","created_by":"James Lal","updated_at":"2026-07-26T18:23:43Z","started_at":"2026-07-26T17:50:28Z","closed_at":"2026-07-26T18:23:43Z","close_reason":"Pinned official OpenAI SDK and generated Rust client/server compatibility gate passes in CI.","dependencies":[{"issue_id":"openapi-generator-xov.9","depends_on_id":"openapi-generator-xov","type":"parent-child","created_at":"2026-07-26T11:48:44Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-xov.7.2","title":"Phase 4: generated and hand-written client round trips validation","description":"Independently verify the acceptance criteria and validation commands in planning/axum-validation/04-roundtrip-matrix.md; add missing regression coverage where necessary.","acceptance_criteria":"All scoped validation commands exit 0 and evidence is recorded in issue notes.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T16:44:15Z","created_by":"James Lal","updated_at":"2026-07-26T17:51:13Z","started_at":"2026-07-26T17:51:10Z","closed_at":"2026-07-26T17:51:13Z","close_reason":"Live ephemeral generated client/server and independent reqwest matrix passes with typed Problem Details, full status taxonomy, redaction, deterministic ordering, and handler isolation.","dependencies":[{"issue_id":"openapi-generator-xov.7.2","depends_on_id":"openapi-generator-xov.7","type":"parent-child","created_at":"2026-07-26T10:44:15Z","created_by":"James Lal","metadata":"{}"},{"issue_id":"openapi-generator-xov.7.2","depends_on_id":"openapi-generator-xov.7.1","type":"blocks","created_at":"2026-07-26T10:44:15Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"openapi-generator-xov.7.1","title":"Phase 4: generated and hand-written client round trips implementation","description":"Implement the production changes defined in planning/axum-validation/04-roundtrip-matrix.md. Record a codebase research mini-plan in issue notes before editing.","acceptance_criteria":"The implementation portion of planning/axum-validation/04-roundtrip-matrix.md is complete with focused tests.","notes":"Research mini-plan: extend the existing generated query/body/parameter scratch-crate strategy into one live ephemeral Axum server; generate the reqwest client from the same compact fixture; exercise success and typed Problem Details through that client; send malformed, oversized, unsupported-media, and schema-invalid requests through a hand-written reqwest client; assert stable status/code/location/redaction and AtomicUsize handler isolation.","status":"closed","priority":1,"issue_type":"task","assignee":"James Lal","owner":"james@littlebearlabs.io","created_at":"2026-07-26T16:44:14Z","created_by":"James Lal","updated_at":"2026-07-26T17:51:10Z","started_at":"2026-07-26T17:44:55Z","closed_at":"2026-07-26T17:51:10Z","close_reason":"Live ephemeral generated client/server and independent reqwest matrix passes with typed Problem Details, full status taxonomy, redaction, deterministic ordering, and handler isolation.","dependencies":[{"issue_id":"openapi-generator-xov.7.1","depends_on_id":"openapi-generator-xov.7","type":"parent-child","created_at":"2026-07-26T10:44:14Z","created_by":"James Lal","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} diff --git a/CHANGELOG.md b/CHANGELOG.md index d10a0d7..140efd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes are recorded here. This project follows semantic versioning, with one pre-1.0 qualification: a minor release may change generated Rust APIs when correcting output that was wrong or incomplete on the wire. +## [0.9.1] - 2026-07-26 + +### Fixed + +- Restored client generation for operations whose selected JSON or form request + content declares no schema. These operations keep their historical no-body + client signature, while server generation fails with an actionable error + because there is no request contract to validate. + ## [0.9.0] - 2026-07-26 ### Added @@ -143,7 +152,8 @@ when correcting output that was wrong or incomplete on the wire. signed enum values, recursive unions, parameter collisions, optional request bodies, range response codes, and path-segment encoding. -[Unreleased]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.9.0...HEAD +[Unreleased]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.9.1...HEAD +[0.9.1]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/gpu-cli/openapi-to-rust/compare/v0.6.0...v0.7.0 diff --git a/Cargo.lock b/Cargo.lock index 235ec4c..9c66c3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -992,7 +992,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openapi-to-rust" -version = "0.9.0" +version = "0.9.1" dependencies = [ "clap", "heck", diff --git a/Cargo.toml b/Cargo.toml index e0dd6c8..ebe5cce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["tmp"] [package] name = "openapi-to-rust" -version = "0.9.0" +version = "0.9.1" edition = "2024" rust-version = "1.88.0" autobins = false diff --git a/src/analysis.rs b/src/analysis.rs index f1858d3..4b383e6 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -352,6 +352,12 @@ pub enum RequestBodyContent { Multipart, OctetStream, TextPlain, + /// A declared request media type without a schema. Client generation + /// preserves its historical no-body signature, while server generation + /// rejects the operation because there is no contract to validate. + SchemaLess { + media_type: String, + }, Unsupported { media_types: Vec, }, @@ -364,9 +370,11 @@ impl RequestBodyContent { Self::Json { schema_name, .. } | Self::FormUrlEncoded { schema_name, .. } => { Some(schema_name) } - Self::Multipart | Self::OctetStream | Self::TextPlain | Self::Unsupported { .. } => { - None - } + Self::Multipart + | Self::OctetStream + | Self::TextPlain + | Self::SchemaLess { .. } + | Self::Unsupported { .. } => None, } } } @@ -4434,51 +4442,47 @@ impl SchemaAnalyzer { use crate::openapi::{is_form_urlencoded_media_type, is_json_media_type}; if let Some((content_type, maybe_schema)) = request_body.best_content() { op_info.request_body = if is_json_media_type(content_type) { - Some( - maybe_schema - .ok_or_else(|| { - GeneratorError::CodeGenError(format!( - "operation `{operation_id}` selects `{content_type}` request content without a schema" - )) - }) - .and_then(|s| { - let validation_schema = self - .raw_request_body_schema(raw_operation.as_ref(), content_type) - .unwrap_or( - serde_json::to_value(s) - .map_err(GeneratorError::ParseError)?, - ); + match maybe_schema { + Some(s) => { + let validation_schema = self + .raw_request_body_schema(raw_operation.as_ref(), content_type) + .unwrap_or( + serde_json::to_value(s).map_err(GeneratorError::ParseError)?, + ); + Some( self.resolve_or_inline_schema(s, operation_id, "Request") .map(|name| RequestBodyContent::Json { schema_name: name, media_type: content_type.to_string(), validation_schema, - }) - })?, - ) + })?, + ) + } + None => Some(RequestBodyContent::SchemaLess { + media_type: content_type.to_string(), + }), + } } else if is_form_urlencoded_media_type(content_type) { - Some( - maybe_schema - .ok_or_else(|| { - GeneratorError::CodeGenError(format!( - "operation `{operation_id}` selects `{content_type}` request content without a schema" - )) - }) - .and_then(|s| { - let validation_schema = self - .raw_request_body_schema(raw_operation.as_ref(), content_type) - .unwrap_or( - serde_json::to_value(s) - .map_err(GeneratorError::ParseError)?, - ); + match maybe_schema { + Some(s) => { + let validation_schema = self + .raw_request_body_schema(raw_operation.as_ref(), content_type) + .unwrap_or( + serde_json::to_value(s).map_err(GeneratorError::ParseError)?, + ); + Some( self.resolve_or_inline_schema(s, operation_id, "Request") .map(|name| RequestBodyContent::FormUrlEncoded { schema_name: name, media_type: content_type.to_string(), validation_schema, - }) - })?, - ) + })?, + ) + } + None => Some(RequestBodyContent::SchemaLess { + media_type: content_type.to_string(), + }), + } } else { match content_type { "multipart/form-data" => Some(RequestBodyContent::Multipart), diff --git a/src/client_generator.rs b/src/client_generator.rs index 12e587c..d1321fd 100644 --- a/src/client_generator.rs +++ b/src/client_generator.rs @@ -889,6 +889,7 @@ impl CodeGenerator { optional_fields: Vec::new(), }); } + RequestBodyContent::SchemaLess { .. } => return None, }; let body_type_name = self.to_rust_type_name(body_name); let body_type = syn::Ident::new(&body_type_name, proc_macro2::Span::call_site()); @@ -1894,6 +1895,13 @@ impl CodeGenerator { // (`requestBody.required` is false or absent) become `Option` per T11. if let Some(ref rb) = op.request_body { use crate::analysis::RequestBodyContent; + if matches!(rb, RequestBodyContent::SchemaLess { .. }) { + return if params.is_empty() { + quote! {} + } else { + quote! { #(#params),* } + }; + } let required = op.request_body_required; let body_type = match rb { RequestBodyContent::Json { schema_name, .. } @@ -1907,12 +1915,18 @@ impl CodeGenerator { RequestBodyContent::OctetStream => quote! { Vec }, RequestBodyContent::TextPlain => quote! { String }, RequestBodyContent::Unsupported { .. } => quote! { Vec }, + RequestBodyContent::SchemaLess { .. } => unreachable!( + "schema-less request bodies preserve the historical client signature" + ), }; let body_ident = match rb { RequestBodyContent::Multipart => quote! { form }, RequestBodyContent::OctetStream | RequestBodyContent::TextPlain | RequestBodyContent::Unsupported { .. } => quote! { body }, + RequestBodyContent::SchemaLess { .. } => unreachable!( + "schema-less request bodies preserve the historical client signature" + ), _ => quote! { request }, }; if required { @@ -2055,6 +2069,7 @@ impl CodeGenerator { }, ) } + RequestBodyContent::SchemaLess { .. } => return quote! {}, }; if required { apply diff --git a/src/registry_generator.rs b/src/registry_generator.rs index 48e5845..e52428f 100644 --- a/src/registry_generator.rs +++ b/src/registry_generator.rs @@ -295,6 +295,7 @@ impl CodeGenerator { RequestBodyContent::Unsupported { .. } => { (quote! { BodyContentType::OctetStream }, quote! { None }) } + RequestBodyContent::SchemaLess { .. } => continue, }; quote! { Some(BodyDef { diff --git a/src/server/codegen.rs b/src/server/codegen.rs index 79f8c3c..74a7e23 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -547,6 +547,13 @@ impl<'a> ServerCodegen<'a> { reason: "text server extraction is not implemented".to_string(), }); } + Some(RequestBodyContent::SchemaLess { media_type }) => { + return Err(ServerCodegenError::UnsupportedRequestBody { + operation_id: operation.operation_id.clone(), + media_type: media_type.clone(), + reason: "request content has no schema to validate".to_string(), + }); + } Some(RequestBodyContent::Unsupported { media_types }) => { return Err(ServerCodegenError::UnsupportedRequestBody { operation_id: operation.operation_id.clone(), diff --git a/tests/operation_extraction_test.rs b/tests/operation_extraction_test.rs index 116db68..90a40f6 100644 --- a/tests/operation_extraction_test.rs +++ b/tests/operation_extraction_test.rs @@ -450,7 +450,7 @@ fn test_content_type_priority() { } #[test] -fn request_body_media_type_is_retained_and_schema_less_content_is_rejected() { +fn request_body_media_type_and_schema_less_content_are_retained() { let vendor_spec = serde_json::json!({ "openapi": "3.1.0", "info": { "title": "vendor json", "version": "1" }, @@ -482,11 +482,10 @@ fn request_body_media_type_is_retained_and_schema_less_content_is_rejected() { "responses": { "204": { "description": "ok" } } }}} }); - let error = SchemaAnalyzer::new(schema_less) - .unwrap() - .analyze() - .unwrap_err() - .to_string(); - assert!(error.contains("createItem")); - assert!(error.contains("without a schema")); + let analysis = SchemaAnalyzer::new(schema_less).unwrap().analyze().unwrap(); + assert!(matches!( + &analysis.operations["createItem"].request_body, + Some(RequestBodyContent::SchemaLess { media_type }) + if media_type == "application/json" + )); } diff --git a/tests/operation_generation_test.rs b/tests/operation_generation_test.rs index b100421..21097dd 100644 --- a/tests/operation_generation_test.rs +++ b/tests/operation_generation_test.rs @@ -117,6 +117,36 @@ fn test_generate_post_operation() { assert!(result_str.contains("Result < User , ApiOpError <")); } +#[test] +fn schema_less_request_content_preserves_client_operation_without_a_body() { + let config = create_test_config(); + let generator = CodeGenerator::new(config); + let operation = OperationInfo { + operation_id: "triggerAction".to_string(), + method: "POST".to_string(), + path: "/actions".to_string(), + summary: None, + description: None, + request_body: Some(RequestBodyContent::SchemaLess { + media_type: "application/json".to_string(), + }), + response_schemas: BTreeMap::new(), + parameters: vec![], + request_body_required: true, + supports_streaming: false, + stream_parameter: None, + tags: Vec::new(), + }; + + let analysis = create_test_analysis_with_operations(vec![operation]); + let result = generator.generate_operation_methods(&analysis).to_string(); + + assert!(result.contains("trigger_action (& self"), "{result}"); + assert!(result.contains(". post (request_url)"), "{result}"); + assert!(!result.contains("request :"), "{result}"); + assert!(!result.contains(". body ("), "{result}"); +} + #[test] fn test_generate_put_operation() { let config = create_test_config(); diff --git a/tests/server_body_validation_test.rs b/tests/server_body_validation_test.rs index 3bcfedd..7139caa 100644 --- a/tests/server_body_validation_test.rs +++ b/tests/server_body_validation_test.rs @@ -123,6 +123,40 @@ fn unsupported_request_media_is_rejected_during_server_generation() { assert!(error.contains("application/xml"), "{error}"); } +#[test] +fn schema_less_request_content_is_rejected_during_server_generation() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "schema-less body", "version": "1" }, + "paths": { "/actions": { "post": { + "operationId": "triggerAction", + "requestBody": { "required": true, "content": { + "application/json": {} + }}, + "responses": { "204": { "description": "unused" } } + }}} + }); + let analysis = SchemaAnalyzer::new(spec).unwrap().analyze().unwrap(); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["triggerAction".into()], + prune_models: true, + validation: Default::default(), + }; + let config = GeneratorConfig { + server: Some(server.clone()), + ..Default::default() + }; + let error = ServerCodegen::new(&config, &analysis, &server) + .generate() + .unwrap_err() + .to_string(); + + assert!(error.contains("triggerAction"), "{error}"); + assert!(error.contains("application/json"), "{error}"); + assert!(error.contains("no schema to validate"), "{error}"); +} + #[test] fn generated_json_pipeline_validates_before_invoking_the_api() { let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));