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
1 change: 1 addition & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 41 additions & 37 deletions src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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),
Expand Down
15 changes: 15 additions & 0 deletions src/client_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -1894,6 +1895,13 @@ impl CodeGenerator {
// (`requestBody.required` is false or absent) become `Option<T>` 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, .. }
Expand All @@ -1907,12 +1915,18 @@ impl CodeGenerator {
RequestBodyContent::OctetStream => quote! { Vec<u8> },
RequestBodyContent::TextPlain => quote! { String },
RequestBodyContent::Unsupported { .. } => quote! { Vec<u8> },
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 {
Expand Down Expand Up @@ -2055,6 +2069,7 @@ impl CodeGenerator {
},
)
}
RequestBodyContent::SchemaLess { .. } => return quote! {},
};
if required {
apply
Expand Down
1 change: 1 addition & 0 deletions src/registry_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ impl CodeGenerator {
RequestBodyContent::Unsupported { .. } => {
(quote! { BodyContentType::OctetStream }, quote! { None })
}
RequestBodyContent::SchemaLess { .. } => continue,
};
quote! {
Some(BodyDef {
Expand Down
7 changes: 7 additions & 0 deletions src/server/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
15 changes: 7 additions & 8 deletions tests/operation_extraction_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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"
));
}
30 changes: 30 additions & 0 deletions tests/operation_generation_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading