oas3-gen 0.26.0

A rust type generator for OpenAPI v3.1.x specification.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
pub(crate) mod cache;
pub(crate) mod common;
pub(crate) mod discriminator;
pub(crate) mod fields;
pub(crate) mod hashing;
pub(crate) mod inline_resolver;
pub(crate) mod methods;
pub(crate) mod operations;
pub(crate) mod parameters;
pub(crate) mod relaxed_enum;
pub(crate) mod requests;
pub(crate) mod responses;
pub(crate) mod structs;
pub(crate) mod type_resolver;
pub(crate) mod type_usage_recorder;
pub(crate) mod union_types;
pub(crate) mod unions;
pub(crate) mod value_enums;
pub(crate) mod variants;

use std::{
  cell::{Ref, RefCell, RefMut},
  collections::HashMap,
  rc::Rc,
  sync::Arc,
};

use anyhow::Result;
pub(crate) use common::ConversionOutput;
use oas3::spec::ObjectSchema;
pub(crate) use operations::{OperationsProcessor, build_server_trait};
pub(crate) use type_resolver::TypeResolver;
pub(crate) use type_usage_recorder::SerdeUsageRecorder;

use crate::{
  generator::{
    ast::{Documentation, EnumToken, RustType, TypeAliasDef, TypeAliasToken, TypeRef},
    converter::{
      cache::SharedSchemaCache,
      discriminator::DiscriminatorConverter,
      structs::StructConverter,
      unions::{EnumConverter, UnionConverter},
    },
    metrics::{GenerationStats, GenerationWarning},
    naming::{constants::DISCRIMINATED_BASE_SUFFIX, identifiers::to_rust_type_name},
    schema_registry::SchemaRegistry,
  },
  utils::{SchemaExt, SchemaSet},
};

/// Policy for handling enum variant name collisions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EnumCasePolicy {
  /// Append index suffix to colliding variants (e.g., `Value`, `Value1`).
  Preserve,
  /// Merge colliding variants and add serde aliases.
  #[default]
  Deduplicate,
}

/// Policy for generating enum helper constructors.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EnumHelperPolicy {
  /// Generate helper methods for creating enum variants.
  #[default]
  Generate,
  /// Disable helper method generation.
  Disable,
}

/// Policy for enum deserialization case sensitivity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EnumDeserializePolicy {
  /// Use standard case-sensitive deserialization.
  #[default]
  CaseSensitive,
  /// Generate custom case-insensitive deserializer.
  CaseInsensitive,
}

/// Policy for OData-specific schema support.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ODataPolicy {
  /// Disable OData-specific handling.
  #[default]
  Disabled,
  /// Enable OData support (makes `@odata.*` fields optional).
  Enabled,
}

/// Target for code generation (client vs server).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GenerationTarget {
  /// Generate HTTP client code.
  #[default]
  Client,
  /// Generate HTTP server code (axum).
  Server,
}

/// Policy for schema inclusion scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SchemaScope {
  /// Generate only schemas referenced by operations.
  #[default]
  ReferencedOnly,
  /// Generate all schemas defined in the spec.
  All,
}

/// Policy for header constant inclusion scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HeaderScope {
  /// Emit header constants only for headers used in operations.
  #[default]
  ReferencedOnly,
  /// Emit header constants for all header parameters in components.
  All,
}

/// Policy for the runtime collection types emitted in generated code.
///
/// Map types (`additionalProperties` and standalone object maps) and arrays
/// marked `uniqueItems: true` can either preserve JSON key/element order at
/// runtime via `indexmap`, or use the standard library equivalents.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CollectionTypePolicy {
  /// Emit `indexmap::IndexMap<String, T>` for map types and
  /// `indexmap::IndexSet<T>` for `uniqueItems` arrays, so the order of keys
  /// or elements received over the wire is retained when the generated type
  /// is re-serialized.
  #[default]
  Ordered,
  /// Emit `std::collections::HashMap<String, T>` for map types and
  /// `Vec<T>` for arrays (ignoring `uniqueItems` at the type level), matching
  /// the pre-0.26 generator output.
  Hashed,
}

