oas3-gen 0.24.0

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

use oas3::spec::{Components, Discriminator, ObjectOrReference, ObjectSchema, Spec};

use crate::generator::schema_registry::{RefCollector, SchemaRegistry};

const SCHEMA_REF_PREFIX: &str = "#/components/schemas/";

fn create_test_spec_with_schemas(schemas: BTreeMap<String, ObjectOrReference<ObjectSchema>>) -> Spec {
  Spec {
    openapi: "3.0.0".to_string(),
    info: oas3::spec::Info {
      title: "Test".to_string(),
      summary: None,
      version: "1.0.0".to_string(),
      description: None,
      terms_of_service: None,
      contact: None,
      license: None,
      extensions: BTreeMap::default(),
    },
    servers: vec![],
    paths: Option::default(),
    webhooks: BTreeMap::default(),
    components: Some(Components {
      schemas,
      ..Default::default()
    }),
    security: vec![],
    tags: vec![],
    external_docs: None,
    extensions: BTreeMap::default(),
  }
}

fn make_simple_schema() -> ObjectSchema {
  ObjectSchema {
    schema_type: None,
    properties: BTreeMap::new(),
    ..Default::default()
  }
}

fn make_schema_with_ref(ref_name: &str) -> ObjectSchema {
  let mut properties = BTreeMap::new();
  properties.insert(
    "related".to_string(),
    ObjectOrReference::Ref {
      ref_path: format!("{SCHEMA_REF_PREFIX}{ref_name}"),
      summary: None,
      description: None,
    },
  );
  ObjectSchema {
    schema_type: None,
    properties,
    ..Default::default()
  }
}

fn make_ref(name: &str) -> ObjectOrReference<ObjectSchema> {
  ObjectOrReference::Ref {
    ref_path: format!("{SCHEMA_REF_PREFIX}{name}"),
    summary: None,
    description: None,
  }
}

#[test]
fn test_parse_ref() {
  let cases = [
    ("#/components/schemas/User", Some("User")),
    ("#/components/schemas/NestedSchema", Some("NestedSchema")),
    ("#/other/path", None),
    ("InvalidRef", None),
  ];
  for (input, expected) in cases {
    let result = SchemaRegistry::parse_ref(input);
    assert_eq!(result.as_deref(), expected, "failed for input {input:?}");
  }
}

#[test]
fn test_ref_collector() {
  let collector = RefCollector::new(None);

  let schema = make_schema_with_ref("User");
  let refs = collector.collect(&schema);
  assert_eq!(refs.len(), 1, "simple ref: expected 1 ref");
  assert!(refs.contains("User"), "simple ref: should contain User");

  let mut properties = BTreeMap::new();
  properties.insert("author".to_string(), make_ref("User"));
  properties.insert("category".to_string(), make_ref("Category"));
  let schema = ObjectSchema {
    schema_type: None,
    properties,
    ..Default::default()
  };
  let refs = collector.collect(&schema);
  assert_eq!(refs.len(), 2, "multiple refs: expected 2 refs");
  assert!(refs.contains("User"), "multiple refs: should contain User");
  assert!(refs.contains("Category"), "multiple refs: should contain Category");

  let schema = ObjectSchema {
    schema_type: None,
    one_of: vec![make_ref("User")],
    any_of: vec![make_ref("Post")],
    all_of: vec![make_ref("Comment")],
    ..Default::default()
  };
  let refs = collector.collect(&schema);
  assert_eq!(refs.len(), 3, "combinators: expected 3 refs");
  assert!(refs.contains("User"), "combinators: should contain User");
  assert!(refs.contains("Post"), "combinators: should contain Post");
  assert!(refs.contains("Comment"), "combinators: should contain Comment");
}

#[test]
fn test_schema_registry() {
  let mut schemas = BTreeMap::new();
  schemas.insert("User".to_string(), ObjectOrReference::Object(make_simple_schema()));
  schemas.insert("Post".to_string(), ObjectOrReference::Object(make_simple_schema()));

  let spec = create_test_spec_with_schemas(schemas);
  let registry = SchemaRegistry::from_spec(spec).registry;

  assert!(registry.get("User").is_some(), "should have User schema");
  assert!(registry.get("Post").is_some(), "should have Post schema");
  assert!(registry.get("NonExistent").is_none(), "should not have NonExistent");
  assert_eq!(registry.keys().len(), 2, "should have 2 schemas");

  let mut schemas = BTreeMap::new();
  schemas.insert("User".to_string(), ObjectOrReference::Object(make_simple_schema()));
  schemas.insert(
    "Post".to_string(),
    ObjectOrReference::Object(make_schema_with_ref("User")),
  );

  let spec = create_test_spec_with_schemas(schemas);
  let mut graph = SchemaRegistry::from_spec(spec).registry;
  graph.build_dependencies();

  assert_eq!(graph.keys().len(), 2, "build deps: should have 2 schemas");
  assert!(graph.get("User").is_some(), "build deps: should have User");
  assert!(graph.get("Post").is_some(), "build deps: should have Post");
}

