oas3-gen 0.23.6

A rust type generator for OpenAPI v3.1.x specification.
use std::{
  collections::{BTreeSet, HashMap},
  rc::Rc,
};

use oas3::spec::ObjectSchema;
use regex::Regex;

use super::{SchemaExt, discriminator::DiscriminatorInfo};
use crate::generator::{
  ast::{
    Documentation, FieldDef, FieldNameToken, RustPrimitive, SerdeAsFieldAttr, SerdeAttribute, TypeRef,
    ValidationAttribute,
  },
  converter::ConverterContext,
};

#[derive(Clone, Debug)]
pub(crate) struct FieldConverter {
  odata_support: bool,
  customizations: HashMap<String, String>,
}

impl FieldConverter {
  pub(crate) fn new(context: &Rc<ConverterContext>) -> Self {
    let config = context.config();
    Self {
      odata_support: config.odata_support(),
      customizations: config.customizations.clone(),
    }
  }

  pub(crate) fn convert_field(
    &self,
    prop_name: &str,
    parent_schema: &ObjectSchema,
    prop_schema: &ObjectSchema,
    resolved_type: TypeRef,
    is_required: bool,
    discriminator_mapping: Option<&(String, String)>,
  ) -> FieldDef {
    let discriminator_info = DiscriminatorInfo::new(prop_name, parent_schema, prop_schema, discriminator_mapping);

    let should_be_optional = self.is_field_optional(
      prop_name,
      parent_schema,
      prop_schema,
      discriminator_info.as_ref(),
      is_required,
    );
    let final_type = if should_be_optional && !resolved_type.nullable {
      resolved_type.with_option()
    } else {
      resolved_type
    };

    let docs = Documentation::from_optional(prop_schema.description.as_ref());
    let validation_attrs = Self::extract_all_validation(prop_name, is_required, prop_schema, &final_type);
    let default_value = Self::extract_default_value(prop_schema);

    let rust_field_name = FieldNameToken::from_raw(prop_name);
    let serde_attrs = if rust_field_name == prop_name {
      BTreeSet::new()
    } else {
      BTreeSet::from([SerdeAttribute::Rename(prop_name.to_string())])
    };

    let serde_as_attr = self.get_customization_for_type(&final_type);

    let field = FieldDef::builder()
      .deprecated(prop_schema.deprecated.unwrap_or(false))
      .docs(docs)
      .maybe_default_value(default_value)
      .maybe_multiple_of(prop_schema.multiple_of.clone())
      .maybe_serde_as_attr(serde_as_attr)
      .name(FieldNameToken::from_raw(prop_name))
      .rust_type(final_type)
      .serde_attrs(serde_attrs)
      .validation_attrs(validation_attrs)
      .build();

    match discriminator_info.as_ref().filter(|d| d.should_hide()) {
      Some(info) => field.with_discriminator_behavior(info.value.as_deref(), info.is_base),
      None => field,
    }
  }

  fn get_customization_for_type(&self, type_ref: &TypeRef) -> Option<SerdeAsFieldAttr> {
    let key = Self::primitive_to_key(&type_ref.base_type)?;
    let custom_type = self.customizations.get(&key)?;
    Some(SerdeAsFieldAttr::CustomOverride {
      custom_type: custom_type.clone(),
      optional: type_ref.nullable,
      is_array: type_ref.is_array,
    })
  }

  fn primitive_to_key(primitive: &RustPrimitive) -> Option<String> {
    match primitive {
      RustPrimitive::DateTime => Some("date_time".to_string()),
      RustPrimitive::Date => Some("date".to_string()),
      RustPrimitive::Time => Some("time".to_string()),
      RustPrimitive::Duration => Some("duration".to_string()),
      RustPrimitive::Uuid => Some("uuid".to_string()),
      RustPrimitive::Custom(name) => Some(name.to_string()),
      _ => None,
    }
  }

  pub(crate) fn extract_parameter_metadata(
    prop_name: &str,
    is_required: bool,
    schema: &ObjectSchema,
    type_ref: &TypeRef,
  ) -> (Vec<ValidationAttribute>, Option<serde_json::Value>) {
    let validation_attrs = Self::extract_all_validation(prop_name, is_required, schema, type_ref);
    let default_value = Self::extract_default_value(schema);
    (validation_attrs, default_value)
  }