/// Configuration for code generation.
///
/// Uses typed enums instead of booleans to make intent explicit at call sites
/// and prevent invalid combinations.
#[derive(Debug, Clone, Default, bon::Builder)]
pub struct CodegenConfig {
  #[builder(default)]
  pub enum_case: EnumCasePolicy,
  #[builder(default)]
  pub enum_helpers: EnumHelperPolicy,
  #[builder(default)]
  pub enum_deserialize: EnumDeserializePolicy,
  #[builder(default)]
  pub odata: ODataPolicy,
  #[builder(default)]
  pub target: GenerationTarget,
  #[builder(default)]
  pub schema_scope: SchemaScope,
  #[builder(default)]
  pub header_scope: HeaderScope,
  #[builder(default)]
  pub collection_types: CollectionTypePolicy,
  #[builder(default)]
  pub enable_builders: bool,
  #[builder(default)]
  pub customizations: HashMap<String, String>,
}

impl CodegenConfig {
  /// Returns `true` if enum variant name collisions should be resolved by appending
  /// numeric suffixes (e.g., `Value`, `Value1`) rather than merging with serde aliases.
  #[must_use]
  pub fn preserve_case_variants(&self) -> bool {
    self.enum_case == EnumCasePolicy::Preserve
  }

  /// Returns `true` if enums should generate custom deserializers that accept
  /// any casing of variant names (e.g., "FOO", "foo", "Foo" all deserialize to `Foo`).
  #[must_use]
  pub fn case_insensitive_enums(&self) -> bool {
    self.enum_deserialize == EnumDeserializePolicy::CaseInsensitive
  }

  /// Returns `true` if enum helper constructor methods should be omitted from generated code.
  #[must_use]
  pub fn no_helpers(&self) -> bool {
    self.enum_helpers == EnumHelperPolicy::Disable
  }

  /// Returns `true` if OData-specific handling is enabled, which makes `@odata.*` fields
  /// optional even when the schema marks them as required.
  #[must_use]
  pub fn odata_support(&self) -> bool {
    self.odata == ODataPolicy::Enabled
  }

  /// Returns `true` if all schemas should be generated, not just those referenced by operations.
  #[must_use]
  pub fn include_all_schemas(&self) -> bool {
    self.schema_scope == SchemaScope::All
  }

  /// Returns `true` if header constants should be emitted for all component-level headers,
  /// not just those used in operations.
  #[must_use]
  pub fn include_all_headers(&self) -> bool {
    self.header_scope == HeaderScope::All
  }

  /// Returns `true` if bon builder derives should be generated for schema structs
  /// and builder methods for request structs.
  #[must_use]
  pub fn enable_builders(&self) -> bool {
    self.enable_builders
  }

  /// Returns `true` when generated map and unique-array fields should emit
  /// `indexmap` types that preserve JSON key/element order at runtime.
  #[must_use]
  pub fn ordered_collections(&self) -> bool {
    self.collection_types == CollectionTypePolicy::Ordered
  }

  /// Returns the fully qualified Rust path used for map-like fields
  /// (`additionalProperties` and standalone object maps).
  #[must_use]
  pub fn map_type_path(&self) -> &'static str {
    match self.collection_types {
      CollectionTypePolicy::Ordered => "indexmap::IndexMap",
      CollectionTypePolicy::Hashed => "std::collections::HashMap",
    }
  }
}

#[derive(Debug, Clone)]
pub(crate) struct ConverterContext {
  pub(crate) graph: Arc<SchemaRegistry>,
  pub(crate) config: CodegenConfig,
  pub(crate) cache: RefCell<SharedSchemaCache>,
  pub(crate) type_usage: RefCell<SerdeUsageRecorder>,
  pub(crate) reachable_schemas: Option<Arc<SchemaSet>>,
}

