Skip to main content

spikard_cli/codegen/
ruby.rs

1//! Ruby code generation from `OpenAPI` schemas
2
3use super::RubyDtoStyle;
4use anyhow::Result;
5use heck::{ToPascalCase, ToSnakeCase};
6use openapiv3::{
7    OpenAPI, Operation, Parameter, ParameterSchemaOrContent, ReferenceOr, Schema, SchemaKind, StringFormat, Type,
8    VariantOrUnknownOrEmpty,
9};
10use std::collections::BTreeSet;
11
12pub struct RubyGenerator {
13    spec: OpenAPI,
14    dto: RubyDtoStyle,
15}
16
17impl RubyGenerator {
18    #[must_use]
19    pub const fn new(spec: OpenAPI, dto: RubyDtoStyle) -> Self {
20        Self { spec, dto }
21    }
22
23    pub fn generate(&self) -> Result<String> {
24        let mut output = String::new();
25
26        output.push_str(&self.generate_header());
27
28        output.push_str(&self.generate_models()?);
29
30        output.push_str(&self.generate_routes()?);
31
32        output.push_str(&self.generate_main());
33
34        Ok(output)
35    }
36
37    fn generate_header(&self) -> String {
38        match self.dto {
39            RubyDtoStyle::DrySchema => format!(
40                r"# frozen_string_literal: true
41# rubocop:disable all
42
43# Generated by Spikard OpenAPI code generator
44# OpenAPI Version: {}
45# Title: {}
46# DO NOT EDIT - regenerate from OpenAPI schema
47
48require 'sinatra/base'
49require 'json'
50require 'date'
51
52begin
53  require 'dry-struct'
54  require 'dry-types'
55rescue LoadError
56  puts 'Warning: dry-struct and dry-types not found. Install with: gem install dry-struct dry-types'
57end
58
59# Type definitions module
60module Types
61  include Dry.Types() if defined?(Dry)
62
63  UUID = Types::Strict::String
64         .constrained(format: /\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/)
65
66  ISODate = Types::Params::Date
67  ISODateTime = Types::Params::DateTime
68end
69
70",
71                self.spec.openapi, self.spec.info.title
72            ),
73        }
74    }
75
76    fn generate_models(&self) -> Result<String> {
77        let mut output = String::new();
78        output.push_str("# Schema Models\n\n");
79        let mut emitted = BTreeSet::new();
80
81        if let Some(components) = &self.spec.components {
82            for (name, schema_ref) in &components.schemas {
83                match schema_ref {
84                    ReferenceOr::Item(schema) => {
85                        self.generate_model_family(&name.to_pascal_case(), schema, &mut emitted, &mut output)?;
86                    }
87                    ReferenceOr::Reference { .. } => {
88                        continue;
89                    }
90                }
91            }
92        }
93
94        for (path, path_item_ref) in &self.spec.paths.paths {
95            let path_item = match path_item_ref {
96                ReferenceOr::Item(item) => item,
97                ReferenceOr::Reference { .. } => continue,
98            };
99
100            for (method, operation) in [
101                ("get", path_item.get.as_ref()),
102                ("post", path_item.post.as_ref()),
103                ("put", path_item.put.as_ref()),
104                ("delete", path_item.delete.as_ref()),
105                ("patch", path_item.patch.as_ref()),
106            ] {
107                let Some(operation) = operation else {
108                    continue;
109                };
110
111                if let Some((class_name, schema)) = self.inline_request_body_model(operation, method, path) {
112                    self.generate_model_family(&class_name, schema, &mut emitted, &mut output)?;
113                }
114
115                if let Some((class_name, schema)) = self.inline_response_model(operation, method, path) {
116                    self.generate_model_family(&class_name, schema, &mut emitted, &mut output)?;
117                }
118            }
119        }
120
121        Ok(output)
122    }
123
124    fn generate_model_class(&self, class_name: &str, schema: &Schema) -> Result<String> {
125        let mut output = String::new();
126
127        if let Some(description) = &schema.schema_data.description {
128            for line in description.lines() {
129                if line.trim().is_empty() {
130                    output.push_str("#\n");
131                } else {
132                    output.push_str(&format!("# {}\n", line.trim_end()));
133                }
134            }
135        } else {
136            output.push_str(&format!("# {class_name} model\n"));
137        }
138
139        output.push_str(&format!("class {class_name} < Dry::Struct\n"));
140
141        match &schema.schema_kind {
142            SchemaKind::Type(Type::Object(obj)) => {
143                if obj.properties.is_empty() {
144                    output.push_str("  # Empty schema\n");
145                } else {
146                    for (prop_name, prop_schema_ref) in &obj.properties {
147                        let is_required = obj.required.contains(prop_name);
148                        let field_name = prop_name.to_snake_case();
149
150                        let type_hint = match prop_schema_ref {
151                            ReferenceOr::Item(prop_schema) => self.schema_to_ruby_type(
152                                Some(class_name),
153                                Some(prop_name),
154                                prop_schema,
155                                !is_required,
156                                None,
157                            ),
158                            ReferenceOr::Reference { reference } => {
159                                let ref_name = reference.split('/').next_back().unwrap();
160                                if is_required {
161                                    ref_name.to_pascal_case()
162                                } else {
163                                    format!("Types.Instance({}).optional", ref_name.to_pascal_case())
164                                }
165                            }
166                        };
167
168                        self.append_attribute_line(&mut output, &field_name, &type_hint);
169                    }
170                }
171            }
172            _ => {
173                output.push_str("  # Unsupported schema type\n");
174            }
175        }
176
177        output.push_str("end\n");
178
179        Ok(output)
180    }
181
182    fn append_attribute_line(&self, output: &mut String, field_name: &str, type_hint: &str) {
183        let single_line = format!("  attribute :{field_name}, {type_hint}\n");
184        if single_line.len() <= 118 {
185            output.push_str(&single_line);
186            return;
187        }
188
189        output.push_str("  attribute(\n");
190        output.push_str(&format!("    :{field_name},\n"));
191        output.push_str(&format!("    {type_hint}\n"));
192        output.push_str("  )\n");
193    }
194
195    fn generate_model_family(
196        &self,
197        class_name: &str,
198        schema: &Schema,
199        emitted: &mut BTreeSet<String>,
200        output: &mut String,
201    ) -> Result<()> {
202        if !emitted.insert(class_name.to_string()) {
203            return Ok(());
204        }
205
206        self.generate_nested_model_families(class_name, schema, emitted, output)?;
207        output.push_str(&self.generate_model_class(class_name, schema)?);
208        output.push('\n');
209        Ok(())
210    }
211
212    fn generate_nested_model_families(
213        &self,
214        parent_class_name: &str,
215        schema: &Schema,
216        emitted: &mut BTreeSet<String>,
217        output: &mut String,
218    ) -> Result<()> {
219        match &schema.schema_kind {
220            SchemaKind::Type(Type::Object(obj)) => {
221                for (prop_name, prop_schema_ref) in &obj.properties {
222                    if let ReferenceOr::Item(prop_schema) = prop_schema_ref {
223                        if let Some(class_name) = self.inline_model_name(parent_class_name, prop_name, prop_schema) {
224                            self.generate_model_family(&class_name, prop_schema, emitted, output)?;
225                        }
226                        if let Some(array_item_name) =
227                            self.inline_array_item_model_name(parent_class_name, prop_name, prop_schema)
228                            && let Some(item_schema) = Self::inline_array_item_schema(prop_schema)
229                        {
230                            self.generate_model_family(&array_item_name, item_schema, emitted, output)?;
231                        }
232                    }
233                }
234            }
235            SchemaKind::AllOf { all_of } => {
236                for schema_ref in all_of {
237                    match schema_ref {
238                        ReferenceOr::Item(item_schema) => {
239                            self.generate_nested_model_families(parent_class_name, item_schema, emitted, output)?
240                        }
241                        ReferenceOr::Reference { .. } => {}
242                    }
243                }
244            }
245            _ => {}
246        }
247
248        Ok(())
249    }
250
251    /// Extract type name from a schema reference or inline schema
252    fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>) -> String {
253        match schema_ref {
254            ReferenceOr::Reference { reference } => {
255                let ref_name = reference.split('/').next_back().unwrap();
256                ref_name.to_pascal_case()
257            }
258            ReferenceOr::Item(schema) => self.schema_to_ruby_return_type(None, None, schema, None),
259        }
260    }
261
262    /// Extract request body type from operation
263    fn extract_request_body_type(&self, operation: &Operation, method: &str, path: &str) -> Option<String> {
264        operation.request_body.as_ref().and_then(|body_ref| match body_ref {
265            ReferenceOr::Item(request_body) => request_body.content.get("application/json").and_then(|media_type| {
266                media_type.schema.as_ref().map(|schema_ref| match schema_ref {
267                    ReferenceOr::Item(schema) if Self::should_generate_inline_model(schema) => {
268                        self.inline_request_body_name(operation, method, path)
269                    }
270                    _ => self.extract_type_from_schema_ref(schema_ref),
271                })
272            }),
273            ReferenceOr::Reference { reference } => {
274                let ref_name = reference.split('/').next_back().unwrap();
275                Some(ref_name.to_pascal_case())
276            }
277        })
278    }
279
280    /// Extract response type from operation (looks for 200/201 responses)
281    fn extract_response_type(&self, operation: &Operation, method: &str, path: &str) -> String {
282        use openapiv3::StatusCode;
283
284        let response = operation
285            .responses
286            .responses
287            .get(&StatusCode::Code(200))
288            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
289            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
290
291        if let Some(response_ref) = response {
292            match response_ref {
293                ReferenceOr::Item(response) => {
294                    if let Some(content) = response.content.get("application/json")
295                        && let Some(schema_ref) = &content.schema
296                    {
297                        return match schema_ref {
298                            ReferenceOr::Item(schema) if Self::should_generate_inline_model(schema) => {
299                                self.inline_response_name(operation, method, path)
300                            }
301                            _ => self.extract_type_from_schema_ref(schema_ref),
302                        };
303                    }
304                }
305                ReferenceOr::Reference { reference } => {
306                    let ref_name = reference.split('/').next_back().unwrap();
307                    return ref_name.to_pascal_case();
308                }
309            }
310        }
311
312        "Hash".to_string()
313    }
314
315    fn schema_to_ruby_type(
316        &self,
317        parent_class_name: Option<&str>,
318        field_name: Option<&str>,
319        schema: &Schema,
320        optional: bool,
321        inline_name: Option<&str>,
322    ) -> String {
323        let base_type = if let Some(enum_type) = self.string_enum_ruby_type(schema) {
324            enum_type
325        } else {
326            match &schema.schema_kind {
327                SchemaKind::Type(Type::String(string_type)) => self.string_format_ruby_type(string_type),
328                SchemaKind::Type(Type::Number(_)) => "Types::Strict::Float".to_string(),
329                SchemaKind::Type(Type::Integer(_)) => "Types::Strict::Integer".to_string(),
330                SchemaKind::Type(Type::Boolean(_)) => "Types::Strict::Bool".to_string(),
331                SchemaKind::Type(Type::Array(arr)) => {
332                    let item_type = match &arr.items {
333                        Some(ReferenceOr::Item(item_schema)) => self.schema_to_ruby_type(
334                            None,
335                            None,
336                            item_schema,
337                            false,
338                            parent_class_name
339                                .zip(field_name)
340                                .and_then(|(parent, field)| self.inline_array_item_model_name(parent, field, schema))
341                                .as_deref(),
342                        ),
343                        Some(ReferenceOr::Reference { reference }) => {
344                            let ref_name = reference.split('/').next_back().unwrap();
345                            format!("Types.Instance({})", ref_name.to_pascal_case())
346                        }
347                        None => "Types::Any".to_string(),
348                    };
349                    format!("Types::Strict::Array.of({item_type})")
350                }
351                SchemaKind::Type(Type::Object(obj)) => {
352                    if obj.properties.is_empty() {
353                        "Types::Strict::Hash".to_string()
354                    } else {
355                        inline_name
356                            .map(|name| format!("Types.Instance({name})"))
357                            .or_else(|| {
358                                parent_class_name.zip(field_name).map(|(parent, field)| {
359                                    format!("Types.Instance({parent}{})", field.to_pascal_case())
360                                })
361                            })
362                            .unwrap_or_else(|| {
363                                let mut entries = Vec::new();
364                                for (prop_name, prop_schema_ref) in &obj.properties {
365                                    let key = prop_name.to_snake_case();
366                                    let is_required = obj.required.contains(prop_name);
367                                    let prop_type = match prop_schema_ref {
368                                        ReferenceOr::Item(prop_schema) => self.schema_to_ruby_type(
369                                            parent_class_name,
370                                            Some(prop_name),
371                                            prop_schema,
372                                            !is_required,
373                                            None,
374                                        ),
375                                        ReferenceOr::Reference { reference } => {
376                                            let ref_name = reference.split('/').next_back().unwrap().to_pascal_case();
377                                            if is_required {
378                                                format!("Types.Instance({ref_name})")
379                                            } else {
380                                                format!("Types.Instance({ref_name}).optional")
381                                            }
382                                        }
383                                    };
384                                    entries.push(format!("{key}: {prop_type}"));
385                                }
386                                format!("Types::Hash.schema({})", entries.join(", "))
387                            })
388                    }
389                }
390                _ => "Types::Any".to_string(),
391            }
392        };
393
394        if optional {
395            format!("{base_type}.optional")
396        } else {
397            base_type
398        }
399    }
400
401    fn string_enum_ruby_type(&self, schema: &Schema) -> Option<String> {
402        let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind else {
403            return None;
404        };
405        let values = string_type
406            .enumeration
407            .iter()
408            .flatten()
409            .map(|value| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")))
410            .collect::<Vec<_>>();
411        (!values.is_empty()).then(|| format!("Types::Strict::String.enum({})", values.join(", ")))
412    }
413
414    fn string_format_ruby_type(&self, string_type: &openapiv3::StringType) -> String {
415        match &string_type.format {
416            VariantOrUnknownOrEmpty::Item(StringFormat::Date) => "Types::ISODate".to_string(),
417            VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => "Types::ISODateTime".to_string(),
418            VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "Types::UUID".to_string(),
419            _ => "Types::Strict::String".to_string(),
420        }
421    }
422
423    fn schema_to_ruby_return_type(
424        &self,
425        parent_class_name: Option<&str>,
426        field_name: Option<&str>,
427        schema: &Schema,
428        inline_name: Option<&str>,
429    ) -> String {
430        match &schema.schema_kind {
431            SchemaKind::Type(Type::String(string_type)) => match &string_type.format {
432                VariantOrUnknownOrEmpty::Item(StringFormat::Date) => "Date".to_string(),
433                VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => "DateTime".to_string(),
434                _ => "String".to_string(),
435            },
436            SchemaKind::Type(Type::Number(_)) => "Float".to_string(),
437            SchemaKind::Type(Type::Integer(_)) => "Integer".to_string(),
438            SchemaKind::Type(Type::Boolean(_)) => "Boolean".to_string(),
439            SchemaKind::Type(Type::Array(arr)) => {
440                let item_type = match &arr.items {
441                    Some(ReferenceOr::Item(item_schema)) => self.schema_to_ruby_return_type(
442                        None,
443                        None,
444                        item_schema,
445                        parent_class_name
446                            .zip(field_name)
447                            .and_then(|(parent, field)| self.inline_array_item_model_name(parent, field, schema))
448                            .as_deref(),
449                    ),
450                    Some(ReferenceOr::Reference { reference }) => {
451                        let ref_name = reference.split('/').next_back().unwrap();
452                        ref_name.to_pascal_case()
453                    }
454                    None => "Object".to_string(),
455                };
456                format!("Array<{item_type}>")
457            }
458            SchemaKind::Type(Type::Object(obj)) => {
459                if obj.properties.is_empty() {
460                    "Hash".to_string()
461                } else {
462                    inline_name
463                        .map(ToOwned::to_owned)
464                        .or_else(|| {
465                            parent_class_name
466                                .zip(field_name)
467                                .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
468                        })
469                        .unwrap_or_else(|| {
470                            let mut value_types = Vec::new();
471                            for prop_schema_ref in obj.properties.values() {
472                                let prop_type = match prop_schema_ref {
473                                    ReferenceOr::Item(prop_schema) => {
474                                        self.schema_to_ruby_return_type(None, None, prop_schema, None)
475                                    }
476                                    ReferenceOr::Reference { reference } => {
477                                        reference.split('/').next_back().unwrap().to_pascal_case()
478                                    }
479                                };
480                                if !value_types.contains(&prop_type) {
481                                    value_types.push(prop_type);
482                                }
483                            }
484                            let union = if value_types.is_empty() {
485                                "Object".to_string()
486                            } else {
487                                value_types.join(", ")
488                            };
489                            format!("Hash{{Symbol => ({union})}}")
490                        })
491                }
492            }
493            _ => "Object".to_string(),
494        }
495    }
496
497    fn generate_routes(&self) -> Result<String> {
498        let mut output = String::new();
499        output.push_str("# API Application\n");
500        output.push_str("class API < Sinatra::Base\n");
501        output.push_str("  # Configure JSON content type by default\n");
502        output.push_str("  before do\n");
503        output.push_str("    content_type :json\n");
504        output.push_str("  end\n\n");
505        output.push_str(&self.generate_route_helpers());
506
507        for (path, path_item_ref) in &self.spec.paths.paths {
508            let path_item = match path_item_ref {
509                ReferenceOr::Item(item) => item,
510                ReferenceOr::Reference { .. } => continue,
511            };
512
513            if let Some(op) = &path_item.get {
514                output.push_str(&self.generate_route_handler(path, "get", op)?);
515            }
516            if let Some(op) = &path_item.post {
517                output.push_str(&self.generate_route_handler(path, "post", op)?);
518            }
519            if let Some(op) = &path_item.put {
520                output.push_str(&self.generate_route_handler(path, "put", op)?);
521            }
522            if let Some(op) = &path_item.delete {
523                output.push_str(&self.generate_route_handler(path, "delete", op)?);
524            }
525            if let Some(op) = &path_item.patch {
526                output.push_str(&self.generate_route_handler(path, "patch", op)?);
527            }
528        }
529
530        output.push_str("end\n");
531
532        Ok(output)
533    }
534
535    fn generate_route_helpers(&self) -> String {
536        r#"  private
537
538  def invalid_parameter!(name, message)
539    halt 400, { error: 'Invalid parameter', parameter: name, message: message }.to_json
540  end
541
542  def coerce_integer_param!(value, name)
543    Integer(value, 10)
544  rescue ArgumentError, TypeError
545    invalid_parameter!(name, 'must be an integer')
546  end
547
548  def coerce_float_param!(value, name)
549    Float(value)
550  rescue ArgumentError, TypeError
551    invalid_parameter!(name, 'must be a float')
552  end
553
554  def coerce_boolean_param!(value, name)
555    case value
556    when true, 'true', '1', 1 then true
557    when false, 'false', '0', 0 then false
558    else
559      invalid_parameter!(name, 'must be a boolean')
560    end
561  end
562
563  def coerce_date_param!(value, name)
564    Date.iso8601(value)
565  rescue ArgumentError, TypeError
566    invalid_parameter!(name, 'must be an ISO 8601 date')
567  end
568
569  def coerce_datetime_param!(value, name)
570    DateTime.iso8601(value)
571  rescue ArgumentError, TypeError
572    invalid_parameter!(name, 'must be an ISO 8601 date-time')
573  end
574
575  def coerce_uuid_param!(value, name)
576    pattern = /\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/
577    return value if pattern.match?(value.to_s)
578
579    invalid_parameter!(name, 'must be a UUID')
580  end
581
582  def coerce_enum_param!(value, name, allowed)
583    return value if allowed.include?(value)
584
585    invalid_parameter!(name, "must be one of: #{allowed.join(', ')}")
586  end
587
588"#
589        .to_string()
590    }
591
592    fn generate_route_handler(&self, path: &str, method: &str, operation: &Operation) -> Result<String> {
593        let mut output = String::new();
594
595        let sinatra_path = path.replace('{', ":").replace('}', "");
596
597        if let Some(summary) = &operation.summary {
598            output.push_str(&format!("  # {summary}\n"));
599        } else {
600            output.push_str(&format!("  # {} {}\n", method.to_uppercase(), path));
601        }
602
603        if let Some(description) = &operation.description {
604            for line in description.lines() {
605                if line.trim().is_empty() {
606                    output.push_str("  #\n");
607                } else {
608                    output.push_str(&format!("  # {}\n", line.trim_end()));
609                }
610            }
611        }
612
613        for param_ref in &operation.parameters {
614            if let ReferenceOr::Item(param) = param_ref {
615                match param {
616                    Parameter::Path {
617                        parameter_data,
618                        style: _,
619                        ..
620                    } => {
621                        let param_type = self.parameter_doc_type(param, false);
622                        let detail = self.parameter_detail(param, false);
623                        output.push_str(&format!(
624                            "  # @param {} [{}] {}\n",
625                            parameter_data.name.to_snake_case(),
626                            param_type,
627                            detail
628                        ));
629                    }
630                    Parameter::Query {
631                        parameter_data,
632                        style: _,
633                        allow_reserved: _,
634                        allow_empty_value: _,
635                        ..
636                    } => {
637                        let param_type = self.parameter_doc_type(param, !parameter_data.required);
638                        let detail = self.parameter_detail(param, true);
639                        output.push_str(&format!(
640                            "  # @param {} [{}] {}\n",
641                            parameter_data.name.to_snake_case(),
642                            param_type,
643                            detail
644                        ));
645                    }
646                    _ => {}
647                }
648            }
649        }
650
651        let body_type = self.extract_request_body_type(operation, method, path);
652        if body_type.is_some() {
653            output.push_str(&format!(
654                "  # @param body [{}] Request body\n",
655                body_type.as_deref().unwrap_or("Hash")
656            ));
657        }
658
659        let return_type = self.extract_response_type(operation, method, path);
660        output.push_str(&format!("  # @return [{return_type}] Response body\n"));
661
662        output.push_str(&format!("  {method} '{sinatra_path}' do\n"));
663
664        let parameter_bindings = self.generate_parameter_bindings(operation);
665        if !parameter_bindings.is_empty() {
666            output.push_str(&parameter_bindings);
667            output.push('\n');
668        }
669
670        if let Some(bt) = body_type {
671            output.push_str("    # Parse and validate request body\n");
672            output.push_str("    # TODO: body_data = JSON.parse(request.body.read)\n");
673            output.push_str("    # TODO: body = ");
674            output.push_str(&bt);
675            output.push_str(".new(body_data)\n\n");
676        }
677
678        // Generate TODO implementation
679        output.push_str("    # TODO: Implement this endpoint\n");
680
681        match method {
682            "get" => {
683                if return_type.starts_with("Array") {
684                    output.push_str("    [].to_json\n");
685                } else {
686                    output.push_str("    {}.to_json\n");
687                }
688            }
689            "post" | "put" | "patch" => {
690                output.push_str("    status 201\n");
691                output.push_str("    {}.to_json\n");
692            }
693            "delete" => {
694                output.push_str("    status 204\n");
695                output.push_str("    ''\n");
696            }
697            _ => {
698                output.push_str("    {}.to_json\n");
699            }
700        }
701
702        output.push_str("  end\n");
703
704        Ok(output)
705    }
706
707    fn generate_parameter_bindings(&self, operation: &Operation) -> String {
708        let mut output = String::new();
709
710        for param_ref in &operation.parameters {
711            let ReferenceOr::Item(param) = param_ref else {
712                continue;
713            };
714
715            let Some(binding) = self.parameter_binding_line(param) else {
716                continue;
717            };
718
719            output.push_str("    ");
720            output.push_str(&binding);
721            output.push('\n');
722        }
723
724        output
725    }
726
727    fn parameter_binding_line(&self, parameter: &Parameter) -> Option<String> {
728        match parameter {
729            Parameter::Path { parameter_data, .. } => Some(self.required_parameter_binding_line(parameter_data)),
730            Parameter::Query { parameter_data, .. } => Some(self.query_parameter_binding_line(parameter_data)),
731            _ => None,
732        }
733    }
734
735    fn required_parameter_binding_line(&self, parameter_data: &openapiv3::ParameterData) -> String {
736        let variable_name = format!("_{}", parameter_data.name.to_snake_case());
737        let value_expr = format!("params.fetch('{}')", parameter_data.name);
738        let coercion = self.parameter_coercion_expr(parameter_data, &value_expr);
739        format!("{variable_name} = {coercion}")
740    }
741
742    fn query_parameter_binding_line(&self, parameter_data: &openapiv3::ParameterData) -> String {
743        let variable_name = format!("_{}", parameter_data.name.to_snake_case());
744        if parameter_data.required {
745            let value_expr = format!("params.fetch('{}')", parameter_data.name);
746            let coercion = self.parameter_coercion_expr(parameter_data, &value_expr);
747            format!("{variable_name} = {coercion}")
748        } else {
749            let value_expr = format!("params['{}']", parameter_data.name);
750            let coercion = self.parameter_coercion_expr(parameter_data, &value_expr);
751            format!(
752                "{variable_name} = params.key?('{}') ? {coercion} : nil",
753                parameter_data.name
754            )
755        }
756    }
757
758    fn parameter_coercion_expr(&self, parameter_data: &openapiv3::ParameterData, value_expr: &str) -> String {
759        match &parameter_data.format {
760            ParameterSchemaOrContent::Schema(schema_ref) => {
761                self.schema_param_coercion_expr(schema_ref, value_expr, &parameter_data.name)
762            }
763            ParameterSchemaOrContent::Content(_) => value_expr.to_string(),
764        }
765    }
766
767    fn schema_param_coercion_expr(&self, schema_ref: &ReferenceOr<Schema>, value_expr: &str, name: &str) -> String {
768        match schema_ref {
769            ReferenceOr::Item(schema) => self.inline_schema_param_coercion_expr(schema, value_expr, name),
770            ReferenceOr::Reference { reference } => self
771                .resolve_schema_reference(reference)
772                .map(|schema| self.inline_schema_param_coercion_expr(schema, value_expr, name))
773                .unwrap_or_else(|| value_expr.to_string()),
774        }
775    }
776
777    fn inline_schema_param_coercion_expr(&self, schema: &Schema, value_expr: &str, name: &str) -> String {
778        match &schema.schema_kind {
779            SchemaKind::Type(Type::String(string_type)) => {
780                let enum_values = string_type
781                    .enumeration
782                    .iter()
783                    .flatten()
784                    .map(|value| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")))
785                    .collect::<Vec<_>>();
786
787                if !enum_values.is_empty() {
788                    return format!(
789                        "coerce_enum_param!({value_expr}, '{name}', [{}])",
790                        enum_values.join(", ")
791                    );
792                }
793
794                match &string_type.format {
795                    VariantOrUnknownOrEmpty::Item(StringFormat::Date) => {
796                        format!("coerce_date_param!({value_expr}, '{name}')")
797                    }
798                    VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => {
799                        format!("coerce_datetime_param!({value_expr}, '{name}')")
800                    }
801                    VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => {
802                        format!("coerce_uuid_param!({value_expr}, '{name}')")
803                    }
804                    _ => value_expr.to_string(),
805                }
806            }
807            SchemaKind::Type(Type::Integer(_)) => format!("coerce_integer_param!({value_expr}, '{name}')"),
808            SchemaKind::Type(Type::Number(_)) => format!("coerce_float_param!({value_expr}, '{name}')"),
809            SchemaKind::Type(Type::Boolean(_)) => format!("coerce_boolean_param!({value_expr}, '{name}')"),
810            _ => value_expr.to_string(),
811        }
812    }
813
814    fn generate_main(&self) -> String {
815        r"
816# Run the application
817# Usage: ruby generated_api.rb
818# Or use with config.ru for Rack-based deployment
819API.run!(host: '0.0.0.0', port: 4567) if __FILE__ == $PROGRAM_NAME
820
821# For Rack-based deployment (config.ru):
822# run API
823"
824        .to_string()
825    }
826
827    fn parameter_doc_type(&self, parameter: &Parameter, optional: bool) -> String {
828        match parameter {
829            Parameter::Path { parameter_data, .. }
830            | Parameter::Query { parameter_data, .. }
831            | Parameter::Header { parameter_data, .. }
832            | Parameter::Cookie { parameter_data, .. } => match &parameter_data.format {
833                ParameterSchemaOrContent::Schema(schema_ref) => {
834                    let base_type = match schema_ref {
835                        ReferenceOr::Item(schema) => self.schema_to_ruby_return_type(None, None, schema, None),
836                        ReferenceOr::Reference { reference } => self
837                            .resolve_schema_reference(reference)
838                            .map(|schema| self.schema_to_ruby_return_type(None, None, schema, None))
839                            .unwrap_or_else(|| reference.split('/').next_back().unwrap().to_pascal_case()),
840                    };
841                    if optional {
842                        format!("{base_type}, nil")
843                    } else {
844                        base_type
845                    }
846                }
847                ParameterSchemaOrContent::Content(_) => {
848                    if optional {
849                        "Object, nil".to_string()
850                    } else {
851                        "Object".to_string()
852                    }
853                }
854            },
855        }
856    }
857
858    fn parameter_detail(&self, parameter: &Parameter, query: bool) -> String {
859        let (parameter_data, required_suffix) = match parameter {
860            Parameter::Path { parameter_data, .. } => (parameter_data, String::new()),
861            Parameter::Query { parameter_data, .. } => {
862                let suffix = if parameter_data.required {
863                    "required".to_string()
864                } else {
865                    "optional".to_string()
866                };
867                (parameter_data, suffix)
868            }
869            Parameter::Header { parameter_data, .. } | Parameter::Cookie { parameter_data, .. } => {
870                (parameter_data, String::new())
871            }
872        };
873
874        let mut details = Vec::new();
875        if let Some(constraint) = self.parameter_constraint(parameter_data) {
876            details.push(constraint);
877        }
878        if query {
879            details.push(required_suffix);
880        }
881
882        let label = if query { "Query parameter" } else { "Path parameter" };
883        if details.is_empty() {
884            label.to_string()
885        } else {
886            format!("{label} ({})", details.join("; "))
887        }
888    }
889
890    fn parameter_constraint(&self, parameter_data: &openapiv3::ParameterData) -> Option<String> {
891        let ParameterSchemaOrContent::Schema(schema_ref) = &parameter_data.format else {
892            return None;
893        };
894
895        let schema = match schema_ref {
896            ReferenceOr::Item(schema) => schema,
897            ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference)?,
898        };
899
900        let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind else {
901            return None;
902        };
903
904        if !string_type.enumeration.is_empty() {
905            let values = string_type.enumeration.iter().flatten().cloned().collect::<Vec<_>>();
906            return Some(format!("enum: {}", values.join(", ")));
907        }
908
909        match &string_type.format {
910            VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => Some("UUID".to_string()),
911            _ => None,
912        }
913    }
914
915    fn resolve_schema_reference<'a>(&'a self, reference: &str) -> Option<&'a Schema> {
916        let name = reference.split('/').next_back()?;
917        self.spec
918            .components
919            .as_ref()?
920            .schemas
921            .get(name)
922            .and_then(|schema_ref| match schema_ref {
923                ReferenceOr::Item(schema) => Some(schema),
924                ReferenceOr::Reference { .. } => None,
925            })
926    }
927
928    fn should_generate_inline_model(schema: &Schema) -> bool {
929        matches!(&schema.schema_kind, SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty())
930    }
931
932    fn inline_model_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
933        match &schema.schema_kind {
934            SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
935                Some(format!("{parent_class_name}{}", field_name.to_pascal_case()))
936            }
937            _ => None,
938        }
939    }
940
941    fn inline_array_item_model_name(
942        &self,
943        parent_class_name: &str,
944        field_name: &str,
945        schema: &Schema,
946    ) -> Option<String> {
947        let item_schema = Self::inline_array_item_schema(schema)?;
948        match &item_schema.schema_kind {
949            SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
950                Some(format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
951            }
952            _ => None,
953        }
954    }
955
956    fn inline_array_item_schema(schema: &Schema) -> Option<&Schema> {
957        match &schema.schema_kind {
958            SchemaKind::Type(Type::Array(array_type)) => match &array_type.items {
959                Some(ReferenceOr::Item(item_schema)) => Some(item_schema),
960                _ => None,
961            },
962            _ => None,
963        }
964    }
965
966    fn inline_request_body_model<'a>(
967        &self,
968        operation: &'a Operation,
969        method: &str,
970        path: &str,
971    ) -> Option<(String, &'a Schema)> {
972        let body_ref = operation.request_body.as_ref()?;
973        let ReferenceOr::Item(request_body) = body_ref else {
974            return None;
975        };
976        let media_type = request_body.content.get("application/json")?;
977        let schema_ref = media_type.schema.as_ref()?;
978        let ReferenceOr::Item(schema) = schema_ref else {
979            return None;
980        };
981        if Self::should_generate_inline_model(schema) {
982            Some((self.inline_request_body_name(operation, method, path), schema))
983        } else {
984            None
985        }
986    }
987
988    fn inline_response_model<'a>(
989        &self,
990        operation: &'a Operation,
991        method: &str,
992        path: &str,
993    ) -> Option<(String, &'a Schema)> {
994        use openapiv3::StatusCode;
995
996        let response_ref = operation
997            .responses
998            .responses
999            .get(&StatusCode::Code(200))
1000            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
1001            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)))?;
1002
1003        let ReferenceOr::Item(response) = response_ref else {
1004            return None;
1005        };
1006        let media_type = response.content.get("application/json")?;
1007        let schema_ref = media_type.schema.as_ref()?;
1008        let ReferenceOr::Item(schema) = schema_ref else {
1009            return None;
1010        };
1011        if Self::should_generate_inline_model(schema) {
1012            Some((self.inline_response_name(operation, method, path), schema))
1013        } else {
1014            None
1015        }
1016    }
1017
1018    fn inline_request_body_name(&self, operation: &Operation, method: &str, path: &str) -> String {
1019        format!("{}RequestBody", self.operation_model_stem(operation, method, path))
1020    }
1021
1022    fn inline_response_name(&self, operation: &Operation, method: &str, path: &str) -> String {
1023        format!("{}ResponseBody", self.operation_model_stem(operation, method, path))
1024    }
1025
1026    fn operation_model_stem(&self, operation: &Operation, method: &str, path: &str) -> String {
1027        operation
1028            .operation_id
1029            .as_ref()
1030            .map(|id| id.to_pascal_case())
1031            .unwrap_or_else(|| {
1032                format!(
1033                    "{}{}",
1034                    method.to_pascal_case(),
1035                    path.split('/')
1036                        .filter(|segment| !segment.is_empty())
1037                        .map(|segment| segment.trim_matches(|c| c == '{' || c == '}').to_pascal_case())
1038                        .collect::<String>()
1039                )
1040            })
1041    }
1042}