#[test]
fn test_schema_graph_cycle_detection() {
  {
    let mut schemas = BTreeMap::new();
    schemas.insert("A".to_string(), ObjectOrReference::Object(make_simple_schema()));
    schemas.insert("B".to_string(), ObjectOrReference::Object(make_schema_with_ref("A")));
    let mut c_schema = make_simple_schema();
    c_schema.properties.insert("b".to_string(), make_ref("B"));
    schemas.insert("C".to_string(), ObjectOrReference::Object(c_schema));

    let spec = create_test_spec_with_schemas(schemas);
    let mut graph = SchemaRegistry::from_spec(spec).registry;
    graph.build_dependencies();
    let cycles = graph.detect_cycles();

    assert!(cycles.is_empty(), "linear deps: should have no cycles");
    assert!(!graph.is_cyclic("A"), "linear deps: A should not be cyclic");
    assert!(!graph.is_cyclic("B"), "linear deps: B should not be cyclic");
    assert!(!graph.is_cyclic("C"), "linear deps: C should not be cyclic");
  }

  {
    let mut a_schema = make_simple_schema();
    a_schema.properties.insert("b".to_string(), make_ref("B"));
    let mut b_schema = make_simple_schema();
    b_schema.properties.insert("a".to_string(), make_ref("A"));

    let mut schemas = BTreeMap::new();
    schemas.insert("A".to_string(), ObjectOrReference::Object(a_schema));
    schemas.insert("B".to_string(), ObjectOrReference::Object(b_schema));

    let spec = create_test_spec_with_schemas(schemas);
    let mut graph = SchemaRegistry::from_spec(spec).registry;
    graph.build_dependencies();
    let cycles = graph.detect_cycles();

    assert_eq!(cycles.len(), 1, "simple cycle: should detect 1 cycle");
    assert!(!cycles[0].is_empty(), "simple cycle: cycle should not be empty");
    assert!(graph.is_cyclic("A"), "simple cycle: A should be cyclic");
    assert!(graph.is_cyclic("B"), "simple cycle: B should be cyclic");
  }

  {
    let mut a_schema = make_simple_schema();
    a_schema.properties.insert("self_ref".to_string(), make_ref("A"));

    let mut schemas = BTreeMap::new();
    schemas.insert("A".to_string(), ObjectOrReference::Object(a_schema));

    let spec = create_test_spec_with_schemas(schemas);
    let mut graph = SchemaRegistry::from_spec(spec).registry;
    graph.build_dependencies();
    let cycles = graph.detect_cycles();

    assert_eq!(cycles.len(), 1, "self-ref: should detect 1 cycle");
    assert!(graph.is_cyclic("A"), "self-ref: A should be cyclic");
  }

  {
    let mut user_schema = make_simple_schema();
    user_schema.properties.insert("posts".to_string(), make_ref("Post"));
    let mut post_schema = make_simple_schema();
    post_schema.properties.insert("author".to_string(), make_ref("User"));

    let mut schemas = BTreeMap::new();
    schemas.insert("User".to_string(), ObjectOrReference::Object(user_schema));
    schemas.insert("Post".to_string(), ObjectOrReference::Object(post_schema));

    let spec = create_test_spec_with_schemas(schemas);
    let mut graph = SchemaRegistry::from_spec(spec).registry;
    graph.build_dependencies();
    let cycles = graph.detect_cycles();

    assert!(!cycles.is_empty(), "user-post cycle: should detect cycles");
    assert!(graph.is_cyclic("User"), "user-post cycle: User should be cyclic");
    assert!(graph.is_cyclic("Post"), "user-post cycle: Post should be cyclic");
  }
}