impl ConverterContext {
  /// Creates a new converter context with the given schema registry, configuration,
  /// type cache, and optional schema filter.
  ///
  /// The `reachable_schemas` parameter, when `Some`, restricts conversion to only
  /// schemas reachable from the filtered operations, enabling dead-code elimination.
  pub(crate) fn new(
    graph: Arc<SchemaRegistry>,
    config: CodegenConfig,
    cache: SharedSchemaCache,
    reachable_schemas: Option<Arc<SchemaSet>>,
  ) -> Self {
    Self {
      graph,
      config,
      cache: RefCell::new(cache),
      type_usage: RefCell::new(SerdeUsageRecorder::new()),
      reachable_schemas,
    }
  }

  /// Returns a reference to the schema registry containing all resolved schemas
  /// and their dependency relationships.
  pub(crate) fn graph(&self) -> &SchemaRegistry {
    &self.graph
  }

  /// Returns a reference to the code generation configuration.
  pub(crate) fn config(&self) -> &CodegenConfig {
    &self.config
  }

  /// Extracts the accumulated type usage data, replacing it with an empty recorder.
  ///
  /// Call this after conversion completes to retrieve which types are used in
  /// requests vs responses for serde mode optimization.
  pub(crate) fn take_type_usage(&self) -> SerdeUsageRecorder {
    self.type_usage.take()
  }

  /// Records that the named type appears in an HTTP request context (request body or parameter).
  ///
  /// Types used only in requests may derive `Serialize` without `Deserialize`.
  pub(crate) fn mark_request(&self, type_name: impl Into<EnumToken>) {
    self.type_usage.borrow_mut().mark_request(type_name);
  }

  /// Records that the named type appears in an HTTP response context.
  ///
  /// Types used only in responses may derive `Deserialize` without `Serialize`.
  pub(crate) fn mark_response(&self, type_name: impl Into<EnumToken>) {
    self.type_usage.borrow_mut().mark_response(type_name);
  }

  /// Records that multiple types appear in HTTP request contexts.
  ///
  /// More efficient than calling [`mark_request`](Self::mark_request) in a loop.
  pub(crate) fn mark_request_iter<I, T>(&self, types: I)
  where
    I: IntoIterator<Item = T>,
    T: Into<super::ast::EnumToken>,
  {
    self.type_usage.borrow_mut().mark_request_iter(types);
  }

  /// Extracts custom type names from a [`TypeRef`] and records them as response types.
  ///
  /// Handles nested types like `Box<CustomType>` by extracting the inner type name.
  pub(crate) fn mark_response_type_ref(&self, type_ref: &TypeRef) {
    self.type_usage.borrow_mut().mark_response_type_ref(type_ref);
  }

  /// Combines type usage data from another recorder into this context's recorder.
  ///
  /// Used when sub-converters track usage independently and results must be aggregated.
  pub(crate) fn merge_usage(&self, other: SerdeUsageRecorder) {
    self.type_usage.borrow_mut().merge(other);
  }

  pub(crate) fn cache(&self) -> Ref<'_, SharedSchemaCache> {
    self.cache.borrow()
  }

  pub(crate) fn cache_mut(&self) -> RefMut<'_, SharedSchemaCache> {
    self.cache.borrow_mut()
  }
}

/// Main entry point for converting OpenAPI schemas into Rust AST.
///
/// Orchestrates the one-way conversion pipeline from OpenAPI schemas to Rust types.
/// Uses `TypeResolver` for read-only type mapping and navigation, while managing
/// the conversion flow through specialized converters (`StructConverter`, `EnumConverter`, etc.).
#[derive(Debug, Clone)]
pub(crate) struct SchemaConverter {
  context: Rc<ConverterContext>,
  type_resolver: TypeResolver,
  struct_converter: StructConverter,
  enum_converter: EnumConverter,
  union_converter: UnionConverter,
  discriminator_converter: DiscriminatorConverter,
}

