Skip to main content

spikard_cli/codegen/
elixir.rs

1//! Elixir code generation from `OpenAPI` schemas.
2
3use super::ElixirDtoStyle;
4use super::base::OpenApiGenerator;
5use anyhow::Result;
6use heck::{ToPascalCase, ToSnakeCase};
7use openapiv3::{
8    OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent, ReferenceOr, Schema, SchemaKind,
9    StatusCode, StringFormat, Type, VariantOrUnknownOrEmpty,
10};
11use serde_json::{Map, Number, Value};
12use std::io::Write;
13use std::process::{Command, Stdio};
14
15use crate::codegen::SchemaRegistry;
16
17pub struct ElixirGenerator {
18    spec: OpenAPI,
19    registry: SchemaRegistry,
20    style: ElixirDtoStyle,
21}
22
23#[derive(Default)]
24struct ElixirParamHelperUsage {
25    uuid: bool,
26    integer: bool,
27    float: bool,
28    boolean: bool,
29    date: bool,
30    datetime: bool,
31    enum_values: bool,
32}
33
34impl ElixirGenerator {
35    pub fn new(spec: OpenAPI, style: ElixirDtoStyle) -> Self {
36        let registry = SchemaRegistry::from_spec(&spec);
37        Self { spec, registry, style }
38    }
39
40    fn root_module_name(&self) -> String {
41        let base = self
42            .spec
43            .info
44            .title
45            .split(|c: char| !c.is_ascii_alphanumeric())
46            .filter(|part| !part.is_empty())
47            .collect::<Vec<_>>()
48            .join(" ");
49
50        match base.as_str() {
51            "" => "GeneratedApi".to_string(),
52            value => {
53                let module = value.to_pascal_case();
54                if module.ends_with("Api") {
55                    module
56                } else {
57                    format!("{module}Api")
58                }
59            }
60        }
61    }
62
63    fn schema_type_name(&self, name: &str) -> String {
64        name.to_snake_case()
65    }
66
67    fn route_path(&self, path: &str) -> String {
68        let mut route = path.to_string();
69        for segment in path.split('/') {
70            if segment.starts_with('{') && segment.ends_with('}') {
71                let name = segment.trim_matches(|c| c == '{' || c == '}');
72                route = route.replace(&format!("{{{name}}}"), &format!(":{}", name.to_snake_case()));
73            }
74        }
75        route
76    }
77
78    fn escape_string(&self, value: &str) -> String {
79        value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
80    }
81
82    fn render_elixir_value(&self, value: &Value, indent_level: usize) -> String {
83        let indent = "  ".repeat(indent_level);
84        let child_indent = "  ".repeat(indent_level + 1);
85
86        match value {
87            Value::Null => "nil".to_string(),
88            Value::Bool(boolean) => boolean.to_string(),
89            Value::Number(number) => number.to_string(),
90            Value::String(string) => format!("\"{}\"", self.escape_string(string)),
91            Value::Array(items) => {
92                if items.is_empty() {
93                    "[]".to_string()
94                } else {
95                    let rendered = items
96                        .iter()
97                        .map(|item| format!("{child_indent}{}", self.render_elixir_value(item, indent_level + 1)))
98                        .collect::<Vec<_>>()
99                        .join(",\n");
100                    format!("[\n{rendered}\n{indent}]")
101                }
102            }
103            Value::Object(map) => {
104                if map.is_empty() {
105                    "%{}".to_string()
106                } else {
107                    let rendered = map
108                        .iter()
109                        .map(|(key, item)| {
110                            format!(
111                                "{child_indent}\"{}\" => {}",
112                                self.escape_string(key),
113                                self.render_elixir_value(item, indent_level + 1)
114                            )
115                        })
116                        .collect::<Vec<_>>()
117                        .join(",\n");
118                    format!("%{{\n{rendered}\n{indent}}}")
119                }
120            }
121        }
122    }
123
124    fn render_schema_literal(&self, schema: &Schema) -> Result<String> {
125        let value = serde_json::to_value(schema)?;
126        Ok(self.render_elixir_value(&value, 1))
127    }
128
129    fn resolve_boxed_schema<'a>(&'a self, schema_ref: &'a ReferenceOr<Box<Schema>>) -> Option<&'a Schema> {
130        match schema_ref {
131            ReferenceOr::Item(schema) => Some(schema.as_ref()),
132            ReferenceOr::Reference { reference } => self.registry.resolve_reference(reference),
133        }
134    }
135
136    fn safe_required_key(&self, name: &str) -> String {
137        let atom_name = name.to_snake_case();
138
139        if atom_name
140            .chars()
141            .enumerate()
142            .all(|(index, ch)| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || (index > 0 && ch == '?'))
143            && !atom_name.is_empty()
144            && !atom_name.starts_with(|c: char| c.is_ascii_digit())
145        {
146            format!(":{atom_name}")
147        } else {
148            "String.t()".to_string()
149        }
150    }
151
152    fn schema_to_typespec(&self, schema: &Schema, nullable: bool) -> String {
153        let base = match &schema.schema_kind {
154            SchemaKind::Type(Type::String(string_type)) => {
155                if string_type.enumeration.iter().flatten().next().is_none() {
156                    "String.t()".to_string()
157                } else {
158                    "String.t()".to_string()
159                }
160            }
161            SchemaKind::Type(Type::Number(_)) => "float()".to_string(),
162            SchemaKind::Type(Type::Integer(_)) => "integer()".to_string(),
163            SchemaKind::Type(Type::Boolean(_)) => "boolean()".to_string(),
164            SchemaKind::Type(Type::Array(array)) => {
165                let item_type = array
166                    .items
167                    .as_ref()
168                    .and_then(|item| self.resolve_boxed_schema(item))
169                    .map_or_else(|| "term()".to_string(), |item| self.schema_to_typespec(item, false));
170                format!("[{item_type}]")
171            }
172            SchemaKind::Type(Type::Object(object)) => {
173                if object.properties.is_empty() {
174                    "map()".to_string()
175                } else {
176                    let fields = object
177                        .properties
178                        .iter()
179                        .map(|(name, schema_ref)| {
180                            let resolved = self.resolve_boxed_schema(schema_ref);
181                            let field_type = resolved
182                                .map(|item| self.schema_to_typespec(item, !object.required.contains(name)))
183                                .unwrap_or_else(|| "term()".to_string());
184                            let key_type = if object.required.contains(name) {
185                                format!("required({})", self.safe_required_key(name))
186                            } else {
187                                format!("optional({})", self.safe_required_key(name))
188                            };
189                            format!("{key_type} => {field_type}")
190                        })
191                        .collect::<Vec<_>>()
192                        .join(", ");
193                    format!("%{{{fields}}}")
194                }
195            }
196            SchemaKind::AllOf { .. } | SchemaKind::AnyOf { .. } | SchemaKind::OneOf { .. } => "map()".to_string(),
197            _ => "term()".to_string(),
198        };
199
200        if nullable || schema.schema_data.nullable {
201            format!("{base} | nil")
202        } else {
203            base
204        }
205    }
206
207    fn schema_placeholder(&self, schema: &Schema) -> Value {
208        if let Some(example) = schema.schema_data.example.clone() {
209            return example;
210        }
211
212        match &schema.schema_kind {
213            SchemaKind::Type(Type::String(string_type)) => {
214                if let Some(first) = string_type.enumeration.iter().flatten().next() {
215                    Value::String(first.clone())
216                } else {
217                    Value::String("TODO".to_string())
218                }
219            }
220            SchemaKind::Type(Type::Number(_)) => Value::Number(Number::from_f64(0.0).unwrap()),
221            SchemaKind::Type(Type::Integer(_)) => Value::Number(Number::from(0)),
222            SchemaKind::Type(Type::Boolean(_)) => Value::Bool(false),
223            SchemaKind::Type(Type::Array(array)) => {
224                if let Some(item) = &array.items
225                    && let Some(resolved) = self.resolve_boxed_schema(item)
226                {
227                    Value::Array(vec![self.schema_placeholder(resolved)])
228                } else {
229                    Value::Array(vec![])
230                }
231            }
232            SchemaKind::Type(Type::Object(object)) => {
233                let mut map = Map::new();
234                for (name, schema_ref) in &object.properties {
235                    let value = self
236                        .resolve_boxed_schema(schema_ref)
237                        .map(|item| self.schema_placeholder(item))
238                        .unwrap_or(Value::Null);
239                    map.insert(name.clone(), value);
240                }
241                Value::Object(map)
242            }
243            _ => Value::Null,
244        }
245    }
246
247    fn parameter_schema(&self, operation: &Operation) -> Option<Schema> {
248        let mut properties = Map::new();
249        let mut required = Vec::new();
250
251        for parameter_ref in &operation.parameters {
252            let ReferenceOr::Item(parameter) = parameter_ref else {
253                continue;
254            };
255
256            match parameter {
257                Parameter::Path { parameter_data, .. }
258                | Parameter::Query { parameter_data, .. }
259                | Parameter::Header { parameter_data, .. }
260                | Parameter::Cookie { parameter_data, .. } => {
261                    let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = &parameter_data.format else {
262                        continue;
263                    };
264                    let Some(schema) = self.registry.resolve(schema_ref) else {
265                        continue;
266                    };
267                    let Ok(value) = serde_json::to_value(schema) else {
268                        continue;
269                    };
270                    properties.insert(parameter_data.name.clone(), value);
271                    if parameter_data.required {
272                        required.push(Value::String(parameter_data.name.clone()));
273                    }
274                }
275            }
276        }
277
278        if properties.is_empty() {
279            return None;
280        }
281
282        let schema_json = Value::Object(Map::from_iter([
283            ("type".to_string(), Value::String("object".to_string())),
284            ("properties".to_string(), Value::Object(properties)),
285            ("required".to_string(), Value::Array(required)),
286        ]));
287
288        serde_json::from_value(schema_json).ok()
289    }
290
291    fn request_body_schema<'a>(&'a self, operation: &'a Operation) -> Option<&'a Schema> {
292        let body = operation.request_body.as_ref()?;
293        let request_body = match body {
294            ReferenceOr::Item(item) => item,
295            ReferenceOr::Reference { reference } => {
296                return self.registry.resolve_reference(reference);
297            }
298        };
299        let media_type = request_body.content.get("application/json")?;
300        media_type
301            .schema
302            .as_ref()
303            .and_then(|schema_ref| self.registry.resolve(schema_ref))
304    }
305
306    fn response_schema<'a>(&'a self, operation: &'a Operation) -> Option<(u16, &'a Schema)> {
307        let response = operation
308            .responses
309            .responses
310            .iter()
311            .find_map(|(status, response_ref)| match status {
312                StatusCode::Code(code) if (200..300).contains(code) => Some((*code, response_ref)),
313                StatusCode::Range(2) => Some((200, response_ref)),
314                _ => None,
315            })?;
316
317        let status = response.0;
318        let response = match response.1 {
319            ReferenceOr::Item(item) => item,
320            ReferenceOr::Reference { reference } => {
321                return self
322                    .registry
323                    .resolve_reference(reference)
324                    .map(|schema| (status, schema));
325            }
326        };
327
328        let media_type = response.content.get("application/json")?;
329        media_type
330            .schema
331            .as_ref()
332            .and_then(|schema_ref| self.registry.resolve(schema_ref))
333            .map(|schema| (status, schema))
334    }
335
336    fn route_options(&self, operation_id: &str, operation: &Operation) -> Result<(String, Vec<String>)> {
337        let mut prelude = Vec::new();
338        let mut options = Vec::new();
339
340        if let Some(parameter_schema) = self.parameter_schema(operation) {
341            let attr_name = format!("{operation_id}_params_schema");
342            prelude.push(format!(
343                "  @{} {}\n",
344                attr_name,
345                self.render_schema_literal(&parameter_schema)?
346            ));
347            options.push(format!("parameter_schema: @{}", attr_name));
348        }
349
350        if let Some(schema) = self.request_body_schema(operation) {
351            let attr_name = format!("{operation_id}_request_schema");
352            prelude.push(format!("  @{} {}\n", attr_name, self.render_schema_literal(schema)?));
353            options.push(format!("request_schema: @{}", attr_name));
354        }
355
356        if let Some((_, schema)) = self.response_schema(operation) {
357            let attr_name = format!("{operation_id}_response_schema");
358            prelude.push(format!("  @{} {}\n", attr_name, self.render_schema_literal(schema)?));
359            options.push(format!("response_schema: @{}", attr_name));
360        }
361
362        Ok((prelude.join(""), options))
363    }
364
365    fn parameter_binding(&self, parameter: &Parameter) -> Option<String> {
366        let (parameter_data, getter) = match parameter {
367            Parameter::Path { parameter_data, .. } => (parameter_data, "get_path_param"),
368            Parameter::Query { parameter_data, .. } => (parameter_data, "get_query_param"),
369            Parameter::Header { parameter_data, .. } => (parameter_data, "get_header"),
370            Parameter::Cookie { parameter_data, .. } => (parameter_data, "get_cookie"),
371        };
372
373        let variable = format!("_{}", parameter_data.name.to_snake_case());
374        let access = format!("Spikard.Request.{getter}(request, \"{}\")", parameter_data.name);
375        Some(format!(
376            "    {} = {}\n",
377            variable,
378            self.parameter_coercion_expr(parameter_data, &access)
379        ))
380    }
381
382    fn parameter_coercion_expr(&self, parameter_data: &ParameterData, access: &str) -> String {
383        match &parameter_data.format {
384            ParameterSchemaOrContent::Schema(schema_ref) => {
385                self.schema_param_coercion_expr(schema_ref, access, &parameter_data.name)
386            }
387            ParameterSchemaOrContent::Content(_) => access.to_string(),
388        }
389    }
390
391    fn schema_param_coercion_expr(&self, schema_ref: &ReferenceOr<Schema>, access: &str, name: &str) -> String {
392        let Some(schema) = self.registry.resolve(schema_ref) else {
393            return access.to_string();
394        };
395        self.inline_schema_param_coercion_expr(schema, access, name)
396    }
397
398    fn inline_schema_param_coercion_expr(&self, schema: &Schema, access: &str, name: &str) -> String {
399        match &schema.schema_kind {
400            SchemaKind::Type(Type::String(string_type)) => {
401                let enum_values = string_type
402                    .enumeration
403                    .iter()
404                    .flatten()
405                    .map(|value| format!("\"{}\"", self.escape_string(value)))
406                    .collect::<Vec<_>>();
407
408                if !enum_values.is_empty() {
409                    return format!(
410                        "coerce_enum_param!({}, \"{}\", [{}])",
411                        access,
412                        name,
413                        enum_values.join(", ")
414                    );
415                }
416
417                match &string_type.format {
418                    VariantOrUnknownOrEmpty::Item(StringFormat::Date) => {
419                        format!("coerce_date_param!({}, \"{}\")", access, name)
420                    }
421                    VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => {
422                        format!("coerce_datetime_param!({}, \"{}\")", access, name)
423                    }
424                    VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => {
425                        format!("coerce_uuid_param!({}, \"{}\")", access, name)
426                    }
427                    _ => access.to_string(),
428                }
429            }
430            SchemaKind::Type(Type::Integer(_)) => format!("coerce_integer_param!({}, \"{}\")", access, name),
431            SchemaKind::Type(Type::Number(_)) => format!("coerce_float_param!({}, \"{}\")", access, name),
432            SchemaKind::Type(Type::Boolean(_)) => format!("coerce_boolean_param!({}, \"{}\")", access, name),
433            _ => access.to_string(),
434        }
435    }
436
437    fn collect_param_helper_usage(&self, operation: &Operation, usage: &mut ElixirParamHelperUsage) {
438        for parameter_ref in &operation.parameters {
439            let ReferenceOr::Item(parameter) = parameter_ref else {
440                continue;
441            };
442
443            let parameter_data = match parameter {
444                Parameter::Path { parameter_data, .. }
445                | Parameter::Query { parameter_data, .. }
446                | Parameter::Header { parameter_data, .. }
447                | Parameter::Cookie { parameter_data, .. } => parameter_data,
448            };
449
450            let ParameterSchemaOrContent::Schema(schema_ref) = &parameter_data.format else {
451                continue;
452            };
453            let Some(schema) = self.registry.resolve(schema_ref) else {
454                continue;
455            };
456
457            self.collect_schema_helper_usage(schema, usage);
458        }
459    }
460
461    fn collect_schema_helper_usage(&self, schema: &Schema, usage: &mut ElixirParamHelperUsage) {
462        match &schema.schema_kind {
463            SchemaKind::Type(Type::String(string_type)) => {
464                if string_type.enumeration.iter().flatten().next().is_some() {
465                    usage.enum_values = true;
466                }
467                match &string_type.format {
468                    VariantOrUnknownOrEmpty::Item(StringFormat::Date) => usage.date = true,
469                    VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => usage.datetime = true,
470                    VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => usage.uuid = true,
471                    _ => {}
472                }
473            }
474            SchemaKind::Type(Type::Integer(_)) => usage.integer = true,
475            SchemaKind::Type(Type::Number(_)) => usage.float = true,
476            SchemaKind::Type(Type::Boolean(_)) => usage.boolean = true,
477            _ => {}
478        }
479    }
480
481    fn render_param_helpers(&self, usage: &ElixirParamHelperUsage) -> String {
482        let mut helpers = String::new();
483
484        if usage.uuid {
485            helpers.push_str(
486                r#"  @uuid_regex ~r/\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/
487
488  defp coerce_uuid_param!(nil, _name), do: nil
489
490  defp coerce_uuid_param!(value, name) do
491    if Regex.match?(@uuid_regex, value) do
492      value
493    else
494      invalid_parameter!(name, "must be a UUID")
495    end
496  end
497
498"#,
499            );
500        }
501
502        if usage.integer {
503            helpers.push_str(
504                r#"  defp coerce_integer_param!(nil, _name), do: nil
505
506  defp coerce_integer_param!(value, name) do
507    case Integer.parse(value) do
508      {integer, ""} -> integer
509      _ -> invalid_parameter!(name, "must be an integer")
510    end
511  end
512
513"#,
514            );
515        }
516
517        if usage.float {
518            helpers.push_str(
519                r#"  defp coerce_float_param!(nil, _name), do: nil
520
521  defp coerce_float_param!(value, name) do
522    case Float.parse(value) do
523      {float, ""} -> float
524      _ -> invalid_parameter!(name, "must be a float")
525    end
526  end
527
528"#,
529            );
530        }
531
532        if usage.boolean {
533            helpers.push_str(
534                r#"  defp coerce_boolean_param!(nil, _name), do: nil
535  defp coerce_boolean_param!(true, _name), do: true
536  defp coerce_boolean_param!(false, _name), do: false
537  defp coerce_boolean_param!("true", _name), do: true
538  defp coerce_boolean_param!("false", _name), do: false
539  defp coerce_boolean_param!("1", _name), do: true
540  defp coerce_boolean_param!("0", _name), do: false
541  defp coerce_boolean_param!(_value, name), do: invalid_parameter!(name, "must be a boolean")
542
543"#,
544            );
545        }
546
547        if usage.date {
548            helpers.push_str(
549                r#"  defp coerce_date_param!(nil, _name), do: nil
550
551  defp coerce_date_param!(value, name) do
552    case Date.from_iso8601(value) do
553      {:ok, date} -> date
554      {:error, _reason} -> invalid_parameter!(name, "must be an ISO 8601 date")
555    end
556  end
557
558"#,
559            );
560        }
561
562        if usage.datetime {
563            helpers.push_str(
564                r#"  defp coerce_datetime_param!(nil, _name), do: nil
565
566  defp coerce_datetime_param!(value, name) do
567    case DateTime.from_iso8601(value) do
568      {:ok, datetime, _offset} -> datetime
569      {:error, _reason} -> invalid_parameter!(name, "must be an ISO 8601 date-time")
570    end
571  end
572
573"#,
574            );
575        }
576
577        if usage.enum_values {
578            helpers.push_str(
579                r#"  defp coerce_enum_param!(nil, _name, _allowed), do: nil
580
581  defp coerce_enum_param!(value, name, allowed) do
582    if value in allowed do
583      value
584    else
585      invalid_parameter!(name, "must be one of: #{Enum.join(allowed, ", ")}")
586    end
587  end
588
589"#,
590            );
591        }
592
593        if !helpers.is_empty() {
594            helpers.push_str(
595                r#"  defp invalid_parameter!(name, message) do
596    raise ArgumentError, "invalid parameter #{name}: #{message}"
597  end
598
599"#,
600            );
601        }
602
603        helpers
604    }
605
606    fn handler_stub(&self, operation: &Operation, operation_id: &str) -> String {
607        let mut code = String::new();
608        let has_request_data = !operation.parameters.is_empty() || operation.request_body.is_some();
609        let request_name = if has_request_data { "request" } else { "_request" };
610
611        code.push_str(&format!(
612            "  @spec {}(Spikard.Request.t()) :: Spikard.Response.t()\n",
613            operation_id
614        ));
615        code.push_str(&format!("  def {}({}) do\n", operation_id, request_name));
616
617        if has_request_data {
618            for parameter_ref in &operation.parameters {
619                let ReferenceOr::Item(parameter) = parameter_ref else {
620                    continue;
621                };
622                if let Some(binding) = self.parameter_binding(parameter) {
623                    code.push_str(&binding);
624                }
625            }
626
627            if self.request_body_schema(operation).is_some() {
628                code.push_str("    _body = Spikard.Request.get_body(request)\n");
629            }
630            code.push('\n');
631        }
632
633        if let Some((status, schema)) = self.response_schema(operation) {
634            let payload = self.render_elixir_value(&self.schema_placeholder(schema), 3);
635            code.push_str(&format!(
636                "    Response.json(\n      {payload},\n      status: {status}\n    )\n"
637            ));
638        } else {
639            let status = operation
640                .responses
641                .responses
642                .keys()
643                .find_map(|status| match status {
644                    StatusCode::Code(code) if (200..300).contains(code) => Some(*code),
645                    StatusCode::Range(2) => Some(200),
646                    _ => None,
647                })
648                .unwrap_or(200);
649            code.push_str(&format!("    Response.status({status})\n"));
650        }
651
652        code.push_str("  end\n\n");
653        code
654    }
655
656    fn format_generated(&self, code: &str) -> String {
657        let mut command = match Command::new("elixir")
658            .arg("-e")
659            .arg(
660                r#"input = IO.read(:stdio, :all)
661IO.write(IO.iodata_to_binary(Code.format_string!(input, line_length: 120)))"#,
662            )
663            .stdin(Stdio::piped())
664            .stdout(Stdio::piped())
665            .stderr(Stdio::piped())
666            .spawn()
667        {
668            Ok(command) => command,
669            Err(_) => return code.to_string(),
670        };
671
672        let Some(stdin) = command.stdin.as_mut() else {
673            return code.to_string();
674        };
675        if stdin.write_all(code.as_bytes()).is_err() {
676            return code.to_string();
677        }
678
679        match command.wait_with_output() {
680            Ok(output) if output.status.success() => {
681                let mut formatted = String::from_utf8(output.stdout).unwrap_or_else(|_| code.to_string());
682                if !formatted.ends_with('\n') {
683                    formatted.push('\n');
684                }
685                formatted
686            }
687            _ => {
688                let mut fallback = code.to_string();
689                if !fallback.ends_with('\n') {
690                    fallback.push('\n');
691                }
692                fallback
693            }
694        }
695    }
696}
697
698impl OpenApiGenerator for ElixirGenerator {
699    fn spec(&self) -> &OpenAPI {
700        &self.spec
701    }
702
703    fn registry(&self) -> &SchemaRegistry {
704        &self.registry
705    }
706
707    fn generate(&self) -> Result<String> {
708        let mut output = String::new();
709        output.push_str(&self.generate_header());
710        output.push_str(&self.generate_models()?);
711        output.push_str(&self.generate_routes()?);
712
713        Ok(self.format_generated(&output))
714    }
715
716    fn generate_header(&self) -> String {
717        let module_name = self.root_module_name();
718        let _ = self.style;
719        format!(
720            "defmodule {module_name}.Router do\n  @moduledoc \"\"\"\n  Generated by Spikard OpenAPI code generator.\n\n  This router wraps the operations defined in the OpenAPI specification and\n  attaches request/response schemas for runtime validation and OpenAPI export.\n  \"\"\"\n\n  use Spikard.Router\n\n  alias {module_name}.Handlers\n\n"
721        )
722    }
723
724    fn generate_models(&self) -> Result<String> {
725        let mut output = String::new();
726
727        self.iter_schemas(|name, schema| {
728            let type_name = self.schema_type_name(name);
729
730            output.push_str(&format!("  @typedoc \"OpenAPI schema for {name}.\"\n"));
731            output.push_str(&format!(
732                "  @type {} :: {}\n",
733                type_name,
734                self.schema_to_typespec(schema, false)
735            ));
736            output.push('\n');
737            Ok(())
738        })?;
739
740        Ok(output)
741    }
742
743    fn generate_routes(&self) -> Result<String> {
744        let module_name = self.root_module_name();
745        let mut router = String::new();
746        let mut handlers = String::new();
747        let mut helper_usage = ElixirParamHelperUsage::default();
748
749        handlers.push_str(&format!(
750            "defmodule {module_name}.Handlers do\n  @moduledoc false\n\n  alias Spikard.Response\n\n"
751        ));
752
753        self.iter_paths(|path, method, operation| {
754            let operation_id = self.generate_operation_id(path, method, operation);
755            let (prelude, options) = self.route_options(&operation_id, operation)?;
756            if !prelude.is_empty() {
757                router.push_str(&prelude);
758            }
759
760            let route = self.route_path(path);
761            let handler_ref = format!("&Handlers.{}/1", operation_id);
762            if !options.is_empty() {
763                router.push_str(&format!(
764                    "  {}(\"{}\", {}, {})",
765                    method,
766                    route,
767                    handler_ref,
768                    options.join(", ")
769                ));
770            } else {
771                router.push_str(&format!("  {} \"{}\", {}", method, route, handler_ref));
772            }
773            router.push_str("\n\n");
774
775            self.collect_param_helper_usage(operation, &mut helper_usage);
776            handlers.push_str(&self.handler_stub(operation, &operation_id));
777            Ok(())
778        })?;
779
780        while router.ends_with("\n\n") {
781            router.pop();
782        }
783        router.push_str("end\n\n");
784        while handlers.ends_with("\n\n") {
785            handlers.pop();
786        }
787        handlers.push_str(&self.render_param_helpers(&helper_usage));
788        handlers.push_str("end\n");
789
790        Ok(format!("{router}{handlers}"))
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797    use openapiv3::{Info, Paths};
798
799    #[test]
800    fn generates_elixir_module_name_from_title() {
801        let generator = ElixirGenerator::new(
802            OpenAPI {
803                openapi: "3.1.0".to_string(),
804                info: Info {
805                    title: "Example Service".to_string(),
806                    version: "1.0.0".to_string(),
807                    ..Default::default()
808                },
809                paths: Paths::default(),
810                ..Default::default()
811            },
812            ElixirDtoStyle::Typespecs,
813        );
814
815        assert_eq!(generator.root_module_name(), "ExampleServiceApi");
816    }
817
818    #[test]
819    fn converts_openapi_paths_to_spikard_paths() {
820        let generator = ElixirGenerator::new(OpenAPI::default(), ElixirDtoStyle::Typespecs);
821        assert_eq!(
822            generator.route_path("/users/{id}/posts/{post_id}"),
823            "/users/:id/posts/:post_id"
824        );
825    }
826}