  pub(crate) fn extract_default_value(schema: &ObjectSchema) -> Option<serde_json::Value> {
    schema
      .default
      .clone()
      .or_else(|| schema.const_value.clone())
      .or_else(|| {
        if schema.enum_values.len() == 1 {
          schema.enum_values.first().cloned()
        } else {
          None
        }
      })
  }

  pub(crate) fn extract_all_validation(
    prop_name: &str,
    is_required: bool,
    schema: &ObjectSchema,
    type_ref: &TypeRef,
  ) -> Vec<ValidationAttribute> {
    let mut attrs = Vec::with_capacity(3);

    if let Some(ref format) = schema.format {
      match format.as_str() {
        "email" => attrs.push(ValidationAttribute::Email),
        "uri" | "url" => attrs.push(ValidationAttribute::Url),
        _ => {}
      }
    }

    if schema.is_numeric() {
      if let Some(range_attr) = Self::build_range_validation_attr(schema, type_ref) {
        attrs.push(range_attr);
      }
      return attrs;
    }

    if schema.is_string() && schema.enum_values.is_empty() {
      let has_non_string_format = schema.format.as_ref().is_some_and(|f| Self::is_non_string_format(f));

      if !has_non_string_format {
        if let Some(length_attr) = Self::build_length_attribute(schema.min_length, schema.max_length, is_required) {
          attrs.push(length_attr);
        }

        if let Some(pattern) = schema.pattern.as_ref() {
          if Regex::new(pattern).is_ok() {
            if let Some(regex) = Self::filter_regex_validation(Some(pattern.clone()), type_ref) {
              attrs.push(ValidationAttribute::Regex(regex));
            }
          } else {
            eprintln!("Warning: Invalid regex pattern '{pattern}' for property '{prop_name}'");
          }
        }
      }
      return attrs;
    }

    if schema.is_array()
      && let Some(length_attr) = Self::build_length_attribute(schema.min_items, schema.max_items, false)
    {
      attrs.push(length_attr);
    }

    attrs
  }

  fn filter_regex_validation(regex: Option<String>, type_ref: &TypeRef) -> Option<String> {
    match &type_ref.base_type {
      RustPrimitive::DateTime | RustPrimitive::Date | RustPrimitive::Time | RustPrimitive::Uuid => None,
      _ => regex,
    }
  }

  fn is_non_string_format(format: &str) -> bool {
    matches!(
      format,
      "date" | "date-time" | "duration" | "time" | "binary" | "byte" | "uuid"
    )
  }

  fn build_range_validation_attr(schema: &ObjectSchema, type_ref: &TypeRef) -> Option<ValidationAttribute> {
    let exclusive_min = schema.exclusive_minimum.clone();
    let exclusive_max = schema.exclusive_maximum.clone();
    let min = schema.minimum.clone();
    let max = schema.maximum.clone();

    if exclusive_min.is_none() && exclusive_max.is_none() && min.is_none() && max.is_none() {
      return None;
    }

    Some(ValidationAttribute::Range {
      primitive: type_ref.base_type.clone(),
      min,
      max,
      exclusive_min,
      exclusive_max,
    })
  }

  fn build_length_attribute(
    min: Option<u64>,
    max: Option<u64>,
    is_required_non_empty: bool,
  ) -> Option<ValidationAttribute> {
    match (min, max) {
      (Some(min), Some(max)) => Some(ValidationAttribute::Length {
        min: Some(min),
        max: Some(max),
      }),
      (Some(min), None) => Some(ValidationAttribute::Length {
        min: Some(min),
        max: None,
      }),
      (None, Some(max)) => Some(ValidationAttribute::Length {
        min: None,
        max: Some(max),
      }),
      (None, None) if is_required_non_empty => Some(ValidationAttribute::Length {
        min: Some(1),
        max: None,
      }),
      _ => None,
    }
  }

  fn is_field_optional(
    &self,
    prop_name: &str,
    parent_schema: &ObjectSchema,
    prop_schema: &ObjectSchema,
    discriminator_info: Option<&DiscriminatorInfo>,
    is_required: bool,
  ) -> bool {
    let has_default = prop_schema.default.is_some();
    let is_discriminator_field = discriminator_info.is_some();
    let discriminator_has_enum = discriminator_info.is_some_and(|i| i.has_enum);

    if !is_required || has_default {
      return true;
    }
    if is_discriminator_field && !discriminator_has_enum {
      return true;
    }
    if self.odata_support
      && prop_name.starts_with("@odata.")
      && parent_schema.discriminator.is_none()
      && parent_schema.all_of.is_empty()
    {
      return true;
    }
    false
  }
}