use crate::parser::Request;
use std::collections::{BTreeMap, HashSet};
use super::{convert, names, prepare_model_data, utils};
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_namespace(&mut out, &model_data, &identifiers);
render_endpoint_meta_class(&mut out);
render_endpoints_namespace(&mut out, &methods, &identifiers);
out
}
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 dart_type = match model_data.definitions.get(model_name) {
Some(schema_type) => convert::schema_to_dart(schema_type, identifiers),
None => "Object?".to_string(),
};
out.push_str(&format!("typedef {ident} = {dart_type};\n\n"));
}
}
fn render_schemas_namespace(
out: &mut String,
model_data: &prepare_model_data::ModelData,
identifiers: &BTreeMap<String, String>,
) {
out.push_str("abstract final class Schemas {\n");
out.push_str(" static const Map<String, String> types = {\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!(
" {}: {},\n",
utils::dart_quote(model_name),
utils::dart_quote(ident)
));
}
out.push_str(" };\n");
out.push_str("}\n\n");
}
fn render_endpoint_meta_class(out: &mut String) {
out.push_str("class EndpointMeta {\n");
out.push_str(" final String path;\n");
out.push_str(" final String method;\n");
out.push_str(" final String? operationId;\n");
out.push_str(" final Map<String, Map<String, String>>? params;\n");
out.push_str(" final String? bodyType;\n");
out.push_str(" final Map<int, String>? responses;\n\n");
out.push_str(" const EndpointMeta({\n");
out.push_str(" required this.path,\n");
out.push_str(" required this.method,\n");
out.push_str(" this.operationId,\n");
out.push_str(" this.params,\n");
out.push_str(" this.bodyType,\n");
out.push_str(" this.responses,\n");
out.push_str(" });\n");
out.push_str("}\n\n");
}
fn render_endpoints_namespace(
out: &mut String,
methods: &BTreeMap<String, BTreeMap<String, &Request>>,
identifiers: &BTreeMap<String, String>,
) {
render_method_endpoint_classes(out, methods, identifiers);
out.push_str("abstract final class Endpoints {\n");
for method in methods.keys() {
let class_name = method_class_name(method);
out.push_str(&format!(" static const {method} = {class_name}();\n"));
}
out.push('\n');
out.push_str(" static const Map<String, Map<String, EndpointMeta>> byMethod = {\n");
for method in methods.keys() {
let class_name = method_class_name(method);
out.push_str(&format!(
" {}: {class_name}.byPath,\n",
utils::dart_quote(method)
));
}
out.push_str(" };\n");
out.push_str("}\n");
}
fn render_method_endpoint_classes(
out: &mut String,
methods: &BTreeMap<String, BTreeMap<String, &Request>>,
identifiers: &BTreeMap<String, String>,
) {
for (method, path_map) in methods {
let class_name = method_class_name(method);
let getter_names = build_endpoint_getter_names(path_map);
out.push_str(&format!("class {class_name} {{\n"));
out.push_str(&format!(" const {class_name}();\n\n"));
out.push_str(" static const Map<String, EndpointMeta> byPath = {\n");
for (path, req) in path_map {
render_endpoint_entry(out, " ", method, path, req, identifiers);
}
out.push_str(" };\n\n");
for (path, getter_name) in getter_names {
out.push_str(&format!(
" EndpointMeta get {getter_name} => byPath[{}]!;\n",
utils::dart_quote(&path)
));
}
out.push('\n');
out.push_str(" EndpointMeta operator [](String path) => byPath[path]!;\n");
out.push_str("}\n\n");
}
}
fn render_endpoint_entry(
out: &mut String,
entry_indent: &str,
method: &str,
path: &str,
req: &Request,
identifiers: &BTreeMap<String, String>,
) {
let field_indent = format!("{entry_indent} ");
out.push_str(&format!(
"{entry_indent}{}: EndpointMeta(\n",
utils::dart_quote(path)
));
out.push_str(&format!(
"{field_indent}path: {},\n",
utils::dart_quote(&req.path)
));
out.push_str(&format!(
"{field_indent}method: {},\n",
utils::dart_quote(method)
));
if let Some(operation_id) = &req.operation_id {
out.push_str(&format!(
"{field_indent}operationId: {},\n",
utils::dart_quote(operation_id)
));
}
if let Some(params_meta) = convert::params_to_dart_meta(req, identifiers) {
out.push_str(&format!(
"{field_indent}params: {},\n",
utils::indent_inline(¶ms_meta, &field_indent)
));
}
if let Some(body) = req.body.as_ref() {
let body_type = convert::parsed_response_to_dart_type(body, identifiers);
out.push_str(&format!(
"{field_indent}bodyType: {},\n",
utils::dart_quote(&body_type)
));
}
if let Some(responses_meta) = convert::responses_to_dart_meta(req, identifiers) {
out.push_str(&format!(
"{field_indent}responses: {},\n",
utils::indent_inline(&responses_meta, &field_indent)
));
}
out.push_str(&format!("{entry_indent}),\n"));
}
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
}
fn method_class_name(method: &str) -> String {
format!("Endpoints{}", upper_camel(method))
}
fn upper_camel(value: &str) -> String {
let mut out = String::new();
let mut next_upper = true;
for ch in value.chars() {
if !ch.is_ascii_alphanumeric() {
next_upper = true;
continue;
}
if next_upper {
out.push(ch.to_ascii_uppercase());
next_upper = false;
continue;
}
out.push(ch);
}
if out.is_empty() {
"Value".to_string()
} else {
out
}
}
fn build_endpoint_getter_names(path_map: &BTreeMap<String, &Request>) -> BTreeMap<String, String> {
let mut used = HashSet::new();
let mut result = BTreeMap::new();
for path in path_map.keys() {
let base = sanitize_endpoint_getter_name(path);
let mut candidate = base.clone();
let mut n = 2usize;
while used.contains(&candidate) {
candidate = format!("{base}{n}");
n += 1;
}
used.insert(candidate.clone());
result.insert(path.clone(), candidate);
}
result
}
fn sanitize_endpoint_getter_name(path: &str) -> String {
let mut parts: Vec<String> = Vec::new();
let mut current = String::new();
for ch in path.chars() {
if ch.is_ascii_alphanumeric() {
current.push(ch);
continue;
}
if !current.is_empty() {
parts.push(current);
current = String::new();
}
}
if !current.is_empty() {
parts.push(current);
}
if parts.is_empty() {
return "root".to_string();
}
let mut out = String::new();
for (idx, part) in parts.into_iter().enumerate() {
let mut chars = part.chars();
let Some(first) = chars.next() else {
continue;
};
if idx == 0 {
out.push(first.to_ascii_lowercase());
} else {
out.push(first.to_ascii_uppercase());
}
out.extend(chars);
}
if out
.chars()
.next()
.map(|ch| ch.is_ascii_digit())
.unwrap_or(false)
{
out = format!("endpoint{out}");
}
match out.as_str() {
"class" | "enum" | "switch" | "case" | "default" | "get" | "set" | "static"
| "void" | "final" | "const" => format!("{out}Endpoint"),
_ => out,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::{ParsedResponse, Request, SchemaType};
use crate::{parser, spec, test_fixtures};
use std::collections::BTreeMap;
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),
}]
}
#[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.
typedef Item = Object?;
abstract final class Schemas {
static const Map<String, String> types = {
'Item': 'Item',
};
}
class EndpointMeta {
final String path;
final String method;
final String? operationId;
final Map<String, Map<String, String>>? params;
final String? bodyType;
final Map<int, String>? responses;
const EndpointMeta({
required this.path,
required this.method,
this.operationId,
this.params,
this.bodyType,
this.responses,
});
}
class EndpointsGet {
const EndpointsGet();
static const Map<String, EndpointMeta> byPath = {
'/items': EndpointMeta(
path: '/items',
method: 'get',
operationId: 'listItems',
responses: {
200: 'Item',
},
),
};
EndpointMeta get items => byPath['/items']!;
EndpointMeta operator [](String path) => byPath[path]!;
}
abstract final class Endpoints {
static const get = EndpointsGet();
static const Map<String, Map<String, EndpointMeta>> byMethod = {
'get': EndpointsGet.byPath,
};
}
"#;
assert_eq!(output, expected);
}
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
}
#[test]
fn renders_combinators_end_to_end() {
let requests = parse_requests_from_json(test_fixtures::COMBINATORS_OPENAPI_JSON);
let output = render_schema_file(&requests);
assert!(output.contains("typedef BasePet = Map<String, Object?>;"));
assert!(output.contains("typedef CatKind = Object?;"));
assert!(output.contains("typedef PetFilter = Object?;"));
assert!(output.contains("abstract final class Endpoints"));
assert!(output.contains("class EndpointsPost"));
assert!(output.contains("static const post = EndpointsPost();"));
assert!(output.contains("bodyType: 'CreatePetRequest'"));
assert!(output.contains("201: 'PetDetails'"));
}
}