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