use super::RubyDtoStyle;
use anyhow::Result;
use heck::{ToPascalCase, ToSnakeCase};
use openapiv3::{
OpenAPI, Operation, Parameter, ParameterSchemaOrContent, ReferenceOr, Schema, SchemaKind, StringFormat, Type,
VariantOrUnknownOrEmpty,
};
use std::collections::BTreeSet;
pub struct RubyGenerator {
spec: OpenAPI,
dto: RubyDtoStyle,
}
impl RubyGenerator {
#[must_use]
pub const fn new(spec: OpenAPI, dto: RubyDtoStyle) -> Self {
Self { spec, dto }
}
pub fn generate(&self) -> Result<String> {
let mut output = String::new();
output.push_str(&self.generate_header());
output.push_str(&self.generate_models()?);
output.push_str(&self.generate_routes()?);
output.push_str(&self.generate_main());
Ok(output)
}
fn generate_header(&self) -> String {
match self.dto {
RubyDtoStyle::DrySchema => format!(
r"# frozen_string_literal: true
# rubocop:disable all
# Generated by Spikard OpenAPI code generator
# OpenAPI Version: {}
# Title: {}
# DO NOT EDIT - regenerate from OpenAPI schema
require 'sinatra/base'
require 'json'
require 'date'
begin
require 'dry-struct'
require 'dry-types'
rescue LoadError
puts 'Warning: dry-struct and dry-types not found. Install with: gem install dry-struct dry-types'
end
# Type definitions module
module Types
include Dry.Types() if defined?(Dry)
UUID = Types::Strict::String
.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/)
ISODate = Types::Params::Date
ISODateTime = Types::Params::DateTime
end
",
self.spec.openapi, self.spec.info.title
),
}
}
fn generate_models(&self) -> Result<String> {
let mut output = String::new();
output.push_str("# Schema Models\n\n");
let mut emitted = BTreeSet::new();
if let Some(components) = &self.spec.components {
for (name, schema_ref) in &components.schemas {
match schema_ref {
ReferenceOr::Item(schema) => {
self.generate_model_family(&name.to_pascal_case(), schema, &mut emitted, &mut output)?;
}
ReferenceOr::Reference { .. } => {
continue;
}
}
}
}
for (path, path_item_ref) in &self.spec.paths.paths {
let path_item = match path_item_ref {
ReferenceOr::Item(item) => item,
ReferenceOr::Reference { .. } => continue,
};
for (method, operation) in [
("get", path_item.get.as_ref()),
("post", path_item.post.as_ref()),
("put", path_item.put.as_ref()),
("delete", path_item.delete.as_ref()),
("patch", path_item.patch.as_ref()),
] {
let Some(operation) = operation else {
continue;
};
if let Some((class_name, schema)) = self.inline_request_body_model(operation, method, path) {
self.generate_model_family(&class_name, schema, &mut emitted, &mut output)?;
}
if let Some((class_name, schema)) = self.inline_response_model(operation, method, path) {
self.generate_model_family(&class_name, schema, &mut emitted, &mut output)?;
}
}
}
Ok(output)
}
fn generate_model_class(&self, class_name: &str, schema: &Schema) -> Result<String> {
let mut output = String::new();
if let Some(description) = &schema.schema_data.description {
for line in description.lines() {
if line.trim().is_empty() {
output.push_str("#\n");
} else {
output.push_str(&format!("# {}\n", line.trim_end()));
}
}
} else {
output.push_str(&format!("# {class_name} model\n"));
}
output.push_str(&format!("class {class_name} < Dry::Struct\n"));
match &schema.schema_kind {
SchemaKind::Type(Type::Object(obj)) => {
if obj.properties.is_empty() {
output.push_str(" # Empty schema\n");
} else {
for (prop_name, prop_schema_ref) in &obj.properties {
let is_required = obj.required.contains(prop_name);
let field_name = prop_name.to_snake_case();
let type_hint = match prop_schema_ref {
ReferenceOr::Item(prop_schema) => self.schema_to_ruby_type(
Some(class_name),
Some(prop_name),
prop_schema,
!is_required,
None,
),
ReferenceOr::Reference { reference } => {
let ref_name = reference.split('/').next_back().unwrap();
if is_required {
ref_name.to_pascal_case()
} else {
format!("Types.Instance({}).optional", ref_name.to_pascal_case())
}
}
};
self.append_attribute_line(&mut output, &field_name, &type_hint);
}
}
}
_ => {
output.push_str(" # Unsupported schema type\n");
}
}
output.push_str("end\n");
Ok(output)
}
fn append_attribute_line(&self, output: &mut String, field_name: &str, type_hint: &str) {
let single_line = format!(" attribute :{field_name}, {type_hint}\n");
if single_line.len() <= 118 {
output.push_str(&single_line);
return;
}
output.push_str(" attribute(\n");
output.push_str(&format!(" :{field_name},\n"));
output.push_str(&format!(" {type_hint}\n"));
output.push_str(" )\n");
}
fn generate_model_family(
&self,
class_name: &str,
schema: &Schema,
emitted: &mut BTreeSet<String>,
output: &mut String,
) -> Result<()> {
if !emitted.insert(class_name.to_string()) {
return Ok(());
}
self.generate_nested_model_families(class_name, schema, emitted, output)?;
output.push_str(&self.generate_model_class(class_name, schema)?);
output.push('\n');
Ok(())
}
fn generate_nested_model_families(
&self,
parent_class_name: &str,
schema: &Schema,
emitted: &mut BTreeSet<String>,
output: &mut String,
) -> Result<()> {
match &schema.schema_kind {
SchemaKind::Type(Type::Object(obj)) => {
for (prop_name, prop_schema_ref) in &obj.properties {
if let ReferenceOr::Item(prop_schema) = prop_schema_ref {
if let Some(class_name) = self.inline_model_name(parent_class_name, prop_name, prop_schema) {
self.generate_model_family(&class_name, prop_schema, emitted, output)?;
}
if let Some(array_item_name) =
self.inline_array_item_model_name(parent_class_name, prop_name, prop_schema)
&& let Some(item_schema) = Self::inline_array_item_schema(prop_schema)
{
self.generate_model_family(&array_item_name, item_schema, emitted, output)?;
}
}
}
}
SchemaKind::AllOf { all_of } => {
for schema_ref in all_of {
match schema_ref {
ReferenceOr::Item(item_schema) => {
self.generate_nested_model_families(parent_class_name, item_schema, emitted, output)?
}
ReferenceOr::Reference { .. } => {}
}
}
}
_ => {}
}
Ok(())
}
fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>) -> String {
match schema_ref {
ReferenceOr::Reference { reference } => {
let ref_name = reference.split('/').next_back().unwrap();
ref_name.to_pascal_case()
}
ReferenceOr::Item(schema) => self.schema_to_ruby_return_type(None, None, schema, None),
}
}
fn extract_request_body_type(&self, operation: &Operation, method: &str, path: &str) -> Option<String> {
operation.request_body.as_ref().and_then(|body_ref| match body_ref {
ReferenceOr::Item(request_body) => request_body.content.get("application/json").and_then(|media_type| {
media_type.schema.as_ref().map(|schema_ref| match schema_ref {
ReferenceOr::Item(schema) if Self::should_generate_inline_model(schema) => {
self.inline_request_body_name(operation, method, path)
}
_ => self.extract_type_from_schema_ref(schema_ref),
})
}),
ReferenceOr::Reference { reference } => {
let ref_name = reference.split('/').next_back().unwrap();
Some(ref_name.to_pascal_case())
}
})
}
fn extract_response_type(&self, operation: &Operation, method: &str, path: &str) -> String {
use openapiv3::StatusCode;
let response = operation
.responses
.responses
.get(&StatusCode::Code(200))
.or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
.or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
if let Some(response_ref) = response {
match response_ref {
ReferenceOr::Item(response) => {
if let Some(content) = response.content.get("application/json")
&& let Some(schema_ref) = &content.schema
{
return match schema_ref {
ReferenceOr::Item(schema) if Self::should_generate_inline_model(schema) => {
self.inline_response_name(operation, method, path)
}
_ => self.extract_type_from_schema_ref(schema_ref),
};
}
}
ReferenceOr::Reference { reference } => {
let ref_name = reference.split('/').next_back().unwrap();
return ref_name.to_pascal_case();
}
}
}
"Hash".to_string()
}
fn schema_to_ruby_type(
&self,
parent_class_name: Option<&str>,
field_name: Option<&str>,
schema: &Schema,
optional: bool,
inline_name: Option<&str>,
) -> String {
let base_type = if let Some(enum_type) = self.string_enum_ruby_type(schema) {
enum_type
} else {
match &schema.schema_kind {
SchemaKind::Type(Type::String(string_type)) => self.string_format_ruby_type(string_type),
SchemaKind::Type(Type::Number(_)) => "Types::Strict::Float".to_string(),
SchemaKind::Type(Type::Integer(_)) => "Types::Strict::Integer".to_string(),
SchemaKind::Type(Type::Boolean(_)) => "Types::Strict::Bool".to_string(),
SchemaKind::Type(Type::Array(arr)) => {
let item_type = match &arr.items {
Some(ReferenceOr::Item(item_schema)) => self.schema_to_ruby_type(
None,
None,
item_schema,
false,
parent_class_name
.zip(field_name)
.and_then(|(parent, field)| self.inline_array_item_model_name(parent, field, schema))
.as_deref(),
),
Some(ReferenceOr::Reference { reference }) => {
let ref_name = reference.split('/').next_back().unwrap();
format!("Types.Instance({})", ref_name.to_pascal_case())
}
None => "Types::Any".to_string(),
};
format!("Types::Strict::Array.of({item_type})")
}
SchemaKind::Type(Type::Object(obj)) => {
if obj.properties.is_empty() {
"Types::Strict::Hash".to_string()
} else {
inline_name
.map(|name| format!("Types.Instance({name})"))
.or_else(|| {
parent_class_name.zip(field_name).map(|(parent, field)| {
format!("Types.Instance({parent}{})", field.to_pascal_case())
})
})
.unwrap_or_else(|| {
let mut entries = Vec::new();
for (prop_name, prop_schema_ref) in &obj.properties {
let key = prop_name.to_snake_case();
let is_required = obj.required.contains(prop_name);
let prop_type = match prop_schema_ref {
ReferenceOr::Item(prop_schema) => self.schema_to_ruby_type(
parent_class_name,
Some(prop_name),
prop_schema,
!is_required,
None,
),
ReferenceOr::Reference { reference } => {
let ref_name = reference.split('/').next_back().unwrap().to_pascal_case();
if is_required {
format!("Types.Instance({ref_name})")
} else {
format!("Types.Instance({ref_name}).optional")
}
}
};
entries.push(format!("{key}: {prop_type}"));
}
format!("Types::Hash.schema({})", entries.join(", "))
})
}
}
_ => "Types::Any".to_string(),
}
};
if optional {
format!("{base_type}.optional")
} else {
base_type
}
}
fn string_enum_ruby_type(&self, schema: &Schema) -> Option<String> {
let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind else {
return None;
};
let values = string_type
.enumeration
.iter()
.flatten()
.map(|value| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")))
.collect::<Vec<_>>();
(!values.is_empty()).then(|| format!("Types::Strict::String.enum({})", values.join(", ")))
}
fn string_format_ruby_type(&self, string_type: &openapiv3::StringType) -> String {
match &string_type.format {
VariantOrUnknownOrEmpty::Item(StringFormat::Date) => "Types::ISODate".to_string(),
VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => "Types::ISODateTime".to_string(),
VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "Types::UUID".to_string(),
_ => "Types::Strict::String".to_string(),
}
}
fn schema_to_ruby_return_type(
&self,
parent_class_name: Option<&str>,
field_name: Option<&str>,
schema: &Schema,
inline_name: Option<&str>,
) -> String {
match &schema.schema_kind {
SchemaKind::Type(Type::String(string_type)) => match &string_type.format {
VariantOrUnknownOrEmpty::Item(StringFormat::Date) => "Date".to_string(),
VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => "DateTime".to_string(),
_ => "String".to_string(),
},
SchemaKind::Type(Type::Number(_)) => "Float".to_string(),
SchemaKind::Type(Type::Integer(_)) => "Integer".to_string(),
SchemaKind::Type(Type::Boolean(_)) => "Boolean".to_string(),
SchemaKind::Type(Type::Array(arr)) => {
let item_type = match &arr.items {
Some(ReferenceOr::Item(item_schema)) => self.schema_to_ruby_return_type(
None,
None,
item_schema,
parent_class_name
.zip(field_name)
.and_then(|(parent, field)| self.inline_array_item_model_name(parent, field, schema))
.as_deref(),
),
Some(ReferenceOr::Reference { reference }) => {
let ref_name = reference.split('/').next_back().unwrap();
ref_name.to_pascal_case()
}
None => "Object".to_string(),
};
format!("Array<{item_type}>")
}
SchemaKind::Type(Type::Object(obj)) => {
if obj.properties.is_empty() {
"Hash".to_string()
} else {
inline_name
.map(ToOwned::to_owned)
.or_else(|| {
parent_class_name
.zip(field_name)
.map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
})
.unwrap_or_else(|| {
let mut value_types = Vec::new();
for prop_schema_ref in obj.properties.values() {
let prop_type = match prop_schema_ref {
ReferenceOr::Item(prop_schema) => {
self.schema_to_ruby_return_type(None, None, prop_schema, None)
}
ReferenceOr::Reference { reference } => {
reference.split('/').next_back().unwrap().to_pascal_case()
}
};
if !value_types.contains(&prop_type) {
value_types.push(prop_type);
}
}
let union = if value_types.is_empty() {
"Object".to_string()
} else {
value_types.join(", ")
};
format!("Hash{{Symbol => ({union})}}")
})
}
}
_ => "Object".to_string(),
}
}
fn generate_routes(&self) -> Result<String> {
let mut output = String::new();
output.push_str("# API Application\n");
output.push_str("class API < Sinatra::Base\n");
output.push_str(" # Configure JSON content type by default\n");
output.push_str(" before do\n");
output.push_str(" content_type :json\n");
output.push_str(" end\n\n");
output.push_str(&self.generate_route_helpers());
for (path, path_item_ref) in &self.spec.paths.paths {
let path_item = match path_item_ref {
ReferenceOr::Item(item) => item,
ReferenceOr::Reference { .. } => continue,
};
if let Some(op) = &path_item.get {
output.push_str(&self.generate_route_handler(path, "get", op)?);
}
if let Some(op) = &path_item.post {
output.push_str(&self.generate_route_handler(path, "post", op)?);
}
if let Some(op) = &path_item.put {
output.push_str(&self.generate_route_handler(path, "put", op)?);
}
if let Some(op) = &path_item.delete {
output.push_str(&self.generate_route_handler(path, "delete", op)?);
}
if let Some(op) = &path_item.patch {
output.push_str(&self.generate_route_handler(path, "patch", op)?);
}
}
output.push_str("end\n");
Ok(output)
}
fn generate_route_helpers(&self) -> String {
r#" private
def invalid_parameter!(name, message)
halt 400, { error: 'Invalid parameter', parameter: name, message: message }.to_json
end
def coerce_integer_param!(value, name)
Integer(value, 10)
rescue ArgumentError, TypeError
invalid_parameter!(name, 'must be an integer')
end
def coerce_float_param!(value, name)
Float(value)
rescue ArgumentError, TypeError
invalid_parameter!(name, 'must be a float')
end
def coerce_boolean_param!(value, name)
case value
when true, 'true', '1', 1 then true
when false, 'false', '0', 0 then false
else
invalid_parameter!(name, 'must be a boolean')
end
end
def coerce_date_param!(value, name)
Date.iso8601(value)
rescue ArgumentError, TypeError
invalid_parameter!(name, 'must be an ISO 8601 date')
end
def coerce_datetime_param!(value, name)
DateTime.iso8601(value)
rescue ArgumentError, TypeError
invalid_parameter!(name, 'must be an ISO 8601 date-time')
end
def coerce_uuid_param!(value, name)
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/
return value if pattern.match?(value.to_s)
invalid_parameter!(name, 'must be a UUID')
end
def coerce_enum_param!(value, name, allowed)
return value if allowed.include?(value)
invalid_parameter!(name, "must be one of: #{allowed.join(', ')}")
end
"#
.to_string()
}
fn generate_route_handler(&self, path: &str, method: &str, operation: &Operation) -> Result<String> {
let mut output = String::new();
let sinatra_path = path.replace('{', ":").replace('}', "");
if let Some(summary) = &operation.summary {
output.push_str(&format!(" # {summary}\n"));
} else {
output.push_str(&format!(" # {} {}\n", method.to_uppercase(), path));
}
if let Some(description) = &operation.description {
for line in description.lines() {
if line.trim().is_empty() {
output.push_str(" #\n");
} else {
output.push_str(&format!(" # {}\n", line.trim_end()));
}
}
}
for param_ref in &operation.parameters {
if let ReferenceOr::Item(param) = param_ref {
match param {
Parameter::Path {
parameter_data,
style: _,
..
} => {
let param_type = self.parameter_doc_type(param, false);
let detail = self.parameter_detail(param, false);
output.push_str(&format!(
" # @param {} [{}] {}\n",
parameter_data.name.to_snake_case(),
param_type,
detail
));
}
Parameter::Query {
parameter_data,
style: _,
allow_reserved: _,
allow_empty_value: _,
..
} => {
let param_type = self.parameter_doc_type(param, !parameter_data.required);
let detail = self.parameter_detail(param, true);
output.push_str(&format!(
" # @param {} [{}] {}\n",
parameter_data.name.to_snake_case(),
param_type,
detail
));
}
_ => {}
}
}
}
let body_type = self.extract_request_body_type(operation, method, path);
if body_type.is_some() {
output.push_str(&format!(
" # @param body [{}] Request body\n",
body_type.as_deref().unwrap_or("Hash")
));
}
let return_type = self.extract_response_type(operation, method, path);
output.push_str(&format!(" # @return [{return_type}] Response body\n"));
output.push_str(&format!(" {method} '{sinatra_path}' do\n"));
let parameter_bindings = self.generate_parameter_bindings(operation);
if !parameter_bindings.is_empty() {
output.push_str(¶meter_bindings);
output.push('\n');
}
if let Some(bt) = body_type {
output.push_str(" # Parse and validate request body\n");
output.push_str(" # TODO: body_data = JSON.parse(request.body.read)\n");
output.push_str(" # TODO: body = ");
output.push_str(&bt);
output.push_str(".new(body_data)\n\n");
}
output.push_str(" # TODO: Implement this endpoint\n");
match method {
"get" => {
if return_type.starts_with("Array") {
output.push_str(" [].to_json\n");
} else {
output.push_str(" {}.to_json\n");
}
}
"post" | "put" | "patch" => {
output.push_str(" status 201\n");
output.push_str(" {}.to_json\n");
}
"delete" => {
output.push_str(" status 204\n");
output.push_str(" ''\n");
}
_ => {
output.push_str(" {}.to_json\n");
}
}
output.push_str(" end\n");
Ok(output)
}
fn generate_parameter_bindings(&self, operation: &Operation) -> String {
let mut output = String::new();
for param_ref in &operation.parameters {
let ReferenceOr::Item(param) = param_ref else {
continue;
};
let Some(binding) = self.parameter_binding_line(param) else {
continue;
};
output.push_str(" ");
output.push_str(&binding);
output.push('\n');
}
output
}
fn parameter_binding_line(&self, parameter: &Parameter) -> Option<String> {
match parameter {
Parameter::Path { parameter_data, .. } => Some(self.required_parameter_binding_line(parameter_data)),
Parameter::Query { parameter_data, .. } => Some(self.query_parameter_binding_line(parameter_data)),
_ => None,
}
}
fn required_parameter_binding_line(&self, parameter_data: &openapiv3::ParameterData) -> String {
let variable_name = format!("_{}", parameter_data.name.to_snake_case());
let value_expr = format!("params.fetch('{}')", parameter_data.name);
let coercion = self.parameter_coercion_expr(parameter_data, &value_expr);
format!("{variable_name} = {coercion}")
}
fn query_parameter_binding_line(&self, parameter_data: &openapiv3::ParameterData) -> String {
let variable_name = format!("_{}", parameter_data.name.to_snake_case());
if parameter_data.required {
let value_expr = format!("params.fetch('{}')", parameter_data.name);
let coercion = self.parameter_coercion_expr(parameter_data, &value_expr);
format!("{variable_name} = {coercion}")
} else {
let value_expr = format!("params['{}']", parameter_data.name);
let coercion = self.parameter_coercion_expr(parameter_data, &value_expr);
format!(
"{variable_name} = params.key?('{}') ? {coercion} : nil",
parameter_data.name
)
}
}
fn parameter_coercion_expr(&self, parameter_data: &openapiv3::ParameterData, value_expr: &str) -> String {
match ¶meter_data.format {
ParameterSchemaOrContent::Schema(schema_ref) => {
self.schema_param_coercion_expr(schema_ref, value_expr, ¶meter_data.name)
}
ParameterSchemaOrContent::Content(_) => value_expr.to_string(),
}
}
fn schema_param_coercion_expr(&self, schema_ref: &ReferenceOr<Schema>, value_expr: &str, name: &str) -> String {
match schema_ref {
ReferenceOr::Item(schema) => self.inline_schema_param_coercion_expr(schema, value_expr, name),
ReferenceOr::Reference { reference } => self
.resolve_schema_reference(reference)
.map(|schema| self.inline_schema_param_coercion_expr(schema, value_expr, name))
.unwrap_or_else(|| value_expr.to_string()),
}
}
fn inline_schema_param_coercion_expr(&self, schema: &Schema, value_expr: &str, name: &str) -> String {
match &schema.schema_kind {
SchemaKind::Type(Type::String(string_type)) => {
let enum_values = string_type
.enumeration
.iter()
.flatten()
.map(|value| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")))
.collect::<Vec<_>>();
if !enum_values.is_empty() {
return format!(
"coerce_enum_param!({value_expr}, '{name}', [{}])",
enum_values.join(", ")
);
}
match &string_type.format {
VariantOrUnknownOrEmpty::Item(StringFormat::Date) => {
format!("coerce_date_param!({value_expr}, '{name}')")
}
VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => {
format!("coerce_datetime_param!({value_expr}, '{name}')")
}
VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => {
format!("coerce_uuid_param!({value_expr}, '{name}')")
}
_ => value_expr.to_string(),
}
}
SchemaKind::Type(Type::Integer(_)) => format!("coerce_integer_param!({value_expr}, '{name}')"),
SchemaKind::Type(Type::Number(_)) => format!("coerce_float_param!({value_expr}, '{name}')"),
SchemaKind::Type(Type::Boolean(_)) => format!("coerce_boolean_param!({value_expr}, '{name}')"),
_ => value_expr.to_string(),
}
}
fn generate_main(&self) -> String {
r"
# Run the application
# Usage: ruby generated_api.rb
# Or use with config.ru for Rack-based deployment
API.run!(host: '0.0.0.0', port: 4567) if __FILE__ == $PROGRAM_NAME
# For Rack-based deployment (config.ru):
# run API
"
.to_string()
}
fn parameter_doc_type(&self, parameter: &Parameter, optional: bool) -> String {
match parameter {
Parameter::Path { parameter_data, .. }
| Parameter::Query { parameter_data, .. }
| Parameter::Header { parameter_data, .. }
| Parameter::Cookie { parameter_data, .. } => match ¶meter_data.format {
ParameterSchemaOrContent::Schema(schema_ref) => {
let base_type = match schema_ref {
ReferenceOr::Item(schema) => self.schema_to_ruby_return_type(None, None, schema, None),
ReferenceOr::Reference { reference } => self
.resolve_schema_reference(reference)
.map(|schema| self.schema_to_ruby_return_type(None, None, schema, None))
.unwrap_or_else(|| reference.split('/').next_back().unwrap().to_pascal_case()),
};
if optional {
format!("{base_type}, nil")
} else {
base_type
}
}
ParameterSchemaOrContent::Content(_) => {
if optional {
"Object, nil".to_string()
} else {
"Object".to_string()
}
}
},
}
}
fn parameter_detail(&self, parameter: &Parameter, query: bool) -> String {
let (parameter_data, required_suffix) = match parameter {
Parameter::Path { parameter_data, .. } => (parameter_data, String::new()),
Parameter::Query { parameter_data, .. } => {
let suffix = if parameter_data.required {
"required".to_string()
} else {
"optional".to_string()
};
(parameter_data, suffix)
}
Parameter::Header { parameter_data, .. } | Parameter::Cookie { parameter_data, .. } => {
(parameter_data, String::new())
}
};
let mut details = Vec::new();
if let Some(constraint) = self.parameter_constraint(parameter_data) {
details.push(constraint);
}
if query {
details.push(required_suffix);
}
let label = if query { "Query parameter" } else { "Path parameter" };
if details.is_empty() {
label.to_string()
} else {
format!("{label} ({})", details.join("; "))
}
}
fn parameter_constraint(&self, parameter_data: &openapiv3::ParameterData) -> Option<String> {
let ParameterSchemaOrContent::Schema(schema_ref) = ¶meter_data.format else {
return None;
};
let schema = match schema_ref {
ReferenceOr::Item(schema) => schema,
ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference)?,
};
let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind else {
return None;
};
if !string_type.enumeration.is_empty() {
let values = string_type.enumeration.iter().flatten().cloned().collect::<Vec<_>>();
return Some(format!("enum: {}", values.join(", ")));
}
match &string_type.format {
VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => Some("UUID".to_string()),
_ => None,
}
}
fn resolve_schema_reference<'a>(&'a self, reference: &str) -> Option<&'a Schema> {
let name = reference.split('/').next_back()?;
self.spec
.components
.as_ref()?
.schemas
.get(name)
.and_then(|schema_ref| match schema_ref {
ReferenceOr::Item(schema) => Some(schema),
ReferenceOr::Reference { .. } => None,
})
}
fn should_generate_inline_model(schema: &Schema) -> bool {
matches!(&schema.schema_kind, SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty())
}
fn inline_model_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
match &schema.schema_kind {
SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
Some(format!("{parent_class_name}{}", field_name.to_pascal_case()))
}
_ => None,
}
}
fn inline_array_item_model_name(
&self,
parent_class_name: &str,
field_name: &str,
schema: &Schema,
) -> Option<String> {
let item_schema = Self::inline_array_item_schema(schema)?;
match &item_schema.schema_kind {
SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
Some(format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
}
_ => None,
}
}
fn inline_array_item_schema(schema: &Schema) -> Option<&Schema> {
match &schema.schema_kind {
SchemaKind::Type(Type::Array(array_type)) => match &array_type.items {
Some(ReferenceOr::Item(item_schema)) => Some(item_schema),
_ => None,
},
_ => None,
}
}
fn inline_request_body_model<'a>(
&self,
operation: &'a Operation,
method: &str,
path: &str,
) -> Option<(String, &'a Schema)> {
let body_ref = operation.request_body.as_ref()?;
let ReferenceOr::Item(request_body) = body_ref else {
return None;
};
let media_type = request_body.content.get("application/json")?;
let schema_ref = media_type.schema.as_ref()?;
let ReferenceOr::Item(schema) = schema_ref else {
return None;
};
if Self::should_generate_inline_model(schema) {
Some((self.inline_request_body_name(operation, method, path), schema))
} else {
None
}
}
fn inline_response_model<'a>(
&self,
operation: &'a Operation,
method: &str,
path: &str,
) -> Option<(String, &'a Schema)> {
use openapiv3::StatusCode;
let response_ref = operation
.responses
.responses
.get(&StatusCode::Code(200))
.or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
.or_else(|| operation.responses.responses.get(&StatusCode::Range(2)))?;
let ReferenceOr::Item(response) = response_ref else {
return None;
};
let media_type = response.content.get("application/json")?;
let schema_ref = media_type.schema.as_ref()?;
let ReferenceOr::Item(schema) = schema_ref else {
return None;
};
if Self::should_generate_inline_model(schema) {
Some((self.inline_response_name(operation, method, path), schema))
} else {
None
}
}
fn inline_request_body_name(&self, operation: &Operation, method: &str, path: &str) -> String {
format!("{}RequestBody", self.operation_model_stem(operation, method, path))
}
fn inline_response_name(&self, operation: &Operation, method: &str, path: &str) -> String {
format!("{}ResponseBody", self.operation_model_stem(operation, method, path))
}
fn operation_model_stem(&self, operation: &Operation, method: &str, path: &str) -> String {
operation
.operation_id
.as_ref()
.map(|id| id.to_pascal_case())
.unwrap_or_else(|| {
format!(
"{}{}",
method.to_pascal_case(),
path.split('/')
.filter(|segment| !segment.is_empty())
.map(|segment| segment.trim_matches(|c| c == '{' || c == '}').to_pascal_case())
.collect::<String>()
)
})
}
}