1use 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) = ¶meter_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(¶meter_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 ¶meter_data.format {
384 ParameterSchemaOrContent::Schema(schema_ref) => {
385 self.schema_param_coercion_expr(schema_ref, access, ¶meter_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) = ¶meter_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}