oas3-gen 0.12.0

A rust type generator for OpenAPI v3.1.x specification.
use std::fmt;

use crate::generator::{
  analyzer::ErrorAnalyzer,
  ast::{OperationInfo, RustType},
  codegen::{self, Visibility},
  converter::{SchemaConverter, operations::OperationConverter},
  schema_graph::SchemaGraph,
};

const OAS3_GEN_VERSION: &str = env!("CARGO_PKG_VERSION");
const CLIPPY_ALLOWS: &[&str] = &[
  "clippy::doc_markdown",
  "clippy::large_enum_variant",
  "clippy::missing_panics_doc",
  "clippy::result_large_err",
];

#[derive(Debug)]
pub struct Orchestrator {
  spec: oas3::Spec,
  visibility: Visibility,
}

#[derive(Debug, Clone)]
pub struct CodeMetadata {
  pub title: String,
  pub version: String,
  pub description: Option<String>,
}

#[derive(Debug)]
pub struct GenerationStats {
  pub types_generated: usize,
  pub operations_converted: usize,
  pub cycles_detected: usize,
  pub cycle_details: Vec<Vec<String>>,
  pub warnings: Vec<GenerationWarning>,
}

#[derive(Debug)]
pub enum GenerationWarning {
  SchemaConversionFailed {
    schema_name: String,
    error: String,
  },
  OperationConversionFailed {
    method: String,
    path: String,
    error: String,
  },
  OperationSpecific {
    operation_id: String,
    message: String,
  },
}

impl fmt::Display for GenerationWarning {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    match self {
      Self::SchemaConversionFailed { schema_name, error } => {
        write!(f, "Failed to convert schema '{schema_name}': {error}")
      }
      Self::OperationConversionFailed { method, path, error } => {
        write!(f, "Failed to convert operation '{method} {path}': {error}")
      }
      Self::OperationSpecific { operation_id, message } => write!(f, "[{operation_id}] {message}"),
    }
  }
}

impl Orchestrator {
  #[must_use]
  pub const fn new(spec: oas3::Spec, visibility: Visibility) -> Self {
    Self { spec, visibility }
  }

  pub fn metadata(&self) -> CodeMetadata {
    CodeMetadata {
      title: self.spec.info.title.clone(),
      version: self.spec.info.version.clone(),
      description: self.spec.info.description.clone(),
    }
  }

  pub fn generate_with_header(&self, source_path: &str) -> anyhow::Result<(String, GenerationStats)> {
    let (code_tokens, stats) = self.run_generation_pipeline()?;
    let formatted_code = Self::format_code(&code_tokens)?;
    let header = self.generate_header(source_path);
    let final_code = format!("{header}\n\n{formatted_code}\n\nfn main() {{}}\n");
    Ok((final_code, stats))
  }

  fn run_generation_pipeline(&self) -> anyhow::Result<(proc_macro2::TokenStream, GenerationStats)> {
    let mut graph = SchemaGraph::new(self.spec.clone())?;
    graph.build_dependencies();
    let cycle_details = graph.detect_cycles();

    let schema_converter = SchemaConverter::new(&graph);
    let (schema_rust_types, schema_warnings) = Self::convert_all_schemas(&graph, &schema_converter);

    let (op_rust_types, operations_info, op_warnings) = Self::convert_all_operations(&graph, &schema_converter);

    let mut rust_types = schema_rust_types;
    rust_types.extend(op_rust_types);
    let mut warnings = schema_warnings;
    warnings.extend(op_warnings);

    let code = self.generate_code_from_artifacts(&graph, &rust_types, &operations_info);

    let stats = GenerationStats {
      types_generated: rust_types.len(),
      operations_converted: operations_info.len(),
      cycles_detected: cycle_details.len(),
      cycle_details,
      warnings,
    };

    Ok((code, stats))
  }

  fn convert_all_schemas(
    graph: &SchemaGraph,
    schema_converter: &SchemaConverter,
  ) -> (Vec<RustType>, Vec<GenerationWarning>) {
    let mut rust_types = Vec::new();
    let mut warnings = Vec::new();

    for schema_name in graph.schema_names() {
      if let Some(schema) = graph.get_schema(schema_name) {
        match schema_converter.convert_schema(schema_name, schema) {
          Ok(types) => rust_types.extend(types),
          Err(e) => warnings.push(GenerationWarning::SchemaConversionFailed {
            schema_name: schema_name.clone(),
            error: e.to_string(),
          }),
        }
      }
    }
    (rust_types, warnings)
  }