impl SchemaConverter {
  /// Creates a new schema converter with all specialized sub-converters.
  ///
  /// Each sub-converter receives a clone of the shared context `Rc`, enabling
  /// coordinated type caching and usage tracking across conversion operations.
  pub(crate) fn new(context: &Rc<ConverterContext>) -> Self {
    Self {
      type_resolver: TypeResolver::new(context.clone()),
      struct_converter: StructConverter::new(context.clone()),
      enum_converter: EnumConverter::new(context.clone()),
      union_converter: UnionConverter::new(context.clone()),
      discriminator_converter: DiscriminatorConverter::new(context.clone()),
      context: context.clone(),
    }
  }

  /// Returns a reference to the shared converter context.
  pub(crate) fn context(&self) -> &Rc<ConverterContext> {
    &self.context
  }

  /// Returns `true` if the schema registry contains a schema with the given name.
  pub(crate) fn contains(&self, name: &str) -> bool {
    self.context.cache().contains_schema_name(name)
  }

  /// Converts a named OpenAPI schema into one or more Rust type definitions.
  ///
  /// Routes the schema to the appropriate specialized converter based on its structure:
  /// - `allOf` schemas become merged structs
  /// - `oneOf`/`anyOf` schemas become tagged or untagged enums
  /// - Schemas with `enum` values become string enums
  /// - Schemas with `properties` become structs
  /// - Array schemas become `Vec<T>` type aliases
  /// - Primitive schemas become type aliases
  ///
  /// Returns multiple types when inline definitions are extracted (e.g., nested
  /// anonymous objects become separate struct definitions).
  pub(crate) fn convert_schema(&self, name: &str, schema: &ObjectSchema) -> Result<Vec<RustType>> {
    if schema.has_intersection() {
      return self.struct_converter.convert_all_of_schema(name);
    }

    if schema.union_variants_with_kind().is_some() {
      if schema.discriminator.is_none() && self.type_resolver.is_wrapper_union(schema)? {
        return self.convert_nullable_enum(name, schema);
      }

      if let Some(flattened) = self.type_resolver.try_flatten_nested_union(schema)? {
        return self
          .union_converter
          .convert_union(name, &flattened.into())
          .map(ConversionOutput::into_vec);
      }

      return self
        .union_converter
        .convert_union(name, schema)
        .map(ConversionOutput::into_vec);
    }

    if !schema.enum_values.is_empty() {
      return Ok(vec![self.enum_converter.convert_value_enum(name, schema)]);
    }

    if !schema.properties.is_empty() || schema.additional_properties.is_some() {
      let result = self.struct_converter.convert_struct(name, schema, None)?;
      return self.finalize_struct_types(name, schema, result.result, result.inline_types);
    }

    if let Some(output) = self.try_array_alias(name, schema)? {
      let alias = RustType::TypeAlias(TypeAliasDef {
        name: TypeAliasToken::from_raw(name),
        docs: Documentation::from_optional(schema.description.as_ref()),
        target: output.result,
      });
      let mut result = vec![alias];
      result.extend(output.inline_types);
      return Ok(result);
    }

    let type_ref = self.type_resolver.resolve_type(schema)?;
    Ok(vec![RustType::TypeAlias(TypeAliasDef {
      name: TypeAliasToken::from_raw(name),
      docs: Documentation::from_optional(schema.description.as_ref()),
      target: type_ref,
    })])
  }

  /// Builds a discriminated union enum for a base schema that defines discriminator mappings.
  ///
  /// The `fallback_type` specifies the struct type to use for the catch-all variant
  /// when the discriminator value doesn't match any known mapping.
  fn discriminated_enum(&self, name: &str, schema: &ObjectSchema, fallback_type: &str) -> Result<RustType> {
    self
      .discriminator_converter
      .build_base_discriminated_enum(name, schema, fallback_type)
  }