#[test]
fn test_schema_graph_integration() {
  let mut schemas = BTreeMap::new();
  schemas.insert("User".to_string(), ObjectOrReference::Object(make_simple_schema()));
  schemas.insert(
    "Post".to_string(),
    ObjectOrReference::Object(make_schema_with_ref("User")),
  );

  let spec = create_test_spec_with_schemas(schemas);
  let mut graph = SchemaRegistry::from_spec(spec).registry;

  assert!(graph.get("User").is_some(), "integration: should have User");
  assert!(graph.get("Post").is_some(), "integration: should have Post");
  assert_eq!(graph.keys().len(), 2, "integration: should have 2 schemas");

  graph.build_dependencies();
  let cycles = graph.detect_cycles();

  assert!(cycles.is_empty(), "integration: should have no cycles");
  assert!(!graph.is_cyclic("User"), "integration: User should not be cyclic");
}

#[test]
fn test_schema_registry_merges_all_of_properties_and_required() {
  let mut parent = make_simple_schema();
  parent.schema_type = Some(oas3::spec::SchemaTypeSet::Single(oas3::spec::SchemaType::Object));
  parent.required.push("id".to_string());
  parent.properties.insert(
    "id".to_string(),
    ObjectOrReference::Object(ObjectSchema {
      schema_type: Some(oas3::spec::SchemaTypeSet::Single(oas3::spec::SchemaType::Integer)),
      ..Default::default()
    }),
  );
  parent.additional_properties = Some(oas3::spec::Schema::Boolean(oas3::spec::BooleanSchema(true)));

  let mut child = make_simple_schema();
  child.schema_type = Some(oas3::spec::SchemaTypeSet::Single(oas3::spec::SchemaType::Object));
  child.required.push("name".to_string());
  child.properties.insert(
    "name".to_string(),
    ObjectOrReference::Object(ObjectSchema {
      schema_type: Some(oas3::spec::SchemaTypeSet::Single(oas3::spec::SchemaType::String)),
      ..Default::default()
    }),
  );
  child.all_of.push(make_ref("Parent"));

  let spec = create_test_spec_with_schemas(BTreeMap::from([
    ("Parent".to_string(), ObjectOrReference::Object(parent.clone())),
    ("Child".to_string(), ObjectOrReference::Object(child.clone())),
  ]));

  let mut graph = SchemaRegistry::from_spec(spec).registry;
  graph.build_dependencies();
  graph.detect_cycles();

  let merged = graph.merged("Child").expect("merged schema should exist for Child");

  assert!(merged.schema.properties.contains_key("id"));
  assert!(merged.schema.properties.contains_key("name"));
  assert!(merged.schema.required.contains(&"id".to_string()));
  assert!(merged.schema.required.contains(&"name".to_string()));
  assert!(merged.schema.additional_properties.is_some());
}

#[test]
fn test_schema_registry_merges_and_tracks_discriminator_parents() {
  let mut parent_schema = make_simple_schema();
  parent_schema.properties.insert(
    "kind".to_string(),
    ObjectOrReference::Object(ObjectSchema {
      schema_type: Some(oas3::spec::SchemaTypeSet::Single(oas3::spec::SchemaType::String)),
      ..Default::default()
    }),
  );
  parent_schema.discriminator = Some(Discriminator {
    property_name: "kind".to_string(),
    mapping: Some(BTreeMap::from([(
      "child".to_string(),
      format!("{SCHEMA_REF_PREFIX}Child"),
    )])),
  });

  let mut child_schema = make_simple_schema();
  child_schema.properties.insert(
    "child_prop".to_string(),
    ObjectOrReference::Object(ObjectSchema {
      schema_type: Some(oas3::spec::SchemaTypeSet::Single(oas3::spec::SchemaType::Integer)),
      ..Default::default()
    }),
  );
  child_schema.all_of.push(make_ref("Parent"));

  let spec = create_test_spec_with_schemas(BTreeMap::from([
    ("Parent".to_string(), ObjectOrReference::Object(parent_schema.clone())),
    ("Child".to_string(), ObjectOrReference::Object(child_schema.clone())),
  ]));

  let mut graph = SchemaRegistry::from_spec(spec).registry;
  graph.build_dependencies();
  graph.detect_cycles();

  let merged_child = graph.merged("Child").expect("merged schema should exist for Child");

  assert_eq!(merged_child.discriminator_parent.as_deref(), Some("Parent"));
  assert!(merged_child.schema.properties.contains_key("kind"));
  assert!(merged_child.schema.properties.contains_key("child_prop"));

  let discriminator = graph.parent("Child").expect("discriminator parent should be tracked");

  assert_eq!(discriminator.parent_name, "Parent");

  let effective = graph.resolved("Child").unwrap();
  assert_eq!(effective.properties.len(), merged_child.schema.properties.len());
}