diff --git a/tests/fixtures/valid/rust-error-payload-shapes.yaml b/tests/fixtures/valid/rust-error-payload-shapes.yaml new file mode 100644 index 00000000..00cbf2e7 --- /dev/null +++ b/tests/fixtures/valid/rust-error-payload-shapes.yaml @@ -0,0 +1,117 @@ +openapi: 3.0.3 +info: + title: Rust Error Payload Shapes API + version: 1.0.0 +paths: + /payloads: + get: + tags: [Payloads] + operationId: getPayloads + responses: + '204': + description: Success without a body. + '410': + description: Array of named models. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ErrorDetail' + '411': + description: Nullable array of named models. + content: + application/json: + schema: + type: array + nullable: true + items: + $ref: '#/components/schemas/ErrorDetail' + '412': + description: Map of arrays of named models. + content: + application/json: + schema: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/ErrorDetail' + '413': + description: Primitive alias suppressed because it shadows String. + content: + application/json: + schema: + $ref: '#/components/schemas/String' + '414': + description: Integer format is preserved. + content: + application/json: + schema: + type: integer + format: int32 + '415': + description: Model alias remains qualified rather than unwrapped. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorAlias' + '416': + description: Long map payload remains flat at render width 100. + content: + application/json: + schema: + type: object + additionalProperties: + $ref: '#/components/schemas/VeryLongErrorPayloadModelNameThatFillsTheRenderWidth' + '417': + description: Documented error without a body. + '418': + description: Text media type overrides its schema payload type. + content: + text/plain: + schema: + type: integer + '419': + description: Binary media type overrides its schema payload type. + content: + application/octet-stream: + schema: + type: string + '420': + description: Dynamic JSON escape hatch. + content: + application/json: + schema: {} + '421': + description: Inline string enum payload. + content: + application/json: + schema: + type: string + enum: [missing, expired] + '422': + description: Binary primitive under a JSON media type. + content: + application/json: + schema: + type: string + format: binary +components: + schemas: + ErrorDetail: + type: object + required: [message] + properties: + message: + type: string + ErrorAlias: + $ref: '#/components/schemas/ErrorDetail' + String: + type: string + VeryLongErrorPayloadModelNameThatFillsTheRenderWidth: + type: object + required: [message] + properties: + message: + type: string diff --git a/tests/rust_error_generation.rs b/tests/rust_error_generation.rs new file mode 100644 index 00000000..0fbdebd6 --- /dev/null +++ b/tests/rust_error_generation.rs @@ -0,0 +1,190 @@ +//! Contracts for shared Rust error generation through the public generators. + +use openapi_nexus::codegen::traits::code_generator::CodeGenerator; +use openapi_nexus::generators::rust::aioduct::RustAioductCodeGenerator; +use openapi_nexus::generators::rust::reqwest::RustReqwestCodeGenerator; +use openapi_nexus::generators::rust::ureq::RustUreqCodeGenerator; +use openapi_nexus::generators::{ArtifactKind, GeneratorError}; +use openapi_nexus::ir::types::{IrPrimitive, IrResponse, IrSpec, IrTypeExpr}; +use openapi_nexus::test_utils::read_fixture; +use sigil_stitch::error::SigilStitchError; + +fn generators() -> [Box; 3] { + [ + Box::new(RustReqwestCodeGenerator::new(toml::value::Table::new())), + Box::new(RustUreqCodeGenerator::new(toml::value::Table::new())), + Box::new(RustAioductCodeGenerator::new(toml::value::Table::new())), + ] +} + +fn minimal_ir() -> IrSpec { + let parsed = openapi_nexus::parser::parse_content_yaml(&read_fixture("valid/minimal.yaml")) + .expect("minimal fixture parses"); + openapi_nexus::ir::lower::lower(parsed).expect("minimal fixture lowers") +} + +fn payload_ir() -> IrSpec { + let fixture = read_fixture("valid/rust-error-payload-shapes.yaml"); + let parsed = + openapi_nexus::parser::parse_content_yaml(&fixture).expect("payload fixture parses"); + openapi_nexus::ir::lower::lower(parsed).expect("payload fixture lowers") +} + +// Value: protects=Rust operation errors retain payload types and exact enum spelling; +// fails_when=arrays/maps/nullability/aliases/media overrides lose their payload shape; +// why_new=existing error goldens use named models, not composite inline payloads; +// seam=CodeGenerator::generate (all Rust backends); no_claim=runtime HTTP decoding. +#[test] +fn composite_error_payloads_preserve_their_generated_types() { + let ir = payload_ir(); + let expected_enum = r#"pub enum GetPayloadsError { + Status410(ApiError>), + Status411(ApiError>>), + Status412(ApiError>>), + Status413(ApiError), + Status414(ApiError), + Status415(ApiError), + Status416(ApiError>), + Status417(ApiError<()>), + Status418(ApiError), + Status419(ApiError>), + Status420(ApiError), + Status421(ApiError), + Validation(ApiError>), + Unexpected(ApiError>), + Transport(Error), +}"#; + + for generator in generators() { + let backend = generator.generator_type(); + let files = generator.generate(&ir).expect("payload fixture generates"); + let api = files + .iter() + .find(|file| file.filename == "payloads.rs") + .expect("payloads API is generated"); + let start = api + .content + .find("pub enum GetPayloadsError {") + .expect("error enum exists"); + let end = api.content[start..].find("\n}").expect("enum closes") + start + 2; + assert_eq!(&api.content[start..end], expected_enum, "{backend}"); + } +} + +// Value: protects=recursive error payload wrappers survive the IR-to-source boundary; +// fails_when=nullable named references, nested maps, or alias handling changes; +// why_new=OpenAPI lowering can normalize these combinations before generation; +// seam=CodeGenerator::generate (all Rust backends); no_claim=OpenAPI lowering correctness. +#[test] +fn nested_ir_error_payloads_preserve_nullability_and_aliases() { + let cases = [ + ( + IrTypeExpr::Nullable(Box::new(IrTypeExpr::Named("ErrorDetail".to_string()))), + "Option", + ), + ( + IrTypeExpr::Map(Box::new(IrTypeExpr::Nullable(Box::new(IrTypeExpr::Array( + Box::new(IrTypeExpr::Named("ErrorDetail".to_string())), + ))))), + "std::collections::HashMap>>", + ), + ( + IrTypeExpr::Array(Box::new(IrTypeExpr::Nullable(Box::new(IrTypeExpr::Map( + Box::new(IrTypeExpr::Named("String".to_string())), + ))))), + "Vec>>", + ), + ( + IrTypeExpr::Array(Box::new(IrTypeExpr::Named("ErrorAlias".to_string()))), + "Vec", + ), + (IrTypeExpr::StringLiteral("expired".to_string()), "String"), + ( + IrTypeExpr::Union(vec![ + IrTypeExpr::Primitive(IrPrimitive::Integer), + IrTypeExpr::Primitive(IrPrimitive::Boolean), + ]), + "serde_json::Value", + ), + ]; + + for (payload, expected_type) in cases { + let mut ir = payload_ir(); + let response = ir.operations[0] + .responses + .iter_mut() + .find(|response| response.status == "410") + .expect("array error response exists"); + response + .content + .insert("application/json".to_string(), payload); + + for generator in generators() { + let backend = generator.generator_type(); + let files = generator + .generate(&ir) + .expect("nested IR payload generates"); + let api = files + .iter() + .find(|file| file.filename == "payloads.rs") + .expect("payloads API exists"); + let variant = api + .content + .lines() + .find(|line| line.trim_start().starts_with("Status410(")) + .expect("410 variant exists"); + assert_eq!( + variant, + format!(" Status410(ApiError<{expected_type}>),"), + "{backend}" + ); + } + } +} + +// Value: protects=public Rust generators return rendering errors without panicking; +// fails_when=file rendering uses expect or loses API/source context; +// why_new=successful goldens never exercise rejected sigil-stitch types; +// seam=CodeGenerator::generate (all Rust backends); no_claim=valid OpenAPI input rejection. +#[test] +fn invalid_error_payload_returns_a_contextual_emission_error() { + let mut ir = minimal_ir(); + ir.operations[0].responses.push(IrResponse { + status: "400".to_string(), + description: "Invalid lowered type reference".to_string(), + content: indexmap::IndexMap::from([( + "application/json".to_string(), + IrTypeExpr::Named(String::new()), + )]), + item_content: indexmap::IndexMap::new(), + headers: indexmap::IndexMap::new(), + }); + + for generator in generators() { + let backend = generator.generator_type(); + let error = generator + .generate(&ir) + .expect_err("an empty qualified type name must be rejected, not rendered"); + match error { + GeneratorError::Emission { + artifact, + name, + source, + } => { + assert!( + matches!(artifact, ArtifactKind::Api), + "{backend}: {artifact}" + ); + assert_eq!(name, "default API", "{backend}"); + match *source { + SigilStitchError::InvalidTypeName { context, reason } => { + assert!(!context.is_empty(), "{backend}: missing render context"); + assert_eq!(reason, "imported name must not be blank", "{backend}"); + } + other => panic!("{backend}: original type diagnostic was lost: {other:?}"), + } + } + other => panic!("{backend}: expected a typed emission error, got {other:?}"), + } + } +} diff --git a/tests/typed_http_error_runtime.rs b/tests/typed_http_error_runtime.rs index 0024b74e..899456e4 100644 --- a/tests/typed_http_error_runtime.rs +++ b/tests/typed_http_error_runtime.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; +use openapi_nexus::codegen::traits::code_generator::CodeGenerator; +use openapi_nexus::codegen::traits::file_writer::FileWriter; use openapi_nexus::generators::rust::aioduct::RustAioductCodeGenerator; use openapi_nexus::generators::rust::reqwest::RustReqwestCodeGenerator; use openapi_nexus::generators::rust::ureq::RustUreqCodeGenerator; @@ -608,3 +610,52 @@ fn aioduct_api_call_error_is_pointer_sized() { String::from_utf8_lossy(&output.stderr) ); } + +fn check_error_payload_crate(generator: &(impl CodeGenerator + FileWriter)) { + let fixture = read_fixture("valid/rust-error-payload-shapes.yaml"); + let files = generate_files(generator, &fixture).expect("composite error fixture generates"); + let temp = TempDir::new(&format!("error-payload-{}", generator.generator_type())); + write_generated_crate(&files, &temp.path); + + let output = Command::new("cargo") + .arg("check") + .arg("--quiet") + .current_dir(&temp.path) + .output() + .expect("generated error payload crate cargo check should run"); + assert!( + output.status.success(), + "{} error payload crate failed to compile\nstatus: {}\nstdout:\n{}\nstderr:\n{}", + generator.generator_type(), + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +// Value: protects=reqwest generated composite error payloads compile; +// fails_when=payload type syntax, imports, aliases, or decode inference is invalid; +// why_new=existing compiled fixtures only cover named error model payloads; +// seam=CodeGenerator::generate and cargo check; no_claim=live HTTP decoding. +#[test] +fn reqwest_composite_error_payloads_compile() { + check_error_payload_crate(&RustReqwestCodeGenerator::new(empty_config())); +} + +// Value: protects=ureq generated composite error payloads compile; +// fails_when=payload type syntax, imports, aliases, or decode inference is invalid; +// why_new=existing compiled fixtures only cover named error model payloads; +// seam=CodeGenerator::generate and cargo check; no_claim=live HTTP decoding. +#[test] +fn ureq_composite_error_payloads_compile() { + check_error_payload_crate(&RustUreqCodeGenerator::new(empty_config())); +} + +// Value: protects=aioduct generated composite error payloads compile; +// fails_when=payload types or generic backend bodies cannot type-check; +// why_new=existing aioduct layout test only covers named error payloads; +// seam=CodeGenerator::generate and cargo check; no_claim=live HTTP decoding. +#[test] +fn aioduct_composite_error_payloads_compile() { + check_error_payload_crate(&RustAioductCodeGenerator::new(empty_config())); +}