diff --git a/CHANGELOG.md b/CHANGELOG.md index c57018c..046f5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ when correcting output that was wrong or incomplete on the wire. ## [Unreleased] +### Changed + +- **Breaking (generated API).** Positional item schemas — 2020-12 + `prefixItems` and the draft-04 `items: [A, B]` spelling — now generate typed + Rust tuples instead of `Vec`, when the spec pins the + array's length (`minItems`/`maxItems`, `items: false`, or + `additionalItems: false`). A `[string, integer]` pair becomes + `(String, i64)`; a `$ref` position keeps its named type, and an inline object + position is hoisted to one. When no extras are allowed but the length varies + and every position shares a type, the array becomes `Vec`. + + An *open* `prefixItems` still generates `Vec` on purpose: + it permits extra elements of any type, and a fixed-arity tuple would reject + payloads the spec allows (#62). + +### Fixed + +- `items: false` and `items: true` — 2020-12 boolean schemas, and the canonical + way to close a tuple — now parse instead of failing the document with "data + did not match any variant of untagged enum Schema" (#62). + ## [0.13.0] - 2026-08-26 ### Changed diff --git a/README.md b/README.md index cea220d..9289032 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,7 @@ focused fixture when relying on a less-common OpenAPI or JSON Schema keyword. | `type` as array (e.g. `["string", "null"]`) | typed + used for nullability | | `prefixItems`, `unevaluatedItems`, `contains` / `minContains` / `maxContains` | typed | | draft-04 positional `items: [A, B]` (FastAPI/pydantic v1 emits it under 3.1) | typed as `prefixItems` | +| Fixed-length `prefixItems` | generated as Rust tuples, e.g. `(String, i64)` | | `patternProperties`, `propertyNames`, `unevaluatedProperties` | typed | | `dependentRequired`, `dependentSchemas`, `if` / `then` / `else` | typed | | `contentEncoding`, `contentMediaType`, `contentSchema` | typed | diff --git a/src/analysis.rs b/src/analysis.rs index 522da44..1e89922 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -201,6 +201,12 @@ pub enum SchemaType { Union { variants: Vec }, /// Array type Array { item_type: Box }, + /// Fixed-arity array — one schema per position, no extras — rendered as a + /// Rust tuple. Only emitted when the spec proves the length (see + /// `SchemaDetails::positional_items_are_exact`); an open `prefixItems` + /// stays an `Array`, because serde would reject the extra elements the + /// spec allows. + Tuple { element_types: Vec }, /// String enum StringEnum { values: Vec }, /// Extensible enum with known values and custom variant @@ -3966,76 +3972,242 @@ impl SchemaAnalyzer { ) -> Result { let details = schema.details(); + // Positional schemas first: when the spec pins the length, the array is + // a tuple, and `items` (if present at all) only describes elements that + // cannot occur. + if let Some(positions) = details.positional_items() { + return self.analyze_positional_items( + positions, + details, + parent_schema_name, + dependencies, + ); + } + // Check if items field is present if let Some(items_schema) = details.item_schema() { - // Analyze the item type - let item_type = match items_schema { - Schema::Reference { reference, .. } => { - // Array of referenced types + let item_type = self.analyze_item_schema( + items_schema, + parent_schema_name, + &format!("{parent_schema_name}Item"), + dependencies, + )?; + Ok(SchemaType::Array { + item_type: Box::new(item_type), + }) + } else { + // No items specified, fall back to generic array + Ok(SchemaType::Primitive { + rust_type: "Vec".to_string(), + serde_with: None, + }) + } + } + + /// Analyze positional element schemas — 2020-12 `prefixItems` or the + /// draft-04 `items: [A, B]` tuple form — into the tightest type the spec + /// justifies. + /// + /// Three tiers, because `prefixItems` alone does not bound an array's + /// length and a Rust tuple is fixed-arity: + /// + /// 1. the length is pinned → a tuple, one element per position; + /// 2. no extras are allowed and every position is the same schema → + /// `Vec`, which accepts any permitted length; + /// 3. otherwise → `Vec`, since a payload may legally + /// carry more elements, of other types, than the positions describe. + fn analyze_positional_items( + &mut self, + positions: &[Schema], + details: &crate::openapi::SchemaDetails, + parent_schema_name: &str, + dependencies: &mut HashSet, + ) -> Result { + if details.positional_items_are_exact() && !positions.is_empty() { + let mut element_types = Vec::with_capacity(positions.len()); + for (index, position) in positions.iter().enumerate() { + element_types.push(self.analyze_item_schema( + position, + parent_schema_name, + &format!("{parent_schema_name}Item{}", index + 1), + dependencies, + )?); + } + return Ok(SchemaType::Tuple { element_types }); + } + + // Analyze a shared position only once, and only when the positions are + // interchangeable: analyzing every position would hoist a named type + // per inline object, and tiers 2 and 3 discard all but one of them. + if details.positional_items_are_closed() + && let Some(shared) = shared_positional_schema(positions) + { + let item_type = self.analyze_item_schema( + shared, + parent_schema_name, + &format!("{parent_schema_name}Item"), + dependencies, + )?; + return Ok(SchemaType::Array { + item_type: Box::new(item_type), + }); + } + + Ok(SchemaType::Primitive { + rust_type: "Vec".to_string(), + serde_with: None, + }) + } + + /// Analyze one element schema into its generated type. + /// + /// `inline_name` names whatever has to be hoisted out of an inline element + /// schema — an object, a string enum, a union — so tuple positions can pass + /// a per-position name. `parent_schema_name` stays the enclosing schema, + /// which is what a `$recursiveRef: "#"` element resolves to. + fn analyze_item_schema( + &mut self, + items_schema: &Schema, + parent_schema_name: &str, + inline_name: &str, + dependencies: &mut HashSet, + ) -> Result { + let item_type = match items_schema { + Schema::Reference { reference, .. } => { + // Array of referenced types + let target = self + .extract_schema_name(reference) + .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))? + .to_string(); + dependencies.insert(target.clone()); + SchemaType::Reference { target } + } + Schema::RecursiveRef { recursive_ref, .. } => { + // Array of recursive references + if recursive_ref == "#" { + // Self-reference to the current schema + let target = self + .find_recursive_anchor_schema() + .unwrap_or_else(|| parent_schema_name.to_string()); + dependencies.insert(target.clone()); + SchemaType::Reference { target } + } else { let target = self - .extract_schema_name(reference) - .ok_or_else(|| GeneratorError::UnresolvedReference(reference.to_string()))? + .extract_schema_name(recursive_ref) + .unwrap_or("RecursiveType") .to_string(); dependencies.insert(target.clone()); SchemaType::Reference { target } } - Schema::RecursiveRef { recursive_ref, .. } => { - // Array of recursive references - if recursive_ref == "#" { - // Self-reference to the current schema - let target = self - .find_recursive_anchor_schema() - .unwrap_or_else(|| parent_schema_name.to_string()); - dependencies.insert(target.clone()); - SchemaType::Reference { target } - } else { - let target = self - .extract_schema_name(recursive_ref) - .unwrap_or("RecursiveType") - .to_string(); - dependencies.insert(target.clone()); - SchemaType::Reference { target } - } - } - Schema::Typed { schema_type, .. } => { - // Array of primitive types - match schema_type { - OpenApiSchemaType::String => { - // Inline string enum in array items — hoist to a - // named enum (`{Parent}Item`) instead of collapsing - // to `Vec`. - match items_schema - .details() - .string_enum_values() - .filter(|values| !values.is_empty()) - { - Some(values) => self.hoist_inline_string_enum( - items_schema, - values, - format!("{parent_schema_name}Item"), - dependencies, - ), - None => SchemaType::Primitive { - rust_type: "String".to_string(), - serde_with: None, - }, - } - } - OpenApiSchemaType::Integer | OpenApiSchemaType::Number => { - let details = items_schema.details(); - let rust_type = self.get_number_rust_type(schema_type.clone(), details); - SchemaType::Primitive { - rust_type, + } + Schema::Typed { schema_type, .. } => { + // Array of primitive types + match schema_type { + OpenApiSchemaType::String => { + // Inline string enum in array items — hoist to a + // named enum (`{Parent}Item`) instead of collapsing + // to `Vec`. + match items_schema + .details() + .string_enum_values() + .filter(|values| !values.is_empty()) + { + Some(values) => self.hoist_inline_string_enum( + items_schema, + values, + inline_name.to_string(), + dependencies, + ), + None => SchemaType::Primitive { + rust_type: "String".to_string(), serde_with: None, - } + }, } - OpenApiSchemaType::Boolean => SchemaType::Primitive { - rust_type: "bool".to_string(), + } + OpenApiSchemaType::Integer | OpenApiSchemaType::Number => { + let details = items_schema.details(); + let rust_type = self.get_number_rust_type(schema_type.clone(), details); + SchemaType::Primitive { + rust_type, serde_with: None, - }, + } + } + OpenApiSchemaType::Boolean => SchemaType::Primitive { + rust_type: "bool".to_string(), + serde_with: None, + }, + OpenApiSchemaType::Object => { + // Inline object in array - create a named schema for it + let object_type_name = inline_name.to_string(); + + // Analyze the object schema + let object_type = self.analyze_object_schema(items_schema, dependencies)?; + + // Create an analyzed schema for the inline object + let inline_schema = AnalyzedSchema { + name: object_type_name.clone(), + original: serde_json::to_value(items_schema).unwrap_or(Value::Null), + schema_type: object_type, + dependencies: dependencies.clone(), + nullable: false, + description: items_schema.details().description.clone(), + default: None, + }; + + // Add the inline object as a named schema + self.resolved_cache + .insert(object_type_name.clone(), inline_schema); + dependencies.insert(object_type_name.clone()); + + // Return a reference to the named schema + SchemaType::Reference { + target: object_type_name, + } + } + OpenApiSchemaType::Array => { + // Array of arrays - recursively analyze + self.analyze_array_schema(items_schema, parent_schema_name, dependencies)? + } + _ => SchemaType::Primitive { + rust_type: "serde_json::Value".to_string(), + serde_with: None, + }, + } + } + Schema::OneOf { .. } | Schema::AnyOf { .. } => { + // Union types in arrays - analyze recursively + let analyzed = self.analyze_schema_value(items_schema, "ArrayItem")?; + + // If we got a discriminated union or union, we need to create a separate schema for it + match &analyzed.schema_type { + SchemaType::DiscriminatedUnion { .. } | SchemaType::Union { .. } => { + // Generate a unique name for the union schema based on the parent context + // Use the parent context directly to maintain consistent naming + let union_name = format!("{inline_name}Union"); + + // Create a new analyzed schema with the correct name + let mut union_schema = analyzed; + union_schema.name = union_name.clone(); + + // Add the union as a separate schema + self.resolved_cache.insert(union_name.clone(), union_schema); + + // Add dependency + dependencies.insert(union_name.clone()); + + // Return a reference to the union schema + SchemaType::Reference { target: union_name } + } + _ => analyzed.schema_type, + } + } + Schema::Untyped { .. } => { + // Try to infer the type + if let Some(inferred) = items_schema.inferred_type() { + match inferred { OpenApiSchemaType::Object => { // Inline object in array - create a named schema for it - let object_type_name = format!("{parent_schema_name}Item"); + let object_type_name = inline_name.to_string(); // Analyze the object schema let object_type = @@ -4062,141 +4234,57 @@ impl SchemaAnalyzer { target: object_type_name, } } - OpenApiSchemaType::Array => { - // Array of arrays - recursively analyze - self.analyze_array_schema( - items_schema, - parent_schema_name, - dependencies, - )? - } - _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }, - } - } - Schema::OneOf { .. } | Schema::AnyOf { .. } => { - // Union types in arrays - analyze recursively - let analyzed = self.analyze_schema_value(items_schema, "ArrayItem")?; - - // If we got a discriminated union or union, we need to create a separate schema for it - match &analyzed.schema_type { - SchemaType::DiscriminatedUnion { .. } | SchemaType::Union { .. } => { - // Generate a unique name for the union schema based on the parent context - // Use the parent context directly to maintain consistent naming - let union_name = format!("{parent_schema_name}ItemUnion"); - - // Create a new analyzed schema with the correct name - let mut union_schema = analyzed; - union_schema.name = union_name.clone(); - - // Add the union as a separate schema - self.resolved_cache.insert(union_name.clone(), union_schema); - - // Add dependency - dependencies.insert(union_name.clone()); - - // Return a reference to the union schema - SchemaType::Reference { target: union_name } - } - _ => analyzed.schema_type, - } - } - Schema::Untyped { .. } => { - // Try to infer the type - if let Some(inferred) = items_schema.inferred_type() { - match inferred { - OpenApiSchemaType::Object => { - // Inline object in array - create a named schema for it - let object_type_name = format!("{parent_schema_name}Item"); - - // Analyze the object schema - let object_type = - self.analyze_object_schema(items_schema, dependencies)?; - - // Create an analyzed schema for the inline object - let inline_schema = AnalyzedSchema { - name: object_type_name.clone(), - original: serde_json::to_value(items_schema) - .unwrap_or(Value::Null), - schema_type: object_type, - dependencies: dependencies.clone(), - nullable: false, - description: items_schema.details().description.clone(), - default: None, - }; - - // Add the inline object as a named schema - self.resolved_cache - .insert(object_type_name.clone(), inline_schema); - dependencies.insert(object_type_name.clone()); - - // Return a reference to the named schema - SchemaType::Reference { - target: object_type_name, - } - } - OpenApiSchemaType::String => { - // Typeless (OpenAPI 3.1) enum in array items — - // same hoisting as the typed-string arm. - match items_schema - .details() - .string_enum_values() - .filter(|values| !values.is_empty()) - { - Some(values) => self.hoist_inline_string_enum( - items_schema, - values, - format!("{parent_schema_name}Item"), - dependencies, - ), - None => SchemaType::Primitive { - rust_type: "String".to_string(), - serde_with: None, - }, - } - } - OpenApiSchemaType::Integer | OpenApiSchemaType::Number => { - let details = items_schema.details(); - let rust_type = self.get_number_rust_type(inferred, details); - SchemaType::Primitive { - rust_type, + OpenApiSchemaType::String => { + // Typeless (OpenAPI 3.1) enum in array items — + // same hoisting as the typed-string arm. + match items_schema + .details() + .string_enum_values() + .filter(|values| !values.is_empty()) + { + Some(values) => self.hoist_inline_string_enum( + items_schema, + values, + inline_name.to_string(), + dependencies, + ), + None => SchemaType::Primitive { + rust_type: "String".to_string(), serde_with: None, - } + }, } - OpenApiSchemaType::Boolean => SchemaType::Primitive { - rust_type: "bool".to_string(), - serde_with: None, - }, - _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), + } + OpenApiSchemaType::Integer | OpenApiSchemaType::Number => { + let details = items_schema.details(); + let rust_type = self.get_number_rust_type(inferred, details); + SchemaType::Primitive { + rust_type, serde_with: None, - }, + } } - } else { - SchemaType::Primitive { + OpenApiSchemaType::Boolean => SchemaType::Primitive { + rust_type: "bool".to_string(), + serde_with: None, + }, + _ => SchemaType::Primitive { rust_type: "serde_json::Value".to_string(), serde_with: None, - } + }, + } + } else { + SchemaType::Primitive { + rust_type: "serde_json::Value".to_string(), + serde_with: None, } } - _ => SchemaType::Primitive { - rust_type: "serde_json::Value".to_string(), - serde_with: None, - }, - }; - - Ok(SchemaType::Array { - item_type: Box::new(item_type), - }) - } else { - // No items specified, fall back to generic array - Ok(SchemaType::Primitive { - rust_type: "Vec".to_string(), + } + _ => SchemaType::Primitive { + rust_type: "serde_json::Value".to_string(), serde_with: None, - }) - } + }, + }; + + Ok(item_type) } fn get_number_rust_type( @@ -6032,6 +6120,37 @@ impl SchemaAnalyzer { /// untagged `Schema` enum that is "data did not match any variant of untagged /// enum Schema", with no field, schema name, or position. Tracking the path /// turns that into a JSON Pointer the author can jump straight to (issue #60). +/// The one schema every position shares, if they are interchangeable: the same +/// `$ref`, or the same primitive type and format. Inline objects never qualify +/// — two structurally identical inline objects still hoist two named types, so +/// treating them as one element type would silently drop a generated name. +fn shared_positional_schema(positions: &[Schema]) -> Option<&Schema> { + let first = positions.first()?; + let key = positional_schema_key(first)?; + positions + .iter() + .skip(1) + .all(|position| positional_schema_key(position).as_deref() == Some(key.as_str())) + .then_some(first) +} + +fn positional_schema_key(schema: &Schema) -> Option { + if let Some(reference) = schema.reference() { + return Some(format!("$ref {reference}")); + } + let details = schema.details(); + if details.properties.is_some() || details.enum_values.is_some() || details.items.is_some() { + return None; + } + match schema.schema_type()? { + crate::openapi::SchemaType::Object | crate::openapi::SchemaType::Array => None, + scalar => Some(format!( + "{scalar:?} {}", + details.format.as_deref().unwrap_or_default() + )), + } +} + fn parse_spec_document(openapi_spec: &Value) -> Result { serde_path_to_error::deserialize(openapi_spec).map_err(|error| { let mut pointer = json_pointer(error.path()); @@ -6485,6 +6604,11 @@ fn rewrite_schema_type_names(schema_type: &mut SchemaType, aliases: &BTreeMap rewrite_schema_type_names(item_type, aliases), + SchemaType::Tuple { element_types } => { + for element_type in element_types { + rewrite_schema_type_names(element_type, aliases); + } + } SchemaType::Reference { target } => { *target = renamed_schema_name(target, aliases); } diff --git a/src/generator.rs b/src/generator.rs index 566290c..2f3549e 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1502,6 +1502,20 @@ impl CodeGenerator { Ok(TokenStream::new()) } } + SchemaType::Tuple { element_types } => { + let tuple_type = self.generate_tuple_type(element_types, analysis); + let type_name = format_ident!("{}", self.to_rust_type_name(&schema.name)); + let doc_comment = if let Some(description) = &schema.description { + let sanitized = self.sanitize_doc_comment(description); + quote! { #[doc = #sanitized] } + } else { + TokenStream::new() + }; + Ok(quote! { + #doc_comment + pub type #type_name = #tuple_type; + }) + } SchemaType::Array { item_type } => { // Generate type alias for named array schemas. // @@ -2716,6 +2730,9 @@ impl CodeGenerator { let inner_type = self.generate_array_item_type(item_type, analysis); quote! { Vec<#inner_type> } } + SchemaType::Tuple { element_types } => { + self.generate_tuple_type(element_types, analysis) + } _ => { // Fallback for complex types quote! { serde_json::Value } @@ -2723,6 +2740,27 @@ impl CodeGenerator { } } + /// Render positional element types as a Rust tuple. serde reads and writes + /// these as JSON arrays of exactly this length, which is what makes the + /// analyzer's exact-length rule load-bearing. + fn generate_tuple_type( + &self, + element_types: &[crate::analysis::SchemaType], + analysis: &crate::analysis::SchemaAnalysis, + ) -> TokenStream { + let elements = element_types + .iter() + .map(|element_type| self.generate_array_item_type(element_type, analysis)) + .collect::>(); + // A one-element Rust tuple needs the trailing comma; `(T)` is just `T`, + // which serde would read as a bare value instead of a single-element + // array. + if let [only] = elements.as_slice() { + return quote! { (#only,) }; + } + quote! { (#(#elements),*) } + } + fn generate_serde_field_attrs( &self, schema_name: &str, @@ -3430,6 +3468,9 @@ impl CodeGenerator { let inner_type = self.generate_array_item_type(item_type, analysis); quote! { Vec<#inner_type> } } + SchemaType::Tuple { element_types } => { + self.generate_tuple_type(element_types, analysis) + } _ => { // Fallback for complex types quote! { serde_json::Value } diff --git a/src/openapi.rs b/src/openapi.rs index 91bf6bb..5c8ab66 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -385,6 +385,10 @@ pub enum Items { Single(Box), /// Draft-04 tuple form: one schema per position. Positional(Vec), + /// 2020-12 boolean schema. `items: false` is the canonical way to close a + /// tuple — no elements beyond `prefixItems` — and `items: true` is the + /// no-op "anything goes" schema. + Bool(bool), } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -865,7 +869,7 @@ impl Schema { // Infer from structure if details.properties.is_some() { Some(SchemaType::Object) - } else if details.items.is_some() { + } else if details.items.is_some() || details.prefix_items.is_some() { Some(SchemaType::Array) } else if details.enum_values.is_some() { Some(SchemaType::String) // Assume string enum @@ -882,11 +886,12 @@ impl SchemaDetails { /// The schema every array element must satisfy, i.e. `items` in its /// 2020-12 single-schema spelling. Returns `None` for the draft-04 tuple /// form, which constrains positions rather than every element — read that - /// through [`Self::positional_items`]. + /// through [`Self::positional_items`] — and for a boolean schema, which + /// constrains nothing worth typing. pub fn item_schema(&self) -> Option<&Schema> { match self.items.as_ref()? { Items::Single(schema) => Some(schema), - Items::Positional(_) => None, + Items::Positional(_) | Items::Bool(_) => None, } } @@ -898,8 +903,40 @@ impl SchemaDetails { } match self.items.as_ref()? { Items::Positional(schemas) => Some(schemas), - Items::Single(_) => None, + Items::Single(_) | Items::Bool(_) => None, + } + } + + /// Whether the array admits no elements beyond its positional schemas. + /// + /// This is not the default: `prefixItems: [A, B]` on its own permits extra + /// elements of any type. An array is closed only when 2020-12 `items: + /// false`, draft-04 `additionalItems: false`, or `maxItems` says so. + pub fn positional_items_are_closed(&self) -> bool { + let Some(positions) = self.positional_items() else { + return false; + }; + if matches!(self.items, Some(Items::Bool(false))) { + return true; + } + if self.extra.get("additionalItems") == Some(&Value::Bool(false)) { + return true; } + self.max_items + .is_some_and(|maximum| maximum <= positions.len() as u64) + } + + /// Whether every valid instance has exactly one element per positional + /// schema — the only case a fixed-arity Rust tuple can represent. A closed + /// array that also permits shorter instances is not exact. + pub fn positional_items_are_exact(&self) -> bool { + let Some(positions) = self.positional_items() else { + return false; + }; + self.positional_items_are_closed() + && self + .min_items + .is_some_and(|minimum| minimum >= positions.len() as u64) } /// Check if this schema is nullable diff --git a/src/server/codegen.rs b/src/server/codegen.rs index 7021396..3e9ea97 100644 --- a/src/server/codegen.rs +++ b/src/server/codegen.rs @@ -190,6 +190,11 @@ fn collect_schema_type_refs( } } SchemaType::Array { item_type } => collect_schema_type_refs(item_type, queue, keep), + SchemaType::Tuple { element_types } => { + for element_type in element_types { + collect_schema_type_refs(element_type, queue, keep); + } + } SchemaType::Reference { target } => seed(target, queue, keep), } } diff --git a/tests/tuple_codegen_test.rs b/tests/tuple_codegen_test.rs new file mode 100644 index 0000000..bc7b0ba --- /dev/null +++ b/tests/tuple_codegen_test.rs @@ -0,0 +1,247 @@ +//! Typed tuples for positional item schemas (issue #62). +//! +//! `prefixItems` (and the draft-04 `items: [A, B]` spelling from #60) carry the +//! element types and, when the spec pins the length, the arity. The generator +//! used to drop both and emit `Vec`. +//! +//! The length is the load-bearing part: `prefixItems` on its own does NOT stop +//! an instance from carrying extra elements of any type, and a Rust tuple is +//! fixed-arity. Emitting a tuple for an open array would produce code that +//! compiles and then fails on payloads the spec permits, so the open cases here +//! assert the conservative fallback just as hard as the closed ones assert the +//! tuple. + +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::{Value, json}; + +fn generate(spec: Value) -> String { + let mut analysis = SchemaAnalyzer::new(spec) + .expect("spec parses") + .analyze() + .expect("spec analyzes"); + CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("code generates") +} + +fn spec_with_pair(pair: Value) -> Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "tuples", "version": "1.0.0" }, + "components": { "schemas": { "Body": { + "type": "object", + "required": ["pair"], + "properties": { "pair": pair } + }}} + }) +} + +#[test] +fn exact_length_by_min_and_max_items_generates_a_tuple() { + let generated = generate(spec_with_pair(json!({ + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [{ "type": "string" }, { "type": "integer" }] + }))); + + assert!( + generated.contains("pub pair: (String, i64)"), + "expected a typed tuple, got:\n{generated}" + ); +} + +#[test] +fn draft_04_positional_items_generate_the_same_tuple() { + let generated = generate(spec_with_pair(json!({ + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [{ "type": "string" }, { "type": "integer" }] + }))); + + assert!( + generated.contains("pub pair: (String, i64)"), + "the draft-04 spelling must generate what prefixItems generates, got:\n{generated}" + ); +} + +#[test] +fn items_false_closes_a_tuple() { + // The canonical 2020-12 way to say "no elements beyond the positions". + let generated = generate(spec_with_pair(json!({ + "type": "array", + "minItems": 2, + "prefixItems": [{ "type": "string" }, { "type": "boolean" }], + "items": false + }))); + + assert!( + generated.contains("pub pair: (String, bool)"), + "`items: false` must close the tuple, got:\n{generated}" + ); +} + +#[test] +fn additional_items_false_closes_a_draft_04_tuple() { + let generated = generate(spec_with_pair(json!({ + "type": "array", + "minItems": 2, + "items": [{ "type": "string" }, { "type": "boolean" }], + "additionalItems": false + }))); + + assert!( + generated.contains("pub pair: (String, bool)"), + "`additionalItems: false` must close the tuple, got:\n{generated}" + ); +} + +#[test] +fn open_prefix_items_stay_an_untyped_array() { + // No length cap: ["a", 1, "anything", {}] is a valid instance, so a tuple + // would fail to deserialize data the spec allows. + let generated = generate(spec_with_pair(json!({ + "type": "array", + "prefixItems": [{ "type": "string" }, { "type": "integer" }] + }))); + + assert!( + generated.contains("pub pair: Vec"), + "an open array must not become a tuple, got:\n{generated}" + ); +} + +#[test] +fn closed_variable_length_positions_of_one_type_become_a_vec() { + // At most 2 elements, both strings, but as few as none: no fixed arity to + // spell as a tuple, yet every element is a String. + let generated = generate(spec_with_pair(json!({ + "type": "array", + "maxItems": 2, + "prefixItems": [{ "type": "string" }, { "type": "string" }] + }))); + + assert!( + generated.contains("pub pair: Vec"), + "expected a typed vec, got:\n{generated}" + ); +} + +#[test] +fn closed_variable_length_positions_of_mixed_types_stay_untyped() { + let generated = generate(spec_with_pair(json!({ + "type": "array", + "maxItems": 2, + "prefixItems": [{ "type": "string" }, { "type": "integer" }] + }))); + + assert!( + generated.contains("pub pair: Vec"), + "a variable-length heterogeneous array has no single element type, got:\n{generated}" + ); +} + +#[test] +fn single_position_tuple_keeps_the_trailing_comma() { + let generated = generate(spec_with_pair(json!({ + "type": "array", + "minItems": 1, + "maxItems": 1, + "prefixItems": [{ "type": "string" }] + }))); + + // `(String)` is just `String`; serde would then expect a bare value where + // the spec says one-element array. + assert!( + generated.contains("pub pair: (String,)"), + "expected a one-element tuple, got:\n{generated}" + ); +} + +#[test] +fn referenced_positions_keep_their_named_types() { + let generated = generate(json!({ + "openapi": "3.1.0", + "info": { "title": "tuples", "version": "1.0.0" }, + "components": { "schemas": { + "Point": { "type": "object", "properties": { "x": { "type": "integer" } } }, + "Body": { + "type": "object", + "required": ["pair"], + "properties": { "pair": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [{ "$ref": "#/components/schemas/Point" }, { "type": "number" }] + }} + } + }} + })); + + assert!( + generated.contains("pub pair: (Point, f64)"), + "a $ref position must keep its generated type, got:\n{generated}" + ); + assert!( + generated.contains("pub struct Point"), + "the referenced schema must still be generated, got:\n{generated}" + ); +} + +#[test] +fn inline_object_positions_are_hoisted_to_named_types() { + let generated = generate(spec_with_pair(json!({ + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + { "type": "string" }, + { "type": "object", "properties": { "count": { "type": "integer" } } } + ] + }))); + + assert!( + generated.contains("pub pair: (String, BodyPairItem2)"), + "an inline object position must hoist a named type, got:\n{generated}" + ); + assert!( + generated.contains("pub struct BodyPairItem2"), + "the hoisted type must be generated, got:\n{generated}" + ); + assert!( + generated.contains("pub count"), + "the hoisted type must keep its fields, got:\n{generated}" + ); +} + +#[test] +fn a_named_tuple_schema_generates_a_type_alias() { + let generated = generate(json!({ + "openapi": "3.1.0", + "info": { "title": "tuples", "version": "1.0.0" }, + "components": { "schemas": { + "Coordinate": { + "type": "array", + "description": "A latitude/longitude pair.", + "minItems": 2, + "maxItems": 2, + "prefixItems": [{ "type": "number" }, { "type": "number" }] + }, + "Body": { + "type": "object", + "required": ["at"], + "properties": { "at": { "$ref": "#/components/schemas/Coordinate" } } + } + }} + })); + + assert!( + generated.contains("pub type Coordinate = (f64, f64)"), + "a top-level tuple schema must alias to a tuple, got:\n{generated}" + ); + assert!( + generated.contains("pub at: Coordinate"), + "references to it must use the alias, got:\n{generated}" + ); +} diff --git a/tests/tuple_items_test.rs b/tests/tuple_items_test.rs index acd1640..590756f 100644 --- a/tests/tuple_items_test.rs +++ b/tests/tuple_items_test.rs @@ -52,8 +52,8 @@ fn positional_items_generate_the_same_types_as_prefix_items() { let canonical_form = generate(spec_with_pair_keyword("prefixItems")); assert!( - tuple_form.contains("pub pair: Vec"), - "positional items must produce an array field, got:\n{tuple_form}" + tuple_form.contains("pub pair: (String, String)"), + "positional items must produce the typed tuple, got:\n{tuple_form}" ); assert_eq!( tuple_form, canonical_form,