  /// Assembles the final type collection for a struct conversion, optionally prepending
  /// a discriminated enum wrapper.
  ///
  /// When the schema defines a discriminator with mappings, this creates a tagged union
  /// enum that wraps the base struct and its subtypes, enabling polymorphic deserialization.
  pub(crate) fn finalize_struct_types(
    &self,
    name: &str,
    schema: &ObjectSchema,
    main_type: RustType,
    inline_types: Vec<RustType>,
  ) -> Result<Vec<RustType>> {
    let discriminated_enum = schema
      .is_discriminated_base_type()
      .then(|| {
        let base_struct_name = match &main_type {
          RustType::Struct(def) => def.name.as_str().to_string(),
          _ => format!("{}{DISCRIMINATED_BASE_SUFFIX}", to_rust_type_name(name)),
        };
        self.discriminated_enum(name, schema, &base_struct_name)
      })
      .transpose()?;

    Ok(
      discriminated_enum
        .into_iter()
        .chain(std::iter::once(main_type))
        .chain(inline_types)
        .collect(),
    )
  }

  /// Converts a nullable enum schema to just the enum type.
  ///
  /// For nullable enums (`anyOf: [{ enum: [...] }, null]`), generates the enum
  /// directly with the schema name. The nullability is handled at the usage site
  /// (struct fields get `Option<EnumType>`).
  fn convert_nullable_enum(&self, name: &str, schema: &ObjectSchema) -> Result<Vec<RustType>> {
    let spec = self.context.graph().spec();
    let Some(non_null_variant) = schema.find_non_null_variant(spec) else {
      return Ok(vec![]);
    };

    let resolved = self.type_resolver.resolve(non_null_variant)?;
    if resolved.has_enum_values() && resolved.enum_values.len() > 1 {
      return Ok(vec![self.enum_converter.convert_value_enum(name, &resolved)]);
    }

    Ok(vec![])
  }

  /// Attempts to create a `Vec<T>` type reference for an array schema with inline items.
  ///
  /// Returns `None` if the schema is not an array type or has no inline item definition.
  /// Returns `Some` with the `Vec` type reference and any inline types extracted from
  /// the array items (e.g., anonymous structs or enums).
  fn try_array_alias(&self, name: &str, schema: &ObjectSchema) -> Result<Option<ConversionOutput<TypeRef>>> {
    if !schema.is_array() && !schema.is_nullable_array() {
      return Ok(None);
    }

    if let Some(output) = self.type_resolver.try_inline_array(name, name, schema)? {
      let type_ref = if schema.is_nullable_array() {
        output.result.with_option()
      } else {
        output.result
      };
      return Ok(Some(ConversionOutput::with_inline_types(type_ref, output.inline_types)));
    }

    Ok(None)
  }

  /// Converts all schemas from the OpenAPI spec to Rust types.
  ///
  /// Processes schemas in two phases: first registers all top-level schemas to enable
  /// deduplication, then converts each to Rust types in specification order.
  pub(crate) fn convert_all_schemas(
    &self,
    operation_reachable: Option<&SchemaSet>,
    stats: &mut GenerationStats,
  ) -> Vec<RustType> {
    let schemas = self.context.graph().schemas();

    let filtered = schemas
      .iter()
      .filter(|(name, _)| operation_reachable.is_none_or(|filter| filter.contains(name.as_str())))
      .collect::<Vec<_>>();

    let ordered = filtered;

    {
      let mut cache = self.context().cache_mut();
      for (name, schema) in &ordered {
        let _ = cache.register_top_level_schema(schema, name);
      }
    }

    ordered.into_iter().fold(vec![], |mut acc, (name, schema)| {
      match self.convert_schema(name, schema) {
        Ok(types) => acc.extend(types),
        Err(e) => stats.record_warning(GenerationWarning::SchemaConversionFailed {
          schema_name: name.clone(),
          error: e.to_string(),
        }),
      }
      acc
    })
  }
}

#[cfg(test)]
mod tests;