kayto 0.1.10

Fast OpenAPI parser that turns imperfect specs into a stable output schema with actionable diagnostics.
use crate::parser::Request;
use std::collections::BTreeMap;

use super::{convert, names, prepare_model_data, utils};

/// Renders the final `schema.ts` content from parsed request IR.
pub fn render_schema_file(requests: &[Request]) -> String {
    let model_data = prepare_model_data::prepare_model_data(requests);
    let identifiers = names::build_model_identifiers(&model_data.names);
    let methods = group_requests_by_method(requests);

    let mut out = String::new();
    out.push_str("// This file is generated by kayto. Do not edit manually.\n\n");
    render_model_types(&mut out, &model_data, &identifiers);
    render_schemas_interface(&mut out, &model_data, &identifiers);
    render_endpoints_namespace(&mut out, &methods);
    out
}

/// Renders top-level model type aliases used by schema registry and endpoints.
fn render_model_types(
    out: &mut String,
    model_data: &prepare_model_data::ModelData,
    identifiers: &BTreeMap<String, String>,
) {
    for model_name in &model_data.names {
        let ident = identifiers
            .get(model_name)
            .expect("type identifier must exist for each model name");

        let ts_type = match model_data.definitions.get(model_name) {
            Some(schema_type) => convert::schema_to_ts(schema_type),
            None => "unknown".to_string(),
        };

        out.push_str(&format!("export type {ident} = {ts_type};\n\n"));
    }
}

/// Renders `Schemas` interface that maps schema keys to generated TS model aliases.
fn render_schemas_interface(
    out: &mut String,
    model_data: &prepare_model_data::ModelData,
    identifiers: &BTreeMap<String, String>,
) {
    out.push_str("export interface Schemas {\n");
    for model_name in &model_data.names {
        let ident = identifiers
            .get(model_name)
            .expect("type identifier must exist for each model name");
        out.push_str(&format!("  {}: {ident};\n", utils::ts_quote(model_name)));
    }
    out.push_str("}\n\n");
}

/// Renders endpoint metadata grouped by HTTP method.
fn render_endpoints_namespace(
    out: &mut String,
    methods: &BTreeMap<String, BTreeMap<String, &Request>>,
) {
    out.push_str("export interface Endpoints {\n");
    for (method, path_map) in methods {
        out.push_str(&format!("  {}: {{\n", utils::ts_quote(method)));
        for (path, req) in path_map {
            render_endpoint_entry(out, method, path, req);
        }
        out.push_str("  };\n");
    }
    out.push_str("}\n");
}

/// Renders a single endpoint metadata record under a method-specific interface.
fn render_endpoint_entry(out: &mut String, method: &str, path: &str, req: &Request) {
    out.push_str(&format!("    {}: {{\n", utils::ts_quote(path)));
    out.push_str(&format!("      path: {};\n", utils::ts_quote(&req.path)));
    out.push_str(&format!("      method: {};\n", utils::ts_quote(method)));

    if let Some(operation_id) = &req.operation_id {
        out.push_str(&format!(
            "      operationId: {};\n",
            utils::ts_quote(operation_id)
        ));
    }

    match convert::params_to_ts(req) {
        Some(params_type) => out.push_str(&format!(
            "      params: {};\n",
            utils::indent_inline(&params_type, "      ")
        )),
        None => out.push_str("      params?: never;\n"),
    }

    match req.body.as_ref() {
        Some(body) => {
            let body_type = convert::parsed_response_to_ts(body);
            out.push_str(&format!(
                "      body: {};\n",
                utils::indent_inline(&body_type, "      ")
            ));
        }
        None => out.push_str("      body?: never;\n"),
    }

    match convert::responses_to_ts(req) {
        Some(responses_type) => out.push_str(&format!(
            "      responses: {};\n",
            utils::indent_inline(&responses_type, "      ")
        )),
        None => out.push_str("      responses?: never;\n"),
    }

    out.push_str("    };\n");
}