  fn convert_all_operations(
    graph: &SchemaGraph,
    schema_converter: &SchemaConverter,
  ) -> (Vec<RustType>, Vec<OperationInfo>, Vec<GenerationWarning>) {
    let mut rust_types = Vec::new();
    let mut operations_info = Vec::new();
    let mut warnings = Vec::new();

    let operation_converter = OperationConverter::new(schema_converter, graph.spec());

    if let Some(ref paths) = graph.spec().paths {
      for (path, path_item) in paths {
        for (method, operation) in path_item.methods() {
          let method_str = method.as_str();
          let operation_id = operation.operation_id.as_deref().unwrap_or("unknown");

          match operation_converter.convert(operation_id, method_str, path, operation) {
            Ok((types, op_info)) => {
              warnings.extend(op_info.warnings.iter().map(|w| GenerationWarning::OperationSpecific {
                operation_id: op_info.operation_id.clone(),
                message: w.clone(),
              }));
              rust_types.extend(types);
              operations_info.push(op_info);
            }
            Err(e) => {
              warnings.push(GenerationWarning::OperationConversionFailed {
                method: method_str.to_string(),
                path: path.clone(),
                error: e.to_string(),
              });
            }
          }
        }
      }
    }
    (rust_types, operations_info, warnings)
  }

  fn generate_code_from_artifacts(
    &self,
    graph: &SchemaGraph,
    rust_types: &[RustType],
    operations_info: &[OperationInfo],
  ) -> proc_macro2::TokenStream {
    let type_usage = codegen::build_type_usage_map(operations_info);
    let error_schemas = ErrorAnalyzer::build_error_schema_set(operations_info, rust_types);

    codegen::generate(
      rust_types,
      &type_usage,
      &graph.all_headers(),
      &error_schemas,
      self.visibility,
    )
  }

  fn format_code(code: &proc_macro2::TokenStream) -> anyhow::Result<String> {
    let syntax_tree = syn::parse2(code.clone())?;
    Ok(prettyplease::unparse(&syntax_tree))
  }

  fn generate_header(&self, source_path: &str) -> String {
    let metadata = self.metadata();
    let description = metadata.description.as_ref().map_or_else(
      || String::from("No description provided"),
      |d| d.replace('\n', "\n//! "),
    );

    let clippy_directives = CLIPPY_ALLOWS
      .iter()
      .map(|allow| format!("#![allow({allow})]"))
      .collect::<Vec<_>>()
      .join("\n");

    format!(
      r"{clippy_directives}
//!
//! AUTO-GENERATED CODE - DO NOT EDIT!
//!
//! {}
//! Source: {}
//! Version: {}
//! Generated by `oas3-gen v{}`
//!
//! {}",
      metadata.title, source_path, metadata.version, OAS3_GEN_VERSION, description
    )
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  fn create_empty_spec(title: &str, version: &str, description: Option<&str>) -> oas3::Spec {
    let spec_json = format!(
      r#"{{
        "openapi": "3.1.0",
        "info": {{
          "title": "{}",
          "version": "{}",
          "description": "{}"
        }},
        "paths": {{}}
      }}"#,
      title,
      version,
      description.unwrap_or("")
    );
    oas3::from_json(&spec_json).unwrap()
  }

  #[test]
  fn test_orchestrator_new_and_metadata() {
    let spec = create_empty_spec("Empty API", "1.0.0", Some("An empty spec."));
    let orchestrator = Orchestrator::new(spec, Visibility::default());

    let metadata = orchestrator.metadata();
    assert_eq!(metadata.title, "Empty API");
    assert_eq!(metadata.version, "1.0.0");
    assert_eq!(metadata.description.as_deref(), Some("An empty spec."));
  }

  #[test]
  fn test_orchestrator_generate_on_empty_spec() {
    let spec = create_empty_spec("Empty API", "1.0.0", None);
    let orchestrator = Orchestrator::new(spec, Visibility::default());

    let result = orchestrator.generate();
    assert!(result.is_ok());

    let (code, stats) = result.unwrap();
    assert!(!code.is_empty());
    assert!(code.contains("use serde::{Deserialize, Serialize}"));
    assert_eq!(stats.types_generated, 0);
    assert_eq!(stats.operations_converted, 0);
    assert_eq!(stats.cycles_detected, 0);
    assert_eq!(stats.warnings.len(), 0);
  }

  #[test]
  fn test_orchestrator_generate_with_header() {
    let spec = create_empty_spec("Test API", "2.0.0", Some("A test API."));
    let orchestrator = Orchestrator::new(spec, Visibility::default());

    let result = orchestrator.generate_with_header("/path/to/spec.json");
    assert!(result.is_ok());

    let (code, _) = result.unwrap();
    assert!(code.contains("AUTO-GENERATED CODE - DO NOT EDIT!"));
    assert!(code.contains("//! Test API"));
    assert!(code.contains("//! Source: /path/to/spec.json"));
    assert!(code.contains("//! Version: 2.0.0"));
    assert!(code.contains("//! A test API."));
    assert!(code.contains("fn main()"));
    assert!(code.contains("#![allow(clippy::doc_markdown)]"));
  }

  #[test]
  fn test_header_generation_with_multiline_description() {
    let spec_json = r#"{
      "openapi": "3.1.0",
      "info": {
        "title": "Test API",
        "version": "1.0.0",
        "description": "Multi\nline\ndescription."
      },
      "paths": {}
    }"#;
    let spec: oas3::Spec = oas3::from_json(spec_json).unwrap();
    let orchestrator = Orchestrator::new(spec, Visibility::default());

    let header = orchestrator.generate_header("test.yaml");
    assert!(header.contains("Multi\n//! line\n//! description."));
  }
}