Skip to main content

oapi_codegen/lower/
schema.rs

1//! Lowering OpenAPI schemas into the [`crate::ir`] representation.
2
3use openapiv3::AdditionalProperties;
4use openapiv3::Discriminator;
5use openapiv3::IntegerFormat;
6use openapiv3::IntegerType;
7use openapiv3::ObjectType;
8use openapiv3::ReferenceOr;
9use openapiv3::Schema;
10use openapiv3::SchemaData;
11use openapiv3::SchemaKind;
12use openapiv3::StringFormat;
13use openapiv3::Type;
14use openapiv3::VariantOrUnknownOrEmpty;
15
16use crate::error::Error;
17use crate::error::Result;
18use crate::ir::Access;
19use crate::ir::Alias;
20use crate::ir::Deprecation;
21use crate::ir::Enum;
22use crate::ir::EnumKind;
23use crate::ir::Field;
24use crate::ir::ForeignDerives;
25use crate::ir::IntegerVariant;
26use crate::ir::Item;
27use crate::ir::Module;
28use crate::ir::RustType;
29use crate::ir::StringVariant;
30use crate::ir::Struct;
31use crate::ir::UnionVariant;
32use crate::loader::Spec;
33use crate::loader::ref_target_name;
34use crate::loader::schema_ref_reason;
35use crate::lower::default::lower_default;
36use crate::naming::Case;
37use crate::naming::X_RUST_NAME;
38use crate::naming::to_ident;
39
40/// The `x-rust-type` extension: emit a verbatim Rust type expression.
41pub(crate) const X_RUST_TYPE: &str = "x-rust-type";
42/// The `x-rust-derive` extension: which of `Debug`, `Clone`, `PartialEq` an
43/// `x-rust-type` target implements.
44const X_RUST_DERIVE: &str = "x-rust-derive";
45/// The `x-rust-serde-skip` extension: drop a field via `#[serde(skip)]`.
46const X_RUST_SERDE_SKIP: &str = "x-rust-serde-skip";
47/// The `x-omitempty` extension: force `skip_serializing_if` on/off for a field.
48const X_OMITEMPTY: &str = "x-omitempty";
49/// The `x-order` extension: explicitly order struct fields (1-indexed).
50const X_ORDER: &str = "x-order";
51/// The `x-deprecated-reason` extension: the note for a `#[deprecated]` item.
52const X_DEPRECATED_REASON: &str = "x-deprecated-reason";
53/// The `x-enum-varnames` extension: override generated enum variant identifiers.
54const X_ENUM_VARNAMES: &str = "x-enum-varnames";
55/// The `x-enumNames` extension: alias of [`X_ENUM_VARNAMES`].
56const X_ENUM_NAMES: &str = "x-enumNames";
57
58/// Cap on inline schema nesting the lowering pass will descend before erroring.
59/// Guards against stack exhaustion on pathological or hostile specs. Well above any
60/// realistic hand-written or generated spec, and independent of whatever
61/// recursion limit the YAML/JSON parser happens to enforce.
62const MAX_SCHEMA_DEPTH: usize = 100;
63
64/// Lower every component schema in `spec` into a module of Rust items.
65///
66/// `names` holds the final Rust type name of every schema, from
67/// [`crate::lower::rename::type_renames`]. The caller resolves the names first,
68/// because it also applies them to the service and decides when to report an
69/// unresolved collision. A module built from unchecked `names` can hold two items
70/// with one name, so the caller must check `names` before the emit pass.
71///
72/// One bad schema does not stop the loop. Component schemas are independent of
73/// each other, so a fault in one says nothing about the next. The pass lowers
74/// them all and reports every fault together, and the author makes one pass over
75/// the document instead of one for each fault. A schema that fails contributes no
76/// item, and the module is dropped, because the collected faults end the run.
77pub fn generate_models(spec: &Spec, names: &crate::lower::rename::TypeNames) -> Result<Module> {
78    let renames = names.renames();
79    let mut mapper = Mapper {
80        spec,
81        renames,
82        extra: Vec::new(),
83        depth: 0,
84    };
85    let mut items = Vec::new();
86    let mut diagnostics = crate::lower::validate::Diagnostics::new();
87    for (name, entry) in spec.schemas() {
88        match entry {
89            ReferenceOr::Item(schema) => match mapper.named_to_item(name, schema) {
90                Ok(item) => items.push(item),
91                Err(problem) => diagnostics.push(problem),
92            },
93            ReferenceOr::Reference { reference } => {
94                match mapper.schema_ref_target(reference, "a top-level schema alias") {
95                    Ok(target) => items.push(Item::Alias(Alias {
96                        name: mapper.type_name_ident(name),
97                        doc: None,
98                        deprecated: None,
99                        ty: RustType::Named(target),
100                    })),
101                    Err(problem) => diagnostics.push(problem),
102                }
103            }
104        }
105    }
106    diagnostics.into_result()?;
107    items.append(&mut mapper.extra);
108    let mut module = Module { items };
109    crate::lower::rename::rewrite_module(&mut module, renames);
110    return Ok(module);
111}
112
113/// Lowers schemas into IR items, accumulating hoisted inline types in `extra`.
114struct Mapper<'a> {
115    spec: &'a Spec,
116    /// `x-rust-name` overrides keyed by original schema name.
117    renames: &'a std::collections::HashMap<String, String>,
118    extra: Vec<Item>,
119    /// Current inline-nesting depth, bounded by [`MAX_SCHEMA_DEPTH`].
120    depth: usize,
121}
122
123impl Mapper<'_> {
124    /// The name a schema `$ref` at `site` points to.
125    ///
126    /// Two faults end generation here. The ref can have a form no schema site
127    /// accepts, which [`schema_ref_reason`] describes. The ref can also name a
128    /// schema the document does not declare. Without the second check the name
129    /// reaches the output, and the generated file does not compile.
130    fn schema_ref_target(&self, reference: &str, site: &str) -> Result<String> {
131        let target = ref_target_name(reference).ok_or_else(|| {
132            return Error::UnsupportedRef {
133                reference: reference.to_owned(),
134                reason: schema_ref_reason(reference, site),
135            };
136        })?;
137        if !self.spec.schemas().contains_key(target) {
138            return Err(Error::UnresolvedRef(reference.to_owned()));
139        }
140        return Ok(target.to_owned());
141    }
142
143    /// The identifier for a top-level type, honouring an `x-rust-name` override.
144    fn type_name_ident(&self, name: &str) -> crate::naming::RustIdent {
145        let effective = self.renames.get(name).map(String::as_str).unwrap_or(name);
146        return to_ident(effective, Case::Pascal);
147    }
148
149    /// Lower a top-level named schema into a single item.
150    fn named_to_item(&mut self, name: &str, schema: &Schema) -> Result<Item> {
151        let data = &schema.schema_data;
152
153        if let Some(verbatim) = extension_str(data, X_RUST_TYPE, name)? {
154            return Ok(Item::Alias(Alias {
155                name: self.type_name_ident(name),
156                doc: doc_of(data),
157                deprecated: deprecation_of(data, name)?,
158                ty: verbatim_type(data, verbatim, name)?,
159            }));
160        }
161
162        let item = match &schema.schema_kind {
163            SchemaKind::Type(Type::String(st)) if !st.enumeration.is_empty() => {
164                Item::Enum(self.string_enum(name, &st.enumeration, data)?)
165            }
166            SchemaKind::Type(Type::Integer(it)) if !it.enumeration.is_empty() => {
167                let repr = integer_type(it);
168                Item::Enum(self.integer_enum(name, &it.enumeration, &repr, data)?)
169            }
170            SchemaKind::Type(Type::Object(obj)) => self.object_to_item(name, obj, data)?,
171            SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => {
172                Item::Enum(self.make_union(name, one_of, data)?)
173            }
174            SchemaKind::AllOf { all_of } => match self.single_ref_all_of(all_of)? {
175                Some(target) => Item::Alias(Alias {
176                    name: self.type_name_ident(name),
177                    doc: doc_of(data),
178                    deprecated: deprecation_of(data, name)?,
179                    ty: RustType::Named(target),
180                }),
181                None => Item::Struct(self.merge_all_of(name, all_of, data)?),
182            },
183            SchemaKind::Type(_) => {
184                let ty = self.type_from_schema(name, schema)?;
185                Item::Alias(Alias {
186                    name: self.type_name_ident(name),
187                    doc: doc_of(data),
188                    deprecated: deprecation_of(data, name)?,
189                    ty,
190                })
191            }
192            SchemaKind::Any(_) => Item::Alias(Alias {
193                name: self.type_name_ident(name),
194                doc: doc_of(data),
195                deprecated: deprecation_of(data, name)?,
196                ty: RustType::Value,
197            }),
198            SchemaKind::Not { .. } => {
199                return Err(Error::UnsupportedSchema {
200                    path: name.to_owned(),
201                    reason: "`not` schemas are not supported".to_owned(),
202                });
203            }
204        };
205        return Ok(item);
206    }
207
208    /// Lower an object schema: a struct when it has properties, otherwise a map
209    /// alias.
210    fn object_to_item(&mut self, name: &str, obj: &ObjectType, data: &SchemaData) -> Result<Item> {
211        if obj.properties.is_empty() {
212            let element = self.additional_properties_type(name, obj)?;
213            return Ok(Item::Alias(Alias {
214                name: self.type_name_ident(name),
215                doc: doc_of(data),
216                deprecated: deprecation_of(data, name)?,
217                ty: RustType::Map(Box::new(element)),
218            }));
219        }
220        let strukt = self.object_to_struct(name, obj, data)?;
221        return Ok(Item::Struct(strukt));
222    }
223
224    /// Build a struct from an object schema's properties.
225    fn object_to_struct(&mut self, name: &str, obj: &ObjectType, data: &SchemaData) -> Result<Struct> {
226        let mut ordered = Vec::with_capacity(obj.properties.len());
227        for (prop_name, prop) in &obj.properties {
228            let required = obj.required.iter().any(|r| {
229                return r == prop_name;
230            });
231            let order = prop_order(prop, &format!("{name}.{prop_name}"))?;
232            let field = self.field_from_prop(name, prop_name, prop, required)?;
233            ordered.push((order, field));
234        }
235        let fields = sort_by_order(ordered);
236
237        let additional_properties = match &obj.additional_properties {
238            Some(AdditionalProperties::Schema(schema)) => {
239                let ty = self.type_from_ref_schema(name, schema.as_ref())?;
240                Some(ty)
241            }
242            Some(AdditionalProperties::Any(true)) => Some(RustType::Value),
243            Some(AdditionalProperties::Any(false)) | None => None,
244        };
245        // Only an explicit `false` denies unknown keys. An absent key permits
246        // them, which is serde's behaviour with no attribute.
247        let deny_unknown_fields = matches!(&obj.additional_properties, Some(AdditionalProperties::Any(false)));
248
249        return Ok(Struct {
250            name: self.type_name_ident(name),
251            doc: doc_of(data),
252            deprecated: deprecation_of(data, name)?,
253            fields,
254            additional_properties,
255            deny_unknown_fields,
256        });
257    }
258
259    /// Build a single struct field from a property schema.
260    fn field_from_prop(
261        &mut self,
262        parent: &str,
263        wire: &str,
264        prop: &ReferenceOr<Box<Schema>>,
265        required: bool,
266    ) -> Result<Field> {
267        let hint = format!("{parent}_{wire}");
268        let at = format!("{parent}.{wire}");
269        let mut ty = self.type_from_schema_ref(&hint, prop)?;
270
271        let data = match prop {
272            ReferenceOr::Item(schema) => Some(&schema.schema_data),
273            ReferenceOr::Reference { .. } => None,
274        };
275
276        // A required property is always present, so its `default` never
277        // applies. Use it, and a payload that omits a required property becomes
278        // valid.
279        let declared = data
280            .filter(|_| return !required)
281            .and_then(|data| return data.default.as_ref());
282
283        let nullable = data.map(|data| return data.nullable).unwrap_or(false);
284        // With a default, an absent property ends up the same as a present one,
285        // so `Option` would only ever hold `Some`. `nullable` is the exception,
286        // because there `null` is a value the property carries.
287        if (!required && declared.is_none()) || nullable {
288            ty = ty.optional();
289        }
290
291        let default = match declared {
292            Some(json) => {
293                let variants_of = |name: &str| {
294                    let ident = self.type_name_ident(name);
295                    return self.extra.iter().find_map(|item| {
296                        return match item {
297                            Item::Enum(enom) if enom.name == ident => match &enom.kind {
298                                EnumKind::Strings(variants) => Some(variants.clone()),
299                                EnumKind::Union(_) | EnumKind::Integers { .. } => None,
300                            },
301                            _ => None,
302                        };
303                    });
304                };
305                Some(lower_default(json, &ty, &variants_of, parent, wire)?)
306            }
307            None => None,
308        };
309
310        let doc = data.and_then(doc_of);
311        let deprecated = match data {
312            Some(data) => deprecation_of(data, &at)?,
313            None => None,
314        };
315        let serde_skip = match data {
316            Some(data) => extension_bool(data, X_RUST_SERDE_SKIP, &at)?.unwrap_or(false),
317            None => false,
318        };
319        let omit_empty = match data {
320            Some(data) => extension_bool(data, X_OMITEMPTY, &at)?,
321            None => None,
322        };
323
324        let rust_name = match data {
325            Some(data) => extension_str(data, X_RUST_NAME, &at)?,
326            None => None,
327        };
328        let ident = match rust_name {
329            Some(custom) => to_ident(custom, Case::Snake),
330            None => to_ident(wire, Case::Snake),
331        };
332        let rename = crate::naming::rename_for(wire, &ident);
333        let access = match prop {
334            ReferenceOr::Item(schema) => access_of(&schema.schema_data, &at)?,
335            // A `$ref` property carries no sibling keyword in OpenAPI 3.0, so
336            // the mark can only sit on the target.
337            ReferenceOr::Reference { reference } => match self.spec.resolve(reference) {
338                Ok(target) => access_of(&target.schema_data, &at)?,
339                Err(_) => Access::ReadWrite,
340            },
341        };
342        let constraints = match prop {
343            ReferenceOr::Item(schema) => crate::lower::constraints::constraints_of(schema),
344            // The alias a `$ref` makes carries no serde attribute, so the field
345            // takes the checks the target declares.
346            ReferenceOr::Reference { reference } => self
347                .spec
348                .resolve(reference)
349                .ok()
350                .and_then(crate::lower::constraints::constraints_through_ref),
351        };
352        let field = Field {
353            name: ident,
354            rename,
355            doc,
356            deprecated,
357            ty,
358            required,
359            omit_empty,
360            serde_skip,
361            default,
362            constraints,
363            access,
364        };
365        crate::lower::constraints::check_constraints(&field)?;
366        return Ok(field);
367    }
368
369    /// Return the referenced schema name when `members` is a single `$ref`
370    /// member, for collapsing a one-element `allOf` at the top level into a type
371    /// alias. Only `$ref` members qualify: a single inline member is left to the
372    /// struct-merge path so a schema never aliases itself.
373    fn single_ref_all_of(&self, members: &[ReferenceOr<Schema>]) -> Result<Option<String>> {
374        let [ReferenceOr::Reference { reference }] = members else {
375            return Ok(None);
376        };
377        let target = self.schema_ref_target(reference, "an allOf member")?;
378        return Ok(Some(target));
379    }
380
381    /// Collapse a single-member `allOf` to the type of its sole member.
382    ///
383    /// A one-element `allOf` carries no composition — it exists only to attach
384    /// sibling keywords (`nullable`, `description`) to a `$ref`, which is the
385    /// canonical OpenAPI 3.0 way to annotate or make a reference nullable. In
386    /// that case the wrapper must resolve to the referenced type itself (reusing
387    /// the shared named schema, and working for enum/union targets too) rather
388    /// than synthesizing a duplicate struct. Multi-member `allOf` is genuine
389    /// composition and returns `None` so the caller merges it as before.
390    fn collapse_single_all_of(&mut self, hint: &str, members: &[ReferenceOr<Schema>]) -> Result<Option<RustType>> {
391        let [only] = members else {
392            return Ok(None);
393        };
394        let ty = match only {
395            ReferenceOr::Reference { reference } => {
396                let target = self.schema_ref_target(reference, "an allOf member")?;
397                RustType::Named(target)
398            }
399            ReferenceOr::Item(schema) => self.type_from_schema(hint, schema)?,
400        };
401        return Ok(Some(ty));
402    }
403
404    /// Merge an `allOf` into a single flat struct, resolving `$ref` members to
405    /// pull in their properties (matching oapi-codegen's behaviour).
406    fn merge_all_of(&mut self, name: &str, members: &[ReferenceOr<Schema>], data: &SchemaData) -> Result<Struct> {
407        let mut merged = MergedObject::default();
408        self.absorb_members(name, members, &mut merged)?;
409
410        let mut ordered = Vec::with_capacity(merged.properties.len());
411        for (wire, prop) in &merged.properties {
412            let required = merged.required.iter().any(|r| {
413                return r == wire;
414            });
415            let order = prop_order(prop, &format!("{name}.{wire}"))?;
416            let field = self.field_from_prop(name, wire, prop, required)?;
417            ordered.push((order, field));
418        }
419        let fields = sort_by_order(ordered);
420
421        return Ok(Struct {
422            name: self.type_name_ident(name),
423            doc: doc_of(data),
424            deprecated: deprecation_of(data, name)?,
425            fields,
426            additional_properties: None,
427            // A merge does not read `additionalProperties` from any member. In
428            // JSON Schema each `allOf` member validates the whole object, so a
429            // member with `additionalProperties: false` rejects every property
430            // that a sibling member declares. A merge that honoured it would
431            // deny the fields it just merged in. The merge drops the key, as it
432            // already drops a member's `additionalProperties` schema.
433            deny_unknown_fields: false,
434        });
435    }
436
437    /// Recursively fold `allOf` members (objects, refs to objects, or nested
438    /// `allOf`) into a single merged object. Shares the [`MAX_SCHEMA_DEPTH`]
439    /// counter with [`Self::type_from_schema`] so nested `allOf` cannot exhaust
440    /// the stack independently of inline-type nesting.
441    fn absorb_members(&mut self, name: &str, members: &[ReferenceOr<Schema>], merged: &mut MergedObject) -> Result<()> {
442        if self.depth >= MAX_SCHEMA_DEPTH {
443            return Err(Error::SchemaDepthExceeded {
444                path: name.to_owned(),
445                limit: MAX_SCHEMA_DEPTH,
446            });
447        }
448        self.depth += 1;
449        let result = self.absorb_members_inner(name, members, merged);
450        self.depth -= 1;
451        return result;
452    }
453
454    fn absorb_members_inner(
455        &mut self,
456        name: &str,
457        members: &[ReferenceOr<Schema>],
458        merged: &mut MergedObject,
459    ) -> Result<()> {
460        for member in members {
461            let schema = match member {
462                ReferenceOr::Item(schema) => schema,
463                ReferenceOr::Reference { reference } => self.spec.resolve(reference)?,
464            };
465            match &schema.schema_kind {
466                SchemaKind::Type(Type::Object(obj)) => merged.absorb(obj),
467                SchemaKind::AllOf { all_of } => self.absorb_members(name, all_of, merged)?,
468                SchemaKind::Type(_)
469                | SchemaKind::OneOf { .. }
470                | SchemaKind::AnyOf { .. }
471                | SchemaKind::Any(_)
472                | SchemaKind::Not { .. } => {
473                    return Err(Error::UnsupportedSchema {
474                        path: name.to_owned(),
475                        reason: "allOf members must be objects or refs to objects".to_owned(),
476                    });
477                }
478            }
479        }
480        return Ok(());
481    }
482    fn make_union(&mut self, name: &str, members: &[ReferenceOr<Schema>], data: &SchemaData) -> Result<Enum> {
483        let variants = match &data.discriminator {
484            Some(disc) if !disc.mapping.is_empty() => self.union_variants_from_mapping(disc)?,
485            Some(_) | None => self.union_variants_from_members(name, members)?,
486        };
487        check_variant_types(name, &variants)?;
488        return Ok(Enum {
489            name: self.type_name_ident(name),
490            doc: doc_of(data),
491            deprecated: deprecation_of(data, name)?,
492            kind: EnumKind::Union(variants),
493        });
494    }
495
496    /// Variant list derived from a discriminator mapping (value -> $ref).
497    fn union_variants_from_mapping(&self, disc: &Discriminator) -> Result<Vec<UnionVariant>> {
498        let mut variants = Vec::with_capacity(disc.mapping.len());
499        let mut seen = std::collections::HashSet::new();
500        for (value, reference) in &disc.mapping {
501            let target = self.schema_ref_target(reference, "a discriminator mapping")?;
502            variants.push(UnionVariant {
503                name: crate::naming::deconflict_ident(to_ident(value, Case::Pascal), &mut seen),
504                ty: RustType::Named(target),
505            });
506        }
507        return Ok(variants);
508    }
509
510    /// Variant list derived from the `oneOf`/`anyOf` member schemas directly.
511    fn union_variants_from_members(
512        &mut self,
513        name: &str,
514        members: &[ReferenceOr<Schema>],
515    ) -> Result<Vec<UnionVariant>> {
516        let mut variants = Vec::with_capacity(members.len());
517        let mut seen = std::collections::HashSet::new();
518        let mut diagnostics = crate::lower::validate::Diagnostics::new();
519        for (index, member) in members.iter().enumerate() {
520            let variant = match member {
521                ReferenceOr::Reference { reference } => {
522                    let target = self.schema_ref_target(reference, "a union member")?;
523                    // Name the variant from the *resolved* type name, so an
524                    // `x-rust-name` override or a configured collision suffix
525                    // reaches the variant too. The raw target name would give a
526                    // variant that contradicts its own payload type.
527                    UnionVariant {
528                        name: crate::naming::deconflict_ident(self.type_name_ident(&target), &mut seen),
529                        ty: RustType::Named(target),
530                    }
531                }
532                ReferenceOr::Item(schema) => {
533                    let Some(seed) = inline_variant_seed(schema, &format!("{name}, member {index}"))? else {
534                        diagnostics.push(Error::UnsupportedSchema {
535                            path: name.to_owned(),
536                            reason: format!("member {index} of the union gives the variant no name"),
537                        });
538                        continue;
539                    };
540                    // The seed names the variant. A type the member hoists goes
541                    // to the crate root, where the seed alone says nothing about
542                    // where it came from and can meet the same name from another
543                    // union. The union name goes in front of it, as it does for
544                    // an inline property. The variant keeps the short name,
545                    // because the enum already says which union it belongs to.
546                    let ty = self.type_from_schema(&format!("{name}_{seed}"), schema)?;
547                    UnionVariant {
548                        name: crate::naming::deconflict_ident(to_ident(&seed, Case::Pascal), &mut seen),
549                        ty,
550                    }
551                }
552            };
553            variants.push(variant);
554        }
555        diagnostics.into_result()?;
556        return Ok(variants);
557    }
558
559    /// Build a string enum from an OpenAPI string `enum`.
560    ///
561    /// `x-enum-varnames` / `x-enumNames` override variant identifiers positionally
562    /// (in declaration order). The wire value is preserved via `#[serde(rename)]`.
563    ///
564    /// A repeated value is an error. The second variant would take the same
565    /// `rename`, which leaves it unreachable and compiles only with a warning.
566    fn string_enum(&self, name: &str, values: &[Option<String>], data: &SchemaData) -> Result<Enum> {
567        let varnames = match extension_str_array(data, X_ENUM_VARNAMES, name)? {
568            Some(names) => Some(names),
569            None => extension_str_array(data, X_ENUM_NAMES, name)?,
570        };
571        let mut diagnostics = crate::lower::validate::Diagnostics::new();
572        let mut variants = Vec::new();
573        let mut seen = std::collections::HashSet::new();
574        let mut values_seen = std::collections::HashSet::new();
575        for (index, value) in values.iter().flatten().enumerate() {
576            if !values_seen.insert(value.as_str()) {
577                diagnostics.push(Error::UnsupportedSchema {
578                    path: name.to_owned(),
579                    reason: format!("the `enum` gives `{value}` more than once"),
580                });
581                continue;
582            }
583            let base = match varnames.as_ref().and_then(|names| return names.get(index)) {
584                Some(custom) => to_ident(custom, Case::Pascal),
585                None => to_ident(value, Case::Pascal),
586            };
587            let ident = crate::naming::deconflict_ident(base, &mut seen);
588            let rename = crate::naming::rename_for(value, &ident);
589            variants.push(StringVariant {
590                name: ident,
591                rename,
592                doc: None,
593            });
594        }
595        diagnostics.into_result()?;
596        return Ok(Enum {
597            name: self.type_name_ident(name),
598            doc: doc_of(data),
599            deprecated: deprecation_of(data, name)?,
600            kind: EnumKind::Strings(variants),
601        });
602    }
603
604    /// Lower an integer schema that carries an `enum` into a C-like Rust enum.
605    ///
606    /// A variant takes its name from `x-enum-varnames` when the document gives
607    /// one. Otherwise the name comes from the value: `1` gives `Value1`, and
608    /// `-1` gives `ValueMinus1`.
609    ///
610    /// A repeated value is an error, because two variants cannot share one
611    /// discriminant (`E0081`). A value the `format` cannot hold is an error for
612    /// the same reason: the literal does not fit the `repr`.
613    fn integer_enum(&self, name: &str, values: &[Option<i64>], repr: &RustType, data: &SchemaData) -> Result<Enum> {
614        let varnames = match extension_str_array(data, X_ENUM_VARNAMES, name)? {
615            Some(names) => Some(names),
616            None => extension_str_array(data, X_ENUM_NAMES, name)?,
617        };
618        let mut diagnostics = crate::lower::validate::Diagnostics::new();
619        let mut variants = Vec::new();
620        let mut seen = std::collections::HashSet::new();
621        let mut values_seen = std::collections::HashSet::new();
622        for (index, value) in values.iter().flatten().enumerate() {
623            if !values_seen.insert(*value) {
624                diagnostics.push(Error::UnsupportedSchema {
625                    path: name.to_owned(),
626                    reason: format!("the `enum` gives `{value}` more than once"),
627                });
628                continue;
629            }
630            if !fits_repr(*value, repr) {
631                diagnostics.push(Error::UnsupportedSchema {
632                    path: name.to_owned(),
633                    reason: format!("the `enum` gives `{value}`, which `{}` cannot hold", repr_name(repr)),
634                });
635                continue;
636            }
637            let base = match varnames.as_ref().and_then(|names| return names.get(index)) {
638                Some(custom) => to_ident(custom, Case::Pascal),
639                None => to_ident(&integer_variant_name(*value), Case::Pascal),
640            };
641            variants.push(IntegerVariant {
642                name: crate::naming::deconflict_ident(base, &mut seen),
643                value: *value,
644                doc: None,
645            });
646        }
647        diagnostics.into_result()?;
648        return Ok(Enum {
649            name: self.type_name_ident(name),
650            doc: doc_of(data),
651            deprecated: deprecation_of(data, name)?,
652            kind: EnumKind::Integers {
653                repr: repr.clone(),
654                variants,
655            },
656        });
657    }
658
659    /// Resolve a property/items/additionalProperties schema reference to a type,
660    /// hoisting inline named types into `self.extra` as needed.
661    fn type_from_schema_ref(&mut self, hint: &str, schema: &ReferenceOr<Box<Schema>>) -> Result<RustType> {
662        match schema {
663            ReferenceOr::Reference { reference } => {
664                let target = self.schema_ref_target(reference, "a property")?;
665                return Ok(RustType::Named(target));
666            }
667            ReferenceOr::Item(schema) => {
668                let ty = self.type_from_schema(hint, schema)?;
669                return Ok(ty);
670            }
671        }
672    }
673
674    /// Resolve an `additionalProperties` schema (an unboxed `ReferenceOr`) to a
675    /// type.
676    fn type_from_ref_schema(&mut self, hint: &str, schema: &ReferenceOr<Schema>) -> Result<RustType> {
677        match schema {
678            ReferenceOr::Reference { reference } => {
679                let target = self.schema_ref_target(reference, "additionalProperties")?;
680                return Ok(RustType::Named(target));
681            }
682            ReferenceOr::Item(schema) => {
683                let ty = self.type_from_schema(hint, schema)?;
684                return Ok(ty);
685            }
686        }
687    }
688
689    /// Map an inline schema to a Rust type, hoisting composite inline schemas
690    /// into named items. Bounds inline nesting via [`MAX_SCHEMA_DEPTH`] so a
691    /// pathological spec errors cleanly instead of exhausting the stack.
692    fn type_from_schema(&mut self, hint: &str, schema: &Schema) -> Result<RustType> {
693        if self.depth >= MAX_SCHEMA_DEPTH {
694            return Err(Error::SchemaDepthExceeded {
695                path: hint.to_owned(),
696                limit: MAX_SCHEMA_DEPTH,
697            });
698        }
699        self.depth += 1;
700        let result = self.type_from_schema_inner(hint, schema);
701        self.depth -= 1;
702        return result;
703    }
704
705    fn type_from_schema_inner(&mut self, hint: &str, schema: &Schema) -> Result<RustType> {
706        let data = &schema.schema_data;
707        if let Some(verbatim) = extension_str(data, X_RUST_TYPE, hint)? {
708            return verbatim_type(data, verbatim, hint);
709        }
710
711        let ty = match &schema.schema_kind {
712            SchemaKind::Type(Type::String(st)) if !st.enumeration.is_empty() => {
713                let enom = self.string_enum(hint, &st.enumeration, data)?;
714                self.extra.push(Item::Enum(enom));
715                RustType::Named(hint.to_owned())
716            }
717            SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
718            SchemaKind::Type(Type::Integer(it)) if !it.enumeration.is_empty() => {
719                let repr = integer_type(it);
720                let enom = self.integer_enum(hint, &it.enumeration, &repr, data)?;
721                self.extra.push(Item::Enum(enom));
722                RustType::Named(hint.to_owned())
723            }
724            SchemaKind::Type(Type::Integer(it)) => integer_type(it),
725            SchemaKind::Type(Type::Number(_)) => RustType::F64,
726            SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
727            SchemaKind::Type(Type::Array(at)) => {
728                let element = match &at.items {
729                    Some(items) => {
730                        let item_hint = format!("{hint}_item");
731                        self.type_from_schema_ref(&item_hint, items)?
732                    }
733                    None => RustType::Value,
734                };
735                RustType::Vec(Box::new(element))
736            }
737            SchemaKind::Type(Type::Object(obj)) => self.inline_object_type(hint, obj, data)?,
738            SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => {
739                let enom = self.make_union(hint, one_of, data)?;
740                self.extra.push(Item::Enum(enom));
741                RustType::Named(hint.to_owned())
742            }
743            SchemaKind::AllOf { all_of } => {
744                if let Some(ty) = self.collapse_single_all_of(hint, all_of)? {
745                    ty
746                } else {
747                    let strukt = self.merge_all_of(hint, all_of, data)?;
748                    self.extra.push(Item::Struct(strukt));
749                    RustType::Named(hint.to_owned())
750                }
751            }
752            SchemaKind::Any(_) => RustType::Value,
753            SchemaKind::Not { .. } => {
754                return Err(Error::UnsupportedSchema {
755                    path: hint.to_owned(),
756                    reason: "`not` schemas are not supported".to_owned(),
757                });
758            }
759        };
760        return Ok(ty);
761    }
762
763    /// Map an inline object: hoist a struct when it has properties, otherwise a
764    /// map of its additionalProperties element type.
765    fn inline_object_type(&mut self, hint: &str, obj: &ObjectType, data: &SchemaData) -> Result<RustType> {
766        if obj.properties.is_empty() {
767            let element = self.additional_properties_type(hint, obj)?;
768            return Ok(RustType::Map(Box::new(element)));
769        }
770        let strukt = self.object_to_struct(hint, obj, data)?;
771        self.extra.push(Item::Struct(strukt));
772        return Ok(RustType::Named(hint.to_owned()));
773    }
774
775    /// Element type for an object used purely as a map (`additionalProperties`).
776    fn additional_properties_type(&mut self, hint: &str, obj: &ObjectType) -> Result<RustType> {
777        let element = match &obj.additional_properties {
778            Some(AdditionalProperties::Schema(schema)) => self.type_from_ref_schema(hint, schema.as_ref())?,
779            Some(AdditionalProperties::Any(_)) | None => RustType::Value,
780        };
781        return Ok(element);
782    }
783}
784
785/// Accumulates merged properties of an `allOf`, preserving first-seen order.
786#[derive(Default)]
787struct MergedObject {
788    properties: indexmap::IndexMap<String, ReferenceOr<Box<Schema>>>,
789    required: Vec<String>,
790}
791
792impl MergedObject {
793    /// Fold one object schema's properties and required list into the merge.
794    fn absorb(&mut self, obj: &ObjectType) {
795        for (name, prop) in &obj.properties {
796            self.properties.insert(name.clone(), prop.clone());
797        }
798        for req in &obj.required {
799            if !self.required.contains(req) {
800                self.required.push(req.clone());
801            }
802        }
803    }
804}
805
806/// Map a string `format` to a Rust type.
807pub(crate) fn string_format_type(format: &VariantOrUnknownOrEmpty<StringFormat>) -> RustType {
808    let ty = match format {
809        VariantOrUnknownOrEmpty::Item(StringFormat::Date) => RustType::Date,
810        VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => RustType::DateTime,
811        VariantOrUnknownOrEmpty::Item(StringFormat::Byte) => RustType::Bytes,
812        VariantOrUnknownOrEmpty::Item(StringFormat::Binary) => RustType::Bytes,
813        VariantOrUnknownOrEmpty::Item(StringFormat::Password) => RustType::String,
814        VariantOrUnknownOrEmpty::Unknown(name) if name == "uuid" => RustType::Uuid,
815        VariantOrUnknownOrEmpty::Unknown(_) => RustType::String,
816        VariantOrUnknownOrEmpty::Empty => RustType::String,
817    };
818    return ty;
819}
820
821/// Whether an integer enum value fits the `repr` its `format` and `minimum` choose.
822///
823/// An unsigned `repr` holds no negative value, so a `minimum` of zero with a
824/// negative `enum` value is a document that disagrees with itself.
825fn fits_repr(value: i64, repr: &RustType) -> bool {
826    return match *repr {
827        RustType::I32 => i32::try_from(value).is_ok(),
828        RustType::U32 => u32::try_from(value).is_ok(),
829        RustType::U64 => u64::try_from(value).is_ok(),
830        _ => true,
831    };
832}
833
834/// The Rust name of an integer enum `repr`, for a diagnostic.
835fn repr_name(repr: &RustType) -> &'static str {
836    return match *repr {
837        RustType::I32 => "i32",
838        RustType::U32 => "u32",
839        RustType::U64 => "u64",
840        _ => "i64",
841    };
842}
843
844/// The default name for an integer enum variant, from its value.
845fn integer_variant_name(value: i64) -> String {
846    if value < 0 {
847        return format!("value_minus_{}", value.unsigned_abs());
848    }
849    return format!("value_{value}");
850}
851
852/// The name an inline union member gives its variant, if it gives one at all.
853///
854/// `x-rust-name` names any member. A member that holds one `enum` value is named
855/// by that value. Otherwise only a member that hoists no type of its own can be
856/// named, and the type it holds gives that name.
857///
858/// A member that hoists — an object, a list, a map, an `enum` of several values —
859/// needs a name for the hoisted type as well as for the variant. The position
860/// would give one, but a position carries no meaning and moves when the document
861/// changes, so the author gives the name instead.
862fn inline_variant_seed(schema: &Schema, at: &str) -> Result<Option<String>> {
863    if let Some(custom) = extension_str(&schema.schema_data, X_RUST_NAME, at)? {
864        return Ok(Some(custom.to_owned()));
865    }
866    if let Some(value) = single_enum_value(schema) {
867        return Ok(Some(value));
868    }
869    let Some(ty) = non_hoisting_type(schema) else {
870        return Ok(None);
871    };
872    return Ok(type_variant_name(&ty).map(str::to_owned));
873}
874
875/// The one value a member holds, when the member is a string `enum` of one value.
876///
877/// Such a member stands for a constant. The value names the variant, because a
878/// constant says what it is and the position adds nothing. A document that lists
879/// a Rust enum as a `oneOf` writes every unit variant this way, so this shape is
880/// common.
881fn single_enum_value(schema: &Schema) -> Option<String> {
882    let SchemaKind::Type(Type::String(st)) = &schema.schema_kind else {
883        return None;
884    };
885    let [Some(value)] = st.enumeration.as_slice() else {
886        return None;
887    };
888    return Some(value.clone());
889}
890
891/// The type an inline union member holds when it hoists nothing.
892///
893/// `None` means the member hoists a type of its own, which then needs a name.
894fn non_hoisting_type(schema: &Schema) -> Option<RustType> {
895    return match &schema.schema_kind {
896        SchemaKind::Type(Type::String(st)) if st.enumeration.is_empty() => Some(string_format_type(&st.format)),
897        SchemaKind::Type(Type::Integer(it)) if it.enumeration.is_empty() => Some(integer_type(it)),
898        SchemaKind::Type(Type::Number(_)) => Some(RustType::F64),
899        SchemaKind::Type(Type::Boolean(_)) => Some(RustType::Bool),
900        _ => None,
901    };
902}
903
904/// The variant name a type gives, for a union member that hoists nothing.
905///
906/// A union cannot hold one type twice, so these names stay unique.
907fn type_variant_name(ty: &RustType) -> Option<&'static str> {
908    return match ty {
909        RustType::Bool => Some("Bool"),
910        RustType::I32 => Some("I32"),
911        RustType::I64 => Some("I64"),
912        RustType::U32 => Some("U32"),
913        RustType::U64 => Some("U64"),
914        RustType::F64 => Some("F64"),
915        RustType::String => Some("String"),
916        RustType::Date => Some("Date"),
917        RustType::DateTime => Some("DateTime"),
918        RustType::Uuid => Some("Uuid"),
919        RustType::Bytes => Some("Bytes"),
920        _ => None,
921    };
922}
923
924/// Reject a union that holds one type more than once.
925///
926/// The emitted enum is `#[serde(untagged)]`. Serde reads the variants in order
927/// and takes the first that fits, so a repeated type makes the later variant
928/// unreachable. A value built with that variant comes back as the earlier one,
929/// which changes the value and reports nothing.
930fn check_variant_types(name: &str, variants: &[UnionVariant]) -> Result<()> {
931    let mut diagnostics = crate::lower::validate::Diagnostics::new();
932    for (index, variant) in variants.iter().enumerate() {
933        let Some(earlier) = variants.iter().take(index).find(|other| return other.ty == variant.ty) else {
934            continue;
935        };
936        diagnostics.push(Error::UnsupportedSchema {
937            path: name.to_owned(),
938            reason: format!(
939                "the union holds `{}` twice, as `{}` and as `{}`",
940                variant.ty.label(),
941                earlier.name.logical(),
942                variant.name.logical()
943            ),
944        });
945    }
946    return diagnostics.into_result();
947}
948
949/// Map an integer schema to a Rust type.
950///
951/// `format` gives the width. A lowest value of zero or more gives the sign: the
952/// document states the value is never negative, so an unsigned type says the
953/// same thing in the type system, and the reader of the field needs no check to
954/// know it. OpenAPI has no unsigned format, so the bound is the only place a
955/// document can put this. `exclusiveMinimum` counts too, because a whole number
956/// above `-1` is zero or more.
957///
958/// The bound stays a bound. It still becomes a check where the value comes in,
959/// unless the type already refuses every value it would reject.
960pub(crate) fn integer_type(it: &IntegerType) -> RustType {
961    let unsigned = matches!(crate::lower::constraints::inclusive_minimum(it), Some(minimum) if minimum >= 0);
962    let ty = match (&it.format, unsigned) {
963        (VariantOrUnknownOrEmpty::Item(IntegerFormat::Int32), false) => RustType::I32,
964        (VariantOrUnknownOrEmpty::Item(IntegerFormat::Int32), true) => RustType::U32,
965        (VariantOrUnknownOrEmpty::Item(IntegerFormat::Int64), false)
966        | (VariantOrUnknownOrEmpty::Unknown(_) | VariantOrUnknownOrEmpty::Empty, false) => RustType::I64,
967        (VariantOrUnknownOrEmpty::Item(IntegerFormat::Int64), true)
968        | (VariantOrUnknownOrEmpty::Unknown(_) | VariantOrUnknownOrEmpty::Empty, true) => RustType::U64,
969    };
970    return ty;
971}
972
973/// Extract a string-valued extension (for example `x-rust-type`) from schema data.
974fn extension_str<'a>(data: &'a SchemaData, key: &str, at: &str) -> Result<Option<&'a str>> {
975    return crate::lower::extension::str_value(&data.extensions, key, at);
976}
977
978/// Extract a boolean-valued extension (for example `x-omitempty`) from schema data.
979fn extension_bool(data: &SchemaData, key: &str, at: &str) -> Result<Option<bool>> {
980    return crate::lower::extension::bool_value(&data.extensions, key, at);
981}
982
983/// Extract an integer-valued extension (for example `x-order`) from schema data.
984fn extension_i64(data: &SchemaData, key: &str, at: &str) -> Result<Option<i64>> {
985    return crate::lower::extension::i64_value(&data.extensions, key, at);
986}
987
988/// The `x-order` value of a property, if it carries one (only inline schemas can).
989fn prop_order(prop: &ReferenceOr<Box<Schema>>, at: &str) -> Result<Option<i64>> {
990    return match prop {
991        ReferenceOr::Item(schema) => extension_i64(&schema.schema_data, X_ORDER, at),
992        ReferenceOr::Reference { .. } => Ok(None),
993    };
994}
995
996/// Order fields by their `x-order` (ascending), keeping fields without one in
997/// their original declaration order after the ordered ones (a stable sort with
998/// unordered fields treated as coming last).
999fn sort_by_order(mut fields: Vec<(Option<i64>, Field)>) -> Vec<Field> {
1000    fields.sort_by_key(|(order, _)| {
1001        return order.unwrap_or(i64::MAX);
1002    });
1003    return fields.into_iter().map(|(_, field)| return field).collect();
1004}
1005
1006/// Extract a string-array extension (for example `x-enum-varnames`).
1007fn extension_str_array<'a>(data: &'a SchemaData, key: &str, at: &str) -> Result<Option<Vec<&'a str>>> {
1008    return crate::lower::extension::str_list_value(&data.extensions, key, at);
1009}
1010
1011/// The trait names `x-rust-derive` accepts, in the order the derive list emits
1012/// them. Only these three, because they are the only traits the generator derives
1013/// on a model without being asked. A serde trait is decided by the direction the
1014/// API uses the model in, which [`crate::emit::usage`] computes and no
1015/// specification overrides.
1016const FOREIGN_DERIVE_NAMES: [&str; 3] = ["Debug", "Clone", "PartialEq"];
1017
1018/// Read `x-rust-derive` into a [`ForeignDerives`].
1019///
1020/// The value lists the traits the target **does** implement, so an absent key
1021/// means all three and an empty list means none. Listing what is present rather
1022/// than what is missing keeps the specification readable: a reader sees the
1023/// target's capability and not a double negative.
1024///
1025/// An unknown trait name is an error and not an ignored key. The whole point of
1026/// the extension is to stop a bound the author cannot satisfy, so a misspelled
1027/// `Parialeq` that silently claims nothing would give exactly the compile error
1028/// the author wrote the key to avoid, with nothing pointing at the typo.
1029fn foreign_derives_of(data: &SchemaData, path: &str) -> Result<ForeignDerives> {
1030    let Some(value) = data.extensions.get(X_RUST_DERIVE) else {
1031        return Ok(ForeignDerives::default());
1032    };
1033    let Some(array) = value.as_array() else {
1034        return Err(Error::UnsupportedSchema {
1035            path: path.to_owned(),
1036            reason: format!("`{X_RUST_DERIVE}` must be a list of trait names, for example `[Debug, Clone]`"),
1037        });
1038    };
1039
1040    let mut derives = ForeignDerives {
1041        debug: false,
1042        clone: false,
1043        partial_eq: false,
1044    };
1045    for entry in array {
1046        let Some(name) = entry.as_str() else {
1047            return Err(Error::UnsupportedSchema {
1048                path: path.to_owned(),
1049                reason: format!("every `{X_RUST_DERIVE}` entry must be a trait name written as a string"),
1050            });
1051        };
1052        match name {
1053            "Debug" => derives.debug = true,
1054            "Clone" => derives.clone = true,
1055            "PartialEq" => derives.partial_eq = true,
1056            other => {
1057                let known = FOREIGN_DERIVE_NAMES.join(", ");
1058                return Err(Error::UnsupportedSchema {
1059                    path: path.to_owned(),
1060                    reason: format!("`{X_RUST_DERIVE}` does not accept `{other}`. It accepts only {known}"),
1061                });
1062            }
1063        }
1064    }
1065    return Ok(derives);
1066}
1067
1068/// Lower an `x-rust-type` target into a [`RustType::Verbatim`], reading its
1069/// `x-rust-derive` alongside. Both keys sit on one schema, so they are read
1070/// together and neither site has to remember the other exists.
1071fn verbatim_type(data: &SchemaData, verbatim: &str, path: &str) -> Result<RustType> {
1072    return Ok(RustType::Verbatim {
1073        text: verbatim.to_owned(),
1074        derives: foreign_derives_of(data, path)?,
1075    });
1076}
1077
1078/// Read `readOnly`/`writeOnly` into the direction they name.
1079///
1080/// A property that sets both marks states that no direction can carry it. The
1081/// generator rejects that rather than pick one of the two marks.
1082pub(crate) fn access_of(data: &SchemaData, at: &str) -> Result<Access> {
1083    return match (data.read_only, data.write_only) {
1084        (true, true) => Err(Error::UnsupportedSchema {
1085            path: at.to_owned(),
1086            reason: "`readOnly` and `writeOnly` are both set, so no request and no response could \
1087                     carry the property. Set at most one of the two."
1088                .to_owned(),
1089        }),
1090        (true, false) => Ok(Access::ReadOnly),
1091        (false, true) => Ok(Access::WriteOnly),
1092        (false, false) => Ok(Access::ReadWrite),
1093    };
1094}
1095
1096/// Derive a `#[deprecated]` annotation from `deprecated: true` and an optional
1097/// `x-deprecated-reason` note. Returns `None` unless the schema is deprecated,
1098/// so a lone `x-deprecated-reason` is a no-op (matching `oapi-codegen`).
1099fn deprecation_of(data: &SchemaData, at: &str) -> Result<Option<Deprecation>> {
1100    if !data.deprecated {
1101        return Ok(None);
1102    }
1103    let note = extension_str(data, X_DEPRECATED_REASON, at)?.map(str::to_owned);
1104    return Ok(Some(Deprecation { note }));
1105}
1106
1107/// Trim and normalise a schema `description` into a doc comment.
1108fn doc_of(data: &SchemaData) -> Option<String> {
1109    let text = data.description.as_ref()?;
1110    let trimmed = text.trim();
1111    if trimmed.is_empty() {
1112        return None;
1113    }
1114    return Some(trimmed.to_owned());
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use std::path::PathBuf;
1120
1121    use super::*;
1122
1123    /// Parse an inline OpenAPI document and emit the generated Rust source.
1124    fn emit_yaml(yaml: &str) -> String {
1125        let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
1126        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1127        let module = lower_models(&spec).expect("map schemas");
1128        return crate::emit::emit_module(&module, None).expect("emit module");
1129    }
1130
1131    /// Lower a spec and give back the outcome, so a test can check a rejection.
1132    fn lower_yaml(yaml: &str) -> Result<Module> {
1133        let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
1134        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1135        return lower_models(&spec);
1136    }
1137
1138    /// Lower every schema with no configured suffix. None of the specs below
1139    /// holds a name collision, so the resolved names need no further check.
1140    fn lower_models(spec: &Spec) -> Result<Module> {
1141        let names = crate::lower::rename::type_renames(spec, None)?;
1142        return generate_models(spec, &names);
1143    }
1144
1145    const PREAMBLE: &str = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\ncomponents:\n  schemas:\n";
1146
1147    /// A schema of `depth` nested inline arrays terminating in a string, built
1148    /// programmatically so the depth guard — not the YAML parser's own recursion
1149    /// limit or a parse-time stack overflow — is what the test exercises.
1150    fn nested_array_schema(depth: usize) -> Schema {
1151        let mut kind = SchemaKind::Type(Type::String(Default::default()));
1152        for _ in 0..depth {
1153            let items = ReferenceOr::Item(Box::new(Schema {
1154                schema_data: SchemaData::default(),
1155                schema_kind: kind,
1156            }));
1157            kind = SchemaKind::Type(Type::Array(openapiv3::ArrayType {
1158                items: Some(items),
1159                min_items: None,
1160                max_items: None,
1161                unique_items: false,
1162            }));
1163        }
1164        return Schema {
1165            schema_data: SchemaData::default(),
1166            schema_kind: kind,
1167        };
1168    }
1169
1170    fn spec_with_schema(name: &str, schema: Schema) -> Spec {
1171        let empty_doc = "openapi: 3.0.3\ninfo:\n  title: t\n  version: '1'\npaths: {}\n";
1172        let mut doc: openapiv3::OpenAPI = serde_yaml::from_str(empty_doc).expect("parse preamble");
1173        doc.components
1174            .get_or_insert_with(Default::default)
1175            .schemas
1176            .insert(name.to_owned(), ReferenceOr::Item(schema));
1177        return Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1178    }
1179    // The two tests below bracket the guard boundary exactly: the deepest
1180    // schema that lowers is `MAX_SCHEMA_DEPTH - 1` levels, and reaching
1181    // `MAX_SCHEMA_DEPTH` errors. Any off-by-one in the guard breaks one of them.
1182
1183    #[test]
1184    fn schema_at_the_depth_limit_errors_instead_of_overflowing() {
1185        let spec = spec_with_schema("Deep", nested_array_schema(MAX_SCHEMA_DEPTH));
1186        let err = lower_models(&spec).expect_err("reaching the limit should hit the depth guard");
1187        assert!(
1188            matches!(err, Error::SchemaDepthExceeded { limit, .. } if limit == MAX_SCHEMA_DEPTH),
1189            "expected SchemaDepthExceeded, got {err:?}"
1190        );
1191    }
1192
1193    #[test]
1194    fn schema_just_under_the_depth_limit_still_lowers() {
1195        let spec = spec_with_schema("Deep", nested_array_schema(MAX_SCHEMA_DEPTH - 1));
1196        lower_models(&spec).expect("just under the limit should lower cleanly");
1197    }
1198
1199    /// Lower an inline document and return the error it gives.
1200    fn lower_error(yaml: &str) -> Error {
1201        let doc: openapiv3::OpenAPI = serde_yaml::from_str(yaml).expect("parse spec");
1202        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1203        return lower_models(&spec).expect_err("the spec should not lower");
1204    }
1205
1206    /// A schema whose `x-order` holds a string, which the extension rejects.
1207    fn bad_order_schema(name: &str) -> String {
1208        return format!(
1209            "    {name}:\n      type: object\n      properties:\n        id:\n          type: string\n          x-order: 'first'\n"
1210        );
1211    }
1212
1213    #[test]
1214    fn every_bad_schema_is_reported_in_one_run() {
1215        // Component schemas are independent, so a fault in one says nothing about
1216        // the next. Reporting the first alone costs the author one run for each
1217        // fault.
1218        let alpha = bad_order_schema("Alpha");
1219        let beta = bad_order_schema("Beta");
1220        let gamma = bad_order_schema("Gamma");
1221        let err = lower_error(&format!("{PREAMBLE}{alpha}{beta}{gamma}"));
1222        let Error::Validation { problems } = &err else {
1223            panic!("expected Validation, got: {err:?}");
1224        };
1225        assert_eq!(problems.len(), 3, "every bad schema should be reported");
1226        let message = err.to_string();
1227        for name in ["Alpha", "Beta", "Gamma"] {
1228            assert!(message.contains(name), "message should name `{name}`: {message}");
1229        }
1230    }
1231
1232    #[test]
1233    fn a_good_schema_beside_a_bad_one_does_not_add_a_problem() {
1234        let alpha = bad_order_schema("Alpha");
1235        let err = lower_error(&format!("{PREAMBLE}{alpha}    Beta:\n      type: string\n"));
1236        assert!(
1237            matches!(&err, Error::InvalidExtensionValue { at, .. } if at == "Alpha.id"),
1238            "one problem should stay unwrapped, got: {err:?}",
1239        );
1240    }
1241
1242    #[test]
1243    fn a_union_member_holding_one_enum_value_is_named_by_that_value() {
1244        // The shape a document gives a Rust enum whose variants carry no data.
1245        // Before this rule every member needed an `x-rust-name`.
1246        let out = emit_yaml(&format!(
1247            "{PREAMBLE}    Signal:\n      oneOf:\n      - type: string\n        enum: [red]\n      - type: string\n        enum: [amber]\n"
1248        ));
1249        assert!(out.contains("Red(SignalRed)"), "expected a named variant, got: {out}");
1250        assert!(
1251            out.contains("Amber(SignalAmber)"),
1252            "expected a named variant, got: {out}"
1253        );
1254        // The hoisted type carries the wire value, so the untagged enum writes
1255        // the constant and not `null`.
1256        assert!(
1257            out.contains("rename = \"red\""),
1258            "the hoisted type should keep the wire value, got: {out}"
1259        );
1260    }
1261
1262    #[test]
1263    fn a_hoisted_union_member_type_carries_the_union_name() {
1264        // Two unions can each hold a member named `Unknown`. Without the union
1265        // name in front, both hoist to `Unknown` and generation stops.
1266        let out = emit_yaml(&format!(
1267            "{PREAMBLE}    Left:\n      oneOf:\n      - x-rust-name: Unknown\n        type: object\n        required: [a]\n        properties:\n          a:\n            type: string\n    Right:\n      oneOf:\n      - x-rust-name: Unknown\n        type: object\n        required: [b]\n        properties:\n          b:\n            type: string\n"
1268        ));
1269        assert!(out.contains("struct LeftUnknown"), "expected LeftUnknown, got: {out}");
1270        assert!(out.contains("struct RightUnknown"), "expected RightUnknown, got: {out}");
1271        // The variant keeps the short name; the enum already says which union.
1272        assert!(
1273            out.contains("Unknown(LeftUnknown)"),
1274            "expected a short variant, got: {out}"
1275        );
1276    }
1277
1278    /// Lower a spec whose one schema carries an `x-rust-derive` and return the
1279    /// error, for the shapes the extension rejects.
1280    fn derive_error(value: &str) -> Error {
1281        let yaml = format!(
1282            "{PREAMBLE}    Target:\n      type: string\n      x-rust-type: crate::Foreign\n      x-rust-derive: {value}\n"
1283        );
1284        let doc: openapiv3::OpenAPI = serde_yaml::from_str(&yaml).expect("parse spec");
1285        let spec = Spec::from_parts(doc, PathBuf::from("inline.yaml"));
1286        return lower_models(&spec).expect_err("the extension should reject this value");
1287    }
1288
1289    #[test]
1290    fn absent_x_rust_derive_claims_every_trait() {
1291        // The default every specification written before the extension existed
1292        // relies on. A model reaching the target keeps all three traits.
1293        let out = emit_yaml(&format!(
1294            "{PREAMBLE}    Holder:\n      type: object\n      required: [value]\n      properties:\n        value:\n          type: string\n          x-rust-type: crate::Foreign\n"
1295        ));
1296        assert!(
1297            out.contains("#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]"),
1298            "absent key should change nothing:\n{out}"
1299        );
1300    }
1301
1302    #[test]
1303    fn empty_x_rust_derive_drops_every_trait() {
1304        let out = emit_yaml(&format!(
1305            "{PREAMBLE}    Holder:\n      type: object\n      required: [value]\n      properties:\n        value:\n          type: string\n          x-rust-type: crate::Foreign\n          x-rust-derive: []\n"
1306        ));
1307        assert!(
1308            out.contains("#[derive(serde::Serialize, serde::Deserialize)]"),
1309            "an empty list claims nothing, so only the serde derives remain:\n{out}"
1310        );
1311    }
1312
1313    #[test]
1314    fn partial_x_rust_derive_keeps_only_the_listed_traits() {
1315        let out = emit_yaml(&format!(
1316            "{PREAMBLE}    Holder:\n      type: object\n      required: [value]\n      properties:\n        value:\n          type: string\n          x-rust-type: crate::Foreign\n          x-rust-derive: [Debug, PartialEq]\n"
1317        ));
1318        assert!(
1319            out.contains("#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]"),
1320            "Clone was not listed, so it is dropped:\n{out}"
1321        );
1322    }
1323
1324    #[test]
1325    fn x_rust_derive_that_is_not_a_list_is_rejected() {
1326        let err = derive_error("Debug");
1327        assert!(
1328            matches!(&err, Error::UnsupportedSchema { reason, .. } if reason.contains("must be a list")),
1329            "expected a list-shape error, got {err:?}"
1330        );
1331    }
1332
1333    #[test]
1334    fn x_rust_derive_entry_that_is_not_a_string_is_rejected() {
1335        let err = derive_error("[7]");
1336        assert!(
1337            matches!(&err, Error::UnsupportedSchema { reason, .. } if reason.contains("written as a string")),
1338            "expected a string-entry error, got {err:?}"
1339        );
1340    }
1341
1342    #[test]
1343    fn misspelled_trait_name_is_an_error_and_not_an_ignored_key() {
1344        // Silently accepting `Parialeq` would claim nothing and produce exactly the
1345        // compile error the author wrote the key to prevent, with nothing pointing
1346        // at the typo.
1347        let err = derive_error("[Parialeq]");
1348        assert!(
1349            matches!(&err, Error::UnsupportedSchema { reason, .. } if reason.contains("Parialeq")),
1350            "expected the unknown name in the error, got {err:?}"
1351        );
1352    }
1353
1354    #[test]
1355    fn maps_string_and_integer_formats() {
1356        let yaml = format!(
1357            "{PREAMBLE}    Thing:\n      type: object\n      required: [day, at, id, blob, big]\n      properties:\n        day:\n          type: string\n          format: date\n        at:\n          type: string\n          format: date-time\n        id:\n          type: string\n          format: uuid\n        blob:\n          type: string\n          format: byte\n        big:\n          type: integer\n          format: int64\n"
1358        );
1359        let out = emit_yaml(&yaml);
1360        assert!(out.contains("pub day: chrono::NaiveDate"), "{out}");
1361        assert!(out.contains("pub at: chrono::DateTime<chrono::Utc>"), "{out}");
1362        assert!(out.contains("pub id: uuid::Uuid"), "{out}");
1363        assert!(out.contains("pub blob: Vec<u8>"), "{out}");
1364        assert!(out.contains("pub big: i64"), "{out}");
1365    }
1366
1367    #[test]
1368    fn an_unsigned_field_keeps_every_check_the_type_does_not_already_make() {
1369        let yaml = format!(
1370            "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: int32\n          minimum: 0\n          maximum: 130\n"
1371        );
1372        let out = emit_yaml(&yaml);
1373        assert!(out.contains("pub count: u32"), "{out}");
1374        // The upper bound still has values to reject, so it stays.
1375        assert!(out.contains("`count` must be 130 or less"), "{out}");
1376        // `u32` refuses a value below zero on its own, so the lower bound
1377        // writes a test no value fails. Rust reads `x < 0` on an unsigned type
1378        // as a warning.
1379        assert!(!out.contains("must be 0 or more"), "{out}");
1380        assert!(!out.contains("*item < 0"), "{out}");
1381    }
1382
1383    #[test]
1384    fn an_exclusive_bound_moves_onto_the_whole_number_beside_it() {
1385        // A whole number above `-1` is zero or more, so the field takes an
1386        // unsigned type and needs no check. Each case gives the keyword, the
1387        // bound, the expected type, and the text the message holds.
1388        let cases: [(&str, &str, &str, Option<&str>); 5] = [
1389            ("minimum: -1\n          exclusiveMinimum: true", "int32", "u32", None),
1390            (
1391                "minimum: 0\n          exclusiveMinimum: true",
1392                "int32",
1393                "u32",
1394                Some("must be 1 or more"),
1395            ),
1396            (
1397                "minimum: -2\n          exclusiveMinimum: true",
1398                "int32",
1399                "i32",
1400                Some("must be -1 or more"),
1401            ),
1402            (
1403                "maximum: 2147483648\n          exclusiveMaximum: true",
1404                "int32",
1405                "i32",
1406                None,
1407            ),
1408            ("minimum: -1\n          exclusiveMinimum: true", "int64", "u64", None),
1409        ];
1410        for (bound, format, ty, message) in cases {
1411            let yaml = format!(
1412                "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: {format}\n          {bound}\n"
1413            );
1414            let out = emit_yaml(&yaml);
1415            assert!(out.contains(&format!("pub count: {ty}")), "{bound}: {out}");
1416            match message {
1417                Some(text) => assert!(out.contains(text), "{bound}: {out}"),
1418                None => assert!(!out.contains("must be"), "{bound}: {out}"),
1419            }
1420        }
1421    }
1422
1423    #[test]
1424    fn a_bound_that_lands_on_the_limit_of_the_type_writes_no_check() {
1425        // `i32` refuses every value above 2147483647 on its own, so the test
1426        // would never fail, and Rust reads it as a useless comparison.
1427        let yaml = format!(
1428            "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: int32\n          minimum: -2147483648\n          maximum: 2147483647\n"
1429        );
1430        let out = emit_yaml(&yaml);
1431        assert!(out.contains("pub count: i32"), "{out}");
1432        assert!(!out.contains("must be"), "{out}");
1433    }
1434
1435    #[test]
1436    fn bounds_that_meet_nowhere_are_refused() {
1437        // A field whose bounds accept no value would refuse every request, so
1438        // the fault belongs at generation. A bound the author writes out of
1439        // range is a different fault, and it keeps the message about width.
1440        // Each case gives the `format`, the bounds, and the text of the fault.
1441        let cases: [(&str, &str, Option<&str>); 13] = [
1442            (
1443                "integer\n          format: int32",
1444                "maximum: -2147483648\n          exclusiveMaximum: true",
1445                Some("nothing lies below `-2147483648`, where `i32` starts"),
1446            ),
1447            (
1448                "integer\n          format: int32",
1449                "maximum: -2147483647\n          exclusiveMaximum: true",
1450                None,
1451            ),
1452            (
1453                "integer",
1454                "minimum: 10\n          maximum: 5",
1455                Some("they allow `10` to `5`"),
1456            ),
1457            ("integer", "minimum: 5\n          maximum: 5", None),
1458            (
1459                "integer",
1460                "maximum: -9223372036854775808\n          exclusiveMaximum: true",
1461                Some("nothing lies below `-9223372036854775808`, where `i64` starts"),
1462            ),
1463            (
1464                "integer\n          format: int32",
1465                "minimum: 4294967295\n          exclusiveMinimum: true",
1466                Some("nothing lies above `4294967295`, where `u32` stops"),
1467            ),
1468            (
1469                "integer\n          format: int32",
1470                "minimum: 2147483647\n          maximum: 2147483647",
1471                None,
1472            ),
1473            // `u64` reaches above where `i64` stops, so this bound is reachable.
1474            (
1475                "integer\n          format: int64",
1476                "minimum: 9223372036854775807\n          exclusiveMinimum: true",
1477                None,
1478            ),
1479            // `u32` stops first, so the same bound reaches nothing.
1480            (
1481                "integer\n          format: int32",
1482                "minimum: 9223372036854775807\n          exclusiveMinimum: true",
1483                Some("nothing lies above `9223372036854775807`, where `u32` stops"),
1484            ),
1485            // A float folds no flag, so both readings run on the bounds as written.
1486            (
1487                "number",
1488                "minimum: 10\n          maximum: 5",
1489                Some("they allow `10` to `5`"),
1490            ),
1491            (
1492                "number",
1493                "minimum: 5\n          maximum: 5\n          exclusiveMinimum: true",
1494                Some("they meet at `5`, which an `exclusive` flag then leaves out"),
1495            ),
1496            (
1497                "number",
1498                "minimum: 0\n          maximum: 1\n          exclusiveMaximum: true",
1499                None,
1500            ),
1501            // Written out of range, not folded there: `i64` holds this bound.
1502            (
1503                "integer\n          format: int32",
1504                "maximum: -5000000000",
1505                Some("the `maximum` value `-5000000000` does not fit `i32`"),
1506            ),
1507        ];
1508        for (kind, bounds, fault) in cases {
1509            let yaml = format!(
1510                "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: {kind}\n          {bounds}\n"
1511            );
1512            let outcome = lower_yaml(&yaml);
1513            match fault {
1514                Some(text) => {
1515                    let error = outcome.expect_err(bounds).to_string();
1516                    assert!(error.contains(text), "{bounds}: {error}");
1517                }
1518                None => assert!(outcome.is_ok(), "{bounds}: got {outcome:?}"),
1519            }
1520        }
1521    }
1522
1523    #[test]
1524    fn a_negative_multiple_of_reports_one_fault_only() {
1525        // An unsigned type holds no negative value, so the width check would
1526        // report the step a second time. The step is not above zero whatever the
1527        // width, so widening the type is the wrong fix and the wrong hint.
1528        let yaml = format!(
1529            "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: int32\n          minimum: 0\n          multipleOf: -1\n"
1530        );
1531        let fault = lower_yaml(&yaml).expect_err("refuse the step").to_string();
1532        assert!(fault.contains("is not above zero"), "{fault}");
1533        assert!(!fault.contains("does not fit"), "{fault}");
1534    }
1535
1536    #[test]
1537    fn an_enum_value_must_fit_the_repr_the_format_and_the_minimum_choose() {
1538        // Each case gives the `format`, the `minimum`, the value, and whether
1539        // the value fits. A `minimum` of zero makes the `repr` unsigned, and an
1540        // unsigned `repr` holds no negative value.
1541        let cases: [(&str, &str, i64, bool); 8] = [
1542            ("int32", "", -1, true),
1543            ("int32", "", 4_294_967_296, false),
1544            ("int32", "\n      minimum: 0", -1, false),
1545            ("int32", "\n      minimum: 0", 5, true),
1546            ("int32", "\n      minimum: 0", 4_294_967_296, false),
1547            ("int64", "\n      minimum: 0", -1, false),
1548            ("int64", "\n      minimum: 0", 4_294_967_296, true),
1549            ("int64", "", -1, true),
1550        ];
1551        for (format, minimum, value, fits) in cases {
1552            let yaml = format!(
1553                "{PREAMBLE}    Offset:\n      type: integer\n      format: {format}{minimum}\n      enum:\n        - {value}\n"
1554            );
1555            let outcome = lower_yaml(&yaml);
1556            assert_eq!(
1557                outcome.is_ok(),
1558                fits,
1559                "format `{format}`, minimum `{minimum}`, value `{value}`: got {outcome:?}"
1560            );
1561        }
1562    }
1563
1564    #[test]
1565    fn a_minimum_of_zero_or_more_gives_an_unsigned_type() {
1566        let yaml = format!(
1567            "{PREAMBLE}    Thing:\n      type: object\n      required: [count, total, plain, signed, above]\n      properties:\n        count:\n          type: integer\n          format: int32\n          minimum: 0\n        total:\n          type: integer\n          format: int64\n          minimum: 0\n        plain:\n          type: integer\n          minimum: 0\n        signed:\n          type: integer\n          format: int32\n          minimum: -1\n        above:\n          type: integer\n          format: int32\n          minimum: 5\n"
1568        );
1569        let out = emit_yaml(&yaml);
1570        assert!(out.contains("pub count: u32"), "{out}");
1571        assert!(out.contains("pub total: u64"), "{out}");
1572        assert!(out.contains("pub plain: u64"), "{out}");
1573        // A negative bound leaves the value able to go below zero.
1574        assert!(out.contains("pub signed: i32"), "{out}");
1575        // Any bound at zero or above says the same thing about the sign.
1576        assert!(out.contains("pub above: u32"), "{out}");
1577    }
1578
1579    #[test]
1580    fn an_integer_without_a_minimum_stays_signed() {
1581        let yaml = format!(
1582            "{PREAMBLE}    Thing:\n      type: object\n      required: [count]\n      properties:\n        count:\n          type: integer\n          format: int32\n          maximum: 10\n"
1583        );
1584        let out = emit_yaml(&yaml);
1585        assert!(out.contains("pub count: i32"), "{out}");
1586    }
1587
1588    #[test]
1589    fn object_with_only_additional_properties_becomes_map_alias() {
1590        let yaml =
1591            format!("{PREAMBLE}    Dict:\n      type: object\n      additionalProperties:\n        type: string\n");
1592        let out = emit_yaml(&yaml);
1593        assert!(
1594            out.contains("pub type Dict = std::collections::HashMap<String, String>;"),
1595            "{out}"
1596        );
1597    }
1598
1599    #[test]
1600    fn inline_nested_object_is_hoisted() {
1601        let yaml = format!(
1602            "{PREAMBLE}    Outer:\n      type: object\n      required: [inner]\n      properties:\n        inner:\n          type: object\n          required: [x]\n          properties:\n            x:\n              type: string\n"
1603        );
1604        let out = emit_yaml(&yaml);
1605        assert!(out.contains("pub struct Outer"), "{out}");
1606        assert!(out.contains("pub inner: OuterInner"), "{out}");
1607        assert!(out.contains("pub struct OuterInner"), "{out}");
1608        assert!(out.contains("pub x: String"), "{out}");
1609    }
1610
1611    #[test]
1612    fn x_rust_type_emits_verbatim_type() {
1613        let yaml = format!(
1614            "{PREAMBLE}    Holder:\n      type: object\n      required: [v]\n      properties:\n        v:\n          type: string\n          x-rust-type: my_crate::Custom\n"
1615        );
1616        let out = emit_yaml(&yaml);
1617        assert!(out.contains("pub v: my_crate::Custom"), "{out}");
1618    }
1619
1620    #[test]
1621    fn optional_field_is_wrapped_and_skipped() {
1622        let yaml = format!(
1623            "{PREAMBLE}    Maybe:\n      type: object\n      properties:\n        note:\n          type: string\n"
1624        );
1625        let out = emit_yaml(&yaml);
1626        assert!(out.contains("skip_serializing_if = \"Option::is_none\""), "{out}");
1627        assert!(out.contains("pub note: Option<String>"), "{out}");
1628    }
1629}