/// Groups parsed requests by HTTP method and then by path for deterministic output.
fn group_requests_by_method(requests: &[Request]) -> BTreeMap<String, BTreeMap<String, &Request>> {
    let mut methods: BTreeMap<String, BTreeMap<String, &Request>> = BTreeMap::new();
    for req in requests {
        methods
            .entry(req.method.to_lowercase())
            .or_default()
            .insert(req.path.clone(), req);
    }
    methods
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::parser::{ParsedResponse, Request, SchemaType};
    use crate::{parser, spec, test_fixtures};
    use std::collections::BTreeMap;

    /// Creates a deterministic single-endpoint fixture for snapshot rendering checks.
    fn fixture_requests() -> Vec<Request> {
        let mut responses = BTreeMap::new();
        responses.insert(
            200,
            ParsedResponse {
                schema_type: Some(SchemaType::Ref("Item".to_string())),
                schema_name: None,
            },
        );

        vec![Request {
            path: "/items".to_string(),
            method: "get".to_string(),
            operation_id: Some("listItems".to_string()),
            params: None,
            body: None,
            responses: Some(responses),
        }]
    }

    /// Verifies rendered schema output for a small fixture remains stable.
    #[test]
    fn renders_expected_schema_snapshot() {
        let output = render_schema_file(&fixture_requests());
        let expected = r#"// This file is generated by kayto. Do not edit manually.

export type Item = unknown;

export interface Schemas {
  "Item": Item;
}

export interface Endpoints {
  "get": {
    "/items": {
      path: "/items";
      method: "get";
      operationId: "listItems";
      params?: never;
      body?: never;
      responses: {
        200: Schemas["Item"];
      };
    };
  };
}
"#;
        assert_eq!(output, expected);
    }

    /// Verifies reserved method names are rendered as quoted method keys.
    #[test]
    fn renders_reserved_method_as_quoted_key() {
        let mut responses = BTreeMap::new();
        responses.insert(
            204,
            ParsedResponse {
                schema_type: None,
                schema_name: None,
            },
        );

        let requests = vec![Request {
            path: "/items/{id}".to_string(),
            method: "delete".to_string(),
            operation_id: Some("deleteItem".to_string()),
            params: None,
            body: None,
            responses: Some(responses),
        }];

        let output = render_schema_file(&requests);
        assert!(output.contains("\"delete\": {"));
        assert!(output.contains("method: \"delete\";"));
    }

    /// Parses OpenAPI JSON and returns parser IR requests for end-to-end rendering tests.
    fn parse_requests_from_json(input: &str) -> Vec<Request> {
        let openapi: spec::OpenAPI = serde_json::from_str(input).expect("valid OpenAPI json");
        let parsed = parser::parse(&openapi).expect("parser should return output");
        assert!(
            parsed.issues.is_empty(),
            "expected no parser issues, got: {:#?}",
            parsed.issues
        );
        parsed.requests
    }

    /// Verifies full TS rendering for schemas and endpoints that use anyOf/oneOf/allOf.
    #[test]
    fn renders_combinators_end_to_end_snapshot() {
        let requests = parse_requests_from_json(test_fixtures::COMBINATORS_OPENAPI_JSON);

        let output = render_schema_file(&requests);
        let expected = r#"// This file is generated by kayto. Do not edit manually.

export type BasePet = {
  "id"?: number;
};

export type CatKind = ("cat") | ("tiger");

export type CreatePetRequest = {
  "filter"?: Schemas["PetFilter"];
  "kind"?: Schemas["CatKind"];
  "name": string;
};

export type PetDetails = (Schemas["BasePet"]) & ({
  "name": string;
});

export type PetFilter = (string) | (number);

export interface Schemas {
  "BasePet": BasePet;
  "CatKind": CatKind;
  "CreatePetRequest": CreatePetRequest;
  "PetDetails": PetDetails;
  "PetFilter": PetFilter;
}

export interface Endpoints {
  "get": {
    "/base-pet": {
      path: "/base-pet";
      method: "get";
      operationId: "getBasePet";
      params?: never;
      body?: never;
      responses: {
        200: Schemas["BasePet"];
      };
    };
    "/filters": {
      path: "/filters";
      method: "get";
      operationId: "getPetFilter";
      params?: never;
      body?: never;
      responses: {
        200: Schemas["PetFilter"];
      };
    };
    "/kinds": {
      path: "/kinds";
      method: "get";
      operationId: "getCatKind";
      params?: never;
      body?: never;
      responses: {
        200: Schemas["CatKind"];
      };
    };
  };
  "post": {
    "/pets": {
      path: "/pets";
      method: "post";
      operationId: "createPet";
      params?: never;
      body: Schemas["CreatePetRequest"];
      responses: {
        201: Schemas["PetDetails"];
      };
    };
  };
}
"#;
        assert_eq!(output, expected);
    }
}