use super::PhpDtoStyle;
use anyhow::Result;
use heck::{ToPascalCase, ToSnakeCase};
use openapiv3::{
OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent, ReferenceOr, Schema, SchemaKind,
StringFormat, Type, VariantOrUnknownOrEmpty,
};
use std::collections::BTreeSet;
pub struct PhpGenerator {
spec: OpenAPI,
style: PhpDtoStyle,
}
impl PhpGenerator {
#[must_use]
pub const fn new(spec: OpenAPI, style: PhpDtoStyle) -> Self {
Self { spec, style }
}
pub fn generate(&self) -> Result<String> {
let mut output = String::new();
match self.style {
PhpDtoStyle::ReadonlyClass => {}
}
output.push_str(&self.generate_header());
output.push_str(&self.generate_models()?);
output.push_str(&self.generate_controllers()?);
output.push_str(&self.generate_main());
Ok(output)
}
fn generate_header(&self) -> String {
let title = Self::escape_php_string(&self.spec.info.title);
let openapi = Self::escape_php_string(&self.spec.openapi);
format!(
"<?php\n/**\n * Generated by Spikard OpenAPI code generator\n * OpenAPI Version: {openapi}\n * Title: {title}\n * DO NOT EDIT - regenerate from OpenAPI schema\n */\n\ndeclare(strict_types=1);\n\nnamespace SpikardGenerated;\n\n"
)
}
fn generate_models(&self) -> Result<String> {
let mut output = String::new();
output.push_str("// Schema Models\n\n");
let mut emitted_models = BTreeSet::new();
let mut emitted_enums = BTreeSet::new();
if self.uses_uuid_types() {
output.push_str(&self.generate_uuid_value_class());
output.push('\n');
}
if let Some(components) = &self.spec.components {
for (name, schema_ref) in &components.schemas {
match schema_ref {
ReferenceOr::Item(schema) => {
self.generate_schema_family(
&name.to_pascal_case(),
schema,
&mut emitted_models,
&mut emitted_enums,
&mut output,
)?;
}
ReferenceOr::Reference { .. } => {
continue;
}
}
}
}
self.generate_inline_route_models(&mut emitted_models, &mut emitted_enums, &mut output)?;
self.generate_inline_parameter_enums(&mut emitted_enums, &mut output)?;
Ok(output)
}
fn generate_model_class(&self, class_name: &str, schema: &Schema) -> Result<String> {
let mut output = String::new();
self.append_php_doc(&mut output, &schema.schema_data.description, &class_name);
output.push_str(&format!("readonly class {class_name}\n{{\n"));
match &schema.schema_kind {
SchemaKind::Type(Type::Object(obj)) => {
if obj.properties.is_empty() {
output.push_str(" // Empty schema\n");
} else {
self.append_constructor(&mut output, class_name, obj)?;
}
}
_ => {
output.push_str(" // Unsupported schema type\n");
}
}
output.push_str("}\n");
Ok(output)
}
fn generate_enum_class(&self, enum_name: &str, schema: &Schema) -> Result<String> {
let mut output = String::new();
self.append_php_doc(&mut output, &schema.schema_data.description, enum_name);
output.push_str(&format!("enum {enum_name}: string\n{{\n"));
let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind else {
output.push_str("}\n");
return Ok(output);
};
for value in string_type.enumeration.iter().flatten() {
output.push_str(&format!(
" case {} = '{}';\n",
Self::enum_case_name(value),
Self::escape_php_string(value)
));
}
output.push_str("}\n");
Ok(output)
}
fn generate_uuid_value_class(&self) -> String {
String::from(
"/**\n * UUID value object\n */\nreadonly class UuidValue\n{\n public function __construct(public string $value)\n {\n if (!preg_match('/^[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}$/', $value)) {\n throw new \\InvalidArgumentException('Invalid UUID value');\n }\n }\n\n public function __toString(): string\n {\n return $this->value;\n }\n}\n",
)
}
fn generate_schema_family(
&self,
class_name: &str,
schema: &Schema,
emitted_models: &mut BTreeSet<String>,
emitted_enums: &mut BTreeSet<String>,
output: &mut String,
) -> Result<()> {
if Self::is_enum_schema(schema) {
return self.generate_enum_family(class_name, schema, emitted_enums, output);
}
if !emitted_models.insert(class_name.to_string()) {
return Ok(());
}
self.generate_nested_model_families(class_name, schema, emitted_models, emitted_enums, output)?;
output.push_str(&self.generate_model_class(class_name, schema)?);
output.push('\n');
Ok(())
}
fn generate_enum_family(
&self,
enum_name: &str,
schema: &Schema,
emitted_enums: &mut BTreeSet<String>,
output: &mut String,
) -> Result<()> {
if !emitted_enums.insert(enum_name.to_string()) {
return Ok(());
}
output.push_str(&self.generate_enum_class(enum_name, schema)?);
output.push('\n');
Ok(())
}
fn generate_nested_model_families(
&self,
parent_class_name: &str,
schema: &Schema,
emitted_models: &mut BTreeSet<String>,
emitted_enums: &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 {
match prop_schema_ref {
ReferenceOr::Item(prop_schema) => {
if let Some(enum_name) = self.inline_enum_name(parent_class_name, prop_name, prop_schema) {
self.generate_enum_family(&enum_name, prop_schema, emitted_enums, output)?;
}
if let Some(class_name) = self.inline_model_name(parent_class_name, prop_name, prop_schema)
{
self.generate_schema_family(
&class_name,
prop_schema,
emitted_models,
emitted_enums,
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_schema_family(
&array_item_name,
item_schema,
emitted_models,
emitted_enums,
output,
)?;
}
if let Some(array_item_enum_name) =
self.inline_array_item_enum_name(parent_class_name, prop_name, prop_schema)
&& let Some(item_schema) = Self::inline_array_item_schema(prop_schema)
{
self.generate_enum_family(&array_item_enum_name, item_schema, emitted_enums, output)?;
}
}
ReferenceOr::Reference { .. } => {}
}
}
}
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_models,
emitted_enums,
output,
)?,
ReferenceOr::Reference { reference } => {
if let Some(item_schema) = self.resolve_schema_reference(reference) {
self.generate_nested_model_families(
parent_class_name,
item_schema,
emitted_models,
emitted_enums,
output,
)?;
}
}
}
}
}
_ => {}
}
Ok(())
}
fn generate_inline_route_models(
&self,
emitted_models: &mut BTreeSet<String>,
emitted_enums: &mut BTreeSet<String>,
output: &mut String,
) -> Result<()> {
for path_item_ref in self.spec.paths.paths.values() {
let ReferenceOr::Item(path_item) = path_item_ref else {
continue;
};
for operation in [
path_item.get.as_ref(),
path_item.post.as_ref(),
path_item.put.as_ref(),
path_item.delete.as_ref(),
path_item.patch.as_ref(),
]
.into_iter()
.flatten()
{
if let Some((class_name, schema)) = self.inline_request_body_model(operation) {
self.generate_schema_family(&class_name, schema, emitted_models, emitted_enums, output)?;
}
if let Some((class_name, schema)) = self.inline_response_model(operation) {
self.generate_schema_family(&class_name, schema, emitted_models, emitted_enums, output)?;
}
}
}
Ok(())
}
fn generate_inline_parameter_enums(&self, emitted_enums: &mut BTreeSet<String>, output: &mut String) -> Result<()> {
for path_item_ref in self.spec.paths.paths.values() {
let ReferenceOr::Item(path_item) = path_item_ref else {
continue;
};
for operation in [
path_item.get.as_ref(),
path_item.post.as_ref(),
path_item.put.as_ref(),
path_item.delete.as_ref(),
path_item.patch.as_ref(),
]
.into_iter()
.flatten()
{
let operation_id = operation.operation_id.as_deref();
for parameter_ref in &operation.parameters {
let ReferenceOr::Item(parameter) = parameter_ref else {
continue;
};
let parameter_data = match parameter {
Parameter::Path { parameter_data, .. }
| Parameter::Query { parameter_data, .. }
| Parameter::Header { parameter_data, .. }
| Parameter::Cookie { parameter_data, .. } => parameter_data,
};
let ParameterSchemaOrContent::Schema(ReferenceOr::Item(schema)) = ¶meter_data.format else {
continue;
};
let Some(enum_name) = self.parameter_enum_name(operation_id, ¶meter_data.name, schema) else {
continue;
};
self.generate_enum_family(&enum_name, schema, emitted_enums, output)?;
}
}
}
Ok(())
}
fn append_php_doc(&self, output: &mut String, description: &Option<String>, class_name: &str) {
output.push_str("/**\n");
if let Some(desc) = description {
let escaped = Self::escape_php_string(desc);
output.push_str(&format!(" * {escaped}\n"));
} else {
output.push_str(&format!(" * {class_name} model\n"));
}
output.push_str(" */\n");
}
fn append_constructor(&self, output: &mut String, class_name: &str, obj: &openapiv3::ObjectType) -> Result<()> {
let (property_lines, property_docs) = self.build_constructor_params(class_name, obj)?;
if !property_docs.is_empty() {
output.push_str(" /**\n");
for doc_line in &property_docs {
output.push_str(doc_line);
}
output.push_str(" */\n");
}
output.push_str(" public function __construct(\n");
let props_str = property_lines.join("");
let props_str = props_str.trim_end_matches(",\n").to_string() + "\n";
output.push_str(&props_str);
output.push_str(" ) {}\n");
Ok(())
}
fn build_constructor_params(
&self,
class_name: &str,
obj: &openapiv3::ObjectType,
) -> Result<(Vec<String>, Vec<String>)> {
let mut required_props = Vec::new();
let mut optional_props = Vec::new();
let mut required_docs = Vec::new();
let mut optional_docs = Vec::new();
for (prop_name, prop_schema_ref) in &obj.properties {
let is_required = obj.required.contains(prop_name);
let field_name = Self::to_camel_case(prop_name);
let (type_hint, nullable, phpdoc_type) = match prop_schema_ref {
ReferenceOr::Item(prop_schema) => {
let (type_hint, nullable) =
self.schema_to_php_type(Some(class_name), Some(prop_name), prop_schema, !is_required, None);
let phpdoc_type =
self.schema_to_phpdoc_type(Some(class_name), Some(prop_name), prop_schema, !is_required, None);
(type_hint, nullable, phpdoc_type)
}
ReferenceOr::Reference { reference } => {
let ref_name = self.extract_ref_name(reference);
let ref_type = ref_name.to_pascal_case();
if is_required {
(ref_type.clone(), false, ref_type)
} else {
(format!("?{ref_type}"), true, format!("{ref_type}|null"))
}
}
};
let prop_line = self.build_property_line(&type_hint, &field_name, is_required, nullable);
let doc_line = format!(" * @param {phpdoc_type} ${field_name}\n");
if is_required {
required_props.push(prop_line);
required_docs.push(doc_line);
} else {
optional_props.push(prop_line);
optional_docs.push(doc_line);
}
}
let mut property_lines = required_props;
property_lines.extend(optional_props);
let mut property_docs = required_docs;
property_docs.extend(optional_docs);
Ok((property_lines, property_docs))
}
fn build_property_line(&self, type_hint: &str, field_name: &str, is_required: bool, nullable: bool) -> String {
if is_required {
format!(" public {type_hint} ${field_name},\n")
} else if nullable {
format!(" public {type_hint} ${field_name} = null,\n")
} else {
format!(" public ?{type_hint} ${field_name} = null,\n")
}
}
fn escape_php_string(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'\\' => vec!['\\', '\\'],
'\'' => vec!['\\', '\''],
'\n' => vec!['\\', 'n'],
'\r' => vec!['\\', 'r'],
'\t' => vec!['\\', 't'],
_ => vec![c],
})
.collect()
}
fn extract_ref_name(&self, reference: &str) -> String {
reference.split('/').next_back().unwrap_or("UnknownType").to_string()
}
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 uses_uuid_types(&self) -> bool {
self.spec.components.as_ref().is_some_and(|components| {
components
.schemas
.values()
.filter_map(|schema_ref| match schema_ref {
ReferenceOr::Item(schema) => Some(schema),
ReferenceOr::Reference { .. } => None,
})
.any(|schema| self.schema_uses_uuid_type(schema))
}) || self.spec.paths.paths.values().any(|path_item_ref| {
let ReferenceOr::Item(path_item) = path_item_ref else {
return false;
};
[
path_item.get.as_ref(),
path_item.post.as_ref(),
path_item.put.as_ref(),
path_item.delete.as_ref(),
path_item.patch.as_ref(),
]
.into_iter()
.flatten()
.any(|operation| self.operation_uses_uuid_type(operation))
})
}
fn operation_uses_uuid_type(&self, operation: &Operation) -> bool {
operation.parameters.iter().any(|parameter_ref| {
let ReferenceOr::Item(parameter) = parameter_ref else {
return false;
};
match parameter {
Parameter::Path { parameter_data, .. }
| Parameter::Query { parameter_data, .. }
| Parameter::Header { parameter_data, .. }
| Parameter::Cookie { parameter_data, .. } => {
let ParameterSchemaOrContent::Schema(schema_ref) = ¶meter_data.format else {
return false;
};
match schema_ref {
ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
ReferenceOr::Reference { reference } => self
.resolve_schema_reference(reference)
.is_some_and(|schema| self.schema_uses_uuid_type(schema)),
}
}
}
}) || 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())
.and_then(|schema_ref| match schema_ref {
ReferenceOr::Item(schema) => Some(schema),
ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
}),
ReferenceOr::Reference { .. } => None,
})
.is_some_and(|schema| self.schema_uses_uuid_type(schema))
}
fn schema_uses_uuid_type(&self, schema: &Schema) -> bool {
let SchemaKind::Type(ty) = &schema.schema_kind else {
if let SchemaKind::AllOf { all_of } = &schema.schema_kind {
return all_of.iter().any(|schema_ref| match schema_ref {
ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
ReferenceOr::Reference { reference } => self
.resolve_schema_reference(reference)
.is_some_and(|schema| self.schema_uses_uuid_type(schema)),
});
}
return false;
};
match ty {
Type::String(string_type) => {
matches!(&string_type.format, VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid")
}
Type::Array(array_type) => array_type
.items
.as_ref()
.and_then(|item_schema| match item_schema {
ReferenceOr::Item(schema) => Some(schema.as_ref()),
ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
})
.is_some_and(|schema| self.schema_uses_uuid_type(schema)),
Type::Object(object_type) => object_type.properties.values().any(|schema_ref| match schema_ref {
ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
ReferenceOr::Reference { reference } => self
.resolve_schema_reference(reference)
.is_some_and(|schema| self.schema_uses_uuid_type(schema)),
}),
_ => false,
}
}
fn to_camel_case(s: &str) -> String {
let snake = s.to_snake_case();
let mut chars = snake.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let mut result = first.to_lowercase().collect::<String>();
let mut capitalize_next = false;
for c in chars {
if c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push_str(&c.to_uppercase().to_string());
capitalize_next = false;
} else {
result.push(c);
}
}
result
}
}
}
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_enum_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
Self::is_enum_schema(schema).then(|| format!("{parent_class_name}{}", field_name.to_pascal_case()))
}
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_enum_name(
&self,
parent_class_name: &str,
field_name: &str,
schema: &Schema,
) -> Option<String> {
let item_schema = Self::inline_array_item_schema(schema)?;
Self::is_enum_schema(item_schema).then(|| format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
}
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 parameter_enum_name(&self, operation_id: Option<&str>, parameter_name: &str, schema: &Schema) -> Option<String> {
Self::is_enum_schema(schema).then(|| {
let operation_prefix = operation_id
.map(str::to_pascal_case)
.unwrap_or_else(|| "Operation".to_string());
format!("{operation_prefix}{}", parameter_name.to_pascal_case())
})
}
fn inline_request_body_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
let operation_id = operation.operation_id.as_deref()?;
let request_body = operation.request_body.as_ref()?;
match request_body {
ReferenceOr::Item(body) => {
let schema_ref = body.content.get("application/json")?.schema.as_ref()?;
match schema_ref {
ReferenceOr::Item(schema) if Self::is_named_inline_object_schema(schema) => {
Some((format!("{}RequestBody", operation_id.to_pascal_case()), schema))
}
_ => None,
}
}
ReferenceOr::Reference { .. } => None,
}
}
fn inline_response_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
use openapiv3::StatusCode;
let operation_id = operation.operation_id.as_deref()?;
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)))?;
match response {
ReferenceOr::Item(response) => {
let schema_ref = response.content.get("application/json")?.schema.as_ref()?;
match schema_ref {
ReferenceOr::Item(schema) if Self::is_named_inline_object_schema(schema) => {
Some((format!("{}ResponseBody", operation_id.to_pascal_case()), schema))
}
_ => None,
}
}
ReferenceOr::Reference { .. } => None,
}
}
fn is_named_inline_object_schema(schema: &Schema) -> bool {
matches!(&schema.schema_kind, SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty())
}
fn is_enum_schema(schema: &Schema) -> bool {
matches!(&schema.schema_kind, SchemaKind::Type(Type::String(string_type)) if !string_type.enumeration.is_empty())
}
fn enum_case_name(value: &str) -> String {
let mut name = value
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
.collect::<String>()
.to_pascal_case();
if name.is_empty() {
name = "Value".to_string();
}
if name.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
name.insert_str(0, "Value");
}
name
}
fn enum_type_name(
&self,
parent_class_name: Option<&str>,
field_name: Option<&str>,
schema: &Schema,
inline_name: Option<&str>,
) -> Option<String> {
Self::is_enum_schema(schema).then(|| {
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(|| "StringBackedEnum".to_string())
})
}
fn string_format_php_type(&self, string_type: &openapiv3::StringType) -> String {
match &string_type.format {
VariantOrUnknownOrEmpty::Item(StringFormat::Date | StringFormat::DateTime) => {
"\\DateTimeImmutable".to_string()
}
VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "UuidValue".to_string(),
_ => "string".to_string(),
}
}
fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>, inline_name: Option<&str>) -> String {
match schema_ref {
ReferenceOr::Reference { reference } => self.extract_ref_name(reference).to_pascal_case(),
ReferenceOr::Item(schema) => self.schema_to_php_type(None, None, schema, false, inline_name).0,
}
}
fn extract_request_body_type(&self, operation: &Operation) -> Option<String> {
operation.request_body.as_ref().and_then(|body_ref| match body_ref {
ReferenceOr::Item(request_body) => self.extract_json_schema_type(
request_body.content.get("application/json"),
operation
.operation_id
.as_deref()
.map(|id| format!("{}RequestBody", id.to_pascal_case()))
.as_deref(),
),
ReferenceOr::Reference { reference } => {
let ref_name = self.extract_ref_name(reference);
Some(ref_name.to_pascal_case())
}
})
}
fn extract_json_schema_type(
&self,
media_type: Option<&openapiv3::MediaType>,
inline_name: Option<&str>,
) -> Option<String> {
media_type.and_then(|mt| {
mt.schema
.as_ref()
.map(|schema_ref| self.extract_type_from_schema_ref(schema_ref, inline_name))
})
}
fn extract_json_schema_doc_type(
&self,
media_type: Option<&openapiv3::MediaType>,
inline_name: Option<&str>,
) -> Option<String> {
media_type.and_then(|mt| {
mt.schema
.as_ref()
.map(|schema_ref| self.phpdoc_type_from_schema_ref(schema_ref, false, inline_name))
})
}
fn extract_response_type(&self, operation: &Operation) -> 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)));
response
.and_then(|response_ref| {
self.extract_response_type_from_ref(
response_ref,
operation
.operation_id
.as_deref()
.map(|id| format!("{}ResponseBody", id.to_pascal_case()))
.as_deref(),
)
})
.unwrap_or_else(|| "array".to_string())
}
fn extract_request_body_doc_type(&self, operation: &Operation) -> Option<String> {
operation.request_body.as_ref().and_then(|body_ref| match body_ref {
ReferenceOr::Item(request_body) => self.extract_json_schema_doc_type(
request_body.content.get("application/json"),
operation
.operation_id
.as_deref()
.map(|id| format!("{}RequestBody", id.to_pascal_case()))
.as_deref(),
),
ReferenceOr::Reference { reference } => {
let ref_name = self.extract_ref_name(reference);
Some(ref_name.to_pascal_case())
}
})
}
fn extract_response_doc_type(&self, operation: &Operation) -> 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)));
response
.and_then(|response_ref| {
self.extract_response_doc_type_from_ref(
response_ref,
operation
.operation_id
.as_deref()
.map(|id| format!("{}ResponseBody", id.to_pascal_case()))
.as_deref(),
)
})
.unwrap_or_else(|| "array<string, mixed>".to_string())
}
fn extract_response_type_from_ref(
&self,
response_ref: &ReferenceOr<openapiv3::Response>,
inline_name: Option<&str>,
) -> Option<String> {
match response_ref {
ReferenceOr::Item(response) => {
let media_type = response.content.get("application/json")?;
let schema_ref = media_type.schema.as_ref()?;
Some(self.extract_type_from_schema_ref(schema_ref, inline_name))
}
ReferenceOr::Reference { reference } => {
let ref_name = self.extract_ref_name(reference);
Some(ref_name.to_pascal_case())
}
}
}
fn extract_response_doc_type_from_ref(
&self,
response_ref: &ReferenceOr<openapiv3::Response>,
inline_name: Option<&str>,
) -> Option<String> {
match response_ref {
ReferenceOr::Item(response) => {
let media_type = response.content.get("application/json")?;
let schema_ref = media_type.schema.as_ref()?;
Some(self.phpdoc_type_from_schema_ref(schema_ref, false, inline_name))
}
ReferenceOr::Reference { reference } => {
let ref_name = self.extract_ref_name(reference);
Some(ref_name.to_pascal_case())
}
}
}
fn schema_to_php_type(
&self,
parent_class_name: Option<&str>,
field_name: Option<&str>,
schema: &Schema,
optional: bool,
inline_name: Option<&str>,
) -> (String, bool) {
let base_type = if let Some(enum_type) = self.enum_type_name(parent_class_name, field_name, schema, inline_name)
{
enum_type
} else {
match &schema.schema_kind {
SchemaKind::Type(Type::String(string_type)) => self.string_format_php_type(string_type),
SchemaKind::Type(Type::Number(_)) => "float".to_string(),
SchemaKind::Type(Type::Integer(_)) => "int".to_string(),
SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
SchemaKind::Type(Type::Array(_)) => {
"array".to_string()
}
SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => 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(|| "array".to_string()),
SchemaKind::Type(Type::Object(_)) => "array".to_string(),
_ => "mixed".to_string(),
}
};
if optional {
(format!("?{base_type}"), true)
} else {
(base_type, false)
}
}
#[allow(dead_code)]
fn schema_to_phpdoc_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.enum_type_name(parent_class_name, field_name, schema, inline_name)
{
enum_type
} else {
match &schema.schema_kind {
SchemaKind::Type(Type::String(string_type)) => {
if string_type.enumeration.is_empty() {
self.string_format_php_type(string_type)
} else {
string_type
.enumeration
.iter()
.flatten()
.map(|value| format!("'{}'", Self::escape_php_string(value)))
.collect::<Vec<_>>()
.join("|")
}
}
SchemaKind::Type(Type::Number(number_type)) => {
if number_type.enumeration.is_empty() {
"float".to_string()
} else {
number_type
.enumeration
.iter()
.flatten()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("|")
}
}
SchemaKind::Type(Type::Integer(integer_type)) => {
if integer_type.enumeration.is_empty() {
"int".to_string()
} else {
integer_type
.enumeration
.iter()
.flatten()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("|")
}
}
SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
SchemaKind::Type(Type::Array(arr)) => {
let item_type = match &arr.items {
Some(ReferenceOr::Item(item_schema)) => self.schema_to_phpdoc_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)
.or_else(|| self.inline_array_item_enum_name(parent, field, schema))
})
.as_deref(),
),
Some(ReferenceOr::Reference { reference }) => {
let ref_name = reference.split('/').next_back().unwrap();
ref_name.to_pascal_case()
}
None => "mixed".to_string(),
};
format!("list<{item_type}>")
}
SchemaKind::Type(Type::Object(obj)) => {
if obj.properties.is_empty() {
"array<string, mixed>".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 entries = Vec::new();
for (prop_name, prop_schema_ref) in &obj.properties {
let is_required = obj.required.contains(prop_name);
let prop_type = match prop_schema_ref {
ReferenceOr::Item(prop_schema) => self.schema_to_phpdoc_type(
parent_class_name,
Some(prop_name),
prop_schema,
!is_required,
None,
),
ReferenceOr::Reference { reference } => {
let ref_name = reference.split('/').next_back().unwrap();
let mut base = ref_name.to_pascal_case();
if !is_required {
base.push_str("|null");
}
base
}
};
if is_required {
entries.push(format!("{prop_name}: {prop_type}"));
} else {
entries.push(format!("{prop_name}?: {prop_type}"));
}
}
format!("array{{{}}}", entries.join(", "))
})
}
}
_ => "mixed".to_string(),
}
};
if optional {
format!("{base_type}|null")
} else {
base_type
}
}
fn generate_controllers(&self) -> Result<String> {
let mut output = String::new();
output.push_str("\n// Controller Classes\n\n");
let controllers = self.group_operations_by_controller();
for (controller_name, routes) in controllers {
output.push_str(&self.generate_controller_class(&controller_name, &routes)?);
output.push('\n');
}
Ok(output)
}
fn group_operations_by_controller(&self) -> std::collections::HashMap<String, Vec<(String, String, Operation)>> {
let mut controllers: std::collections::HashMap<String, Vec<(String, String, Operation)>> =
std::collections::HashMap::new();
for (path, path_item_ref) in &self.spec.paths.paths {
let path_item = match path_item_ref {
ReferenceOr::Item(item) => item,
ReferenceOr::Reference { .. } => continue,
};
let controller_name = self.extract_controller_name(path);
self.add_path_operations(&mut controllers, path, &controller_name, path_item);
}
controllers
}
fn add_path_operations(
&self,
controllers: &mut std::collections::HashMap<String, Vec<(String, String, Operation)>>,
path: &str,
controller_name: &str,
path_item: &openapiv3::PathItem,
) {
let methods = [
("GET", &path_item.get),
("POST", &path_item.post),
("PUT", &path_item.put),
("DELETE", &path_item.delete),
("PATCH", &path_item.patch),
];
for (method, op_opt) in methods {
if let Some(op) = op_opt {
controllers.entry(controller_name.to_string()).or_default().push((
path.to_string(),
method.to_string(),
op.clone(),
));
}
}
}
fn extract_controller_name(&self, path: &str) -> String {
let segments: Vec<&str> = path
.split('/')
.filter(|s| !s.is_empty() && !s.starts_with('{'))
.collect();
if let Some(first_segment) = segments.first() {
format!("{}Controller", first_segment.to_pascal_case())
} else {
"DefaultController".to_string()
}
}
fn generate_controller_class(
&self,
controller_name: &str,
routes: &[(String, String, Operation)],
) -> Result<String> {
let mut output = String::new();
output.push_str("/**\n");
output.push_str(&format!(" * {controller_name} - Generated controller for API routes\n"));
output.push_str(" */\n");
output.push_str(&format!("class {controller_name}\n{{\n"));
for (path, method, operation) in routes {
output.push_str(&self.generate_route_handler(path, method, operation)?);
}
output.push_str("}\n");
Ok(output)
}
fn generate_route_handler(&self, path: &str, method: &str, operation: &Operation) -> Result<String> {
let mut output = String::new();
let method_name = self.extract_handler_method_name(operation, method, path);
let (path_params, query_params, body_type, return_type) =
self.extract_handler_parameters(operation, &mut output)?;
output.push_str(&format!(
" #[Route('{}', methods: ['{}'])]\n",
path,
method.to_uppercase()
));
self.append_function_signature(
&mut output,
&method_name,
&path_params,
&query_params,
&body_type,
&return_type,
);
self.append_function_body(&mut output);
Ok(output)
}
fn extract_handler_method_name(&self, operation: &Operation, method: &str, path: &str) -> String {
operation
.operation_id
.as_ref()
.map(|id| Self::to_camel_case(id))
.unwrap_or_else(|| {
Self::to_camel_case(&format!(
"{}_{}",
method.to_lowercase(),
path.replace('/', "_").replace(['{', '}'], "").trim_matches('_')
))
})
}
#[allow(clippy::type_complexity)]
fn extract_handler_parameters(
&self,
operation: &Operation,
output: &mut String,
) -> Result<(
Vec<(String, String)>,
Vec<(String, String, bool)>,
Option<String>,
String,
)> {
output.push_str(" /**\n");
self.append_operation_description(output, operation);
let (path_params, query_params) = self.extract_path_and_query_params(operation, output);
let body_type = self.extract_request_body_type(operation);
let return_type = self.extract_response_type(operation);
let body_doc_type = self.extract_request_body_doc_type(operation);
let return_doc_type = self.extract_response_doc_type(operation);
self.append_parameter_docs(output, &body_doc_type, &return_doc_type);
Ok((path_params, query_params, body_type, return_type))
}
fn append_operation_description(&self, output: &mut String, operation: &Operation) {
if let Some(summary) = &operation.summary {
output.push_str(&format!(" * {summary}\n"));
}
if let Some(description) = &operation.description {
output.push_str(&format!(" * \n * {description}\n"));
}
output.push_str(" * \n");
}
#[allow(clippy::type_complexity)]
fn extract_path_and_query_params(
&self,
operation: &Operation,
output: &mut String,
) -> (Vec<(String, String)>, Vec<(String, String, bool)>) {
let mut path_params = Vec::new();
let mut query_params = Vec::new();
for param_ref in &operation.parameters {
if let ReferenceOr::Item(param) = param_ref {
self.process_parameter(
operation.operation_id.as_deref(),
param,
&mut path_params,
&mut query_params,
output,
);
}
}
(path_params, query_params)
}
fn process_parameter(
&self,
operation_id: Option<&str>,
param: &Parameter,
path_params: &mut Vec<(String, String)>,
query_params: &mut Vec<(String, String, bool)>,
output: &mut String,
) {
match param {
Parameter::Path { parameter_data, .. } => {
let param_name = Self::to_camel_case(¶meter_data.name);
let (native_type, phpdoc_type) = self.parameter_types(operation_id, parameter_data, false);
path_params.push((param_name.clone(), native_type));
output.push_str(&format!(" * @param {phpdoc_type} ${param_name}\n"));
}
Parameter::Query { parameter_data, .. } => {
let param_name = Self::to_camel_case(¶meter_data.name);
let required = parameter_data.required;
let (native_type, phpdoc_type) = self.parameter_types(operation_id, parameter_data, !required);
query_params.push((param_name.clone(), native_type, required));
output.push_str(&format!(" * @param {phpdoc_type} ${param_name}\n"));
}
_ => {}
}
}
fn parameter_types(
&self,
operation_id: Option<&str>,
parameter_data: &ParameterData,
optional: bool,
) -> (String, String) {
match ¶meter_data.format {
ParameterSchemaOrContent::Schema(schema_ref) => match schema_ref {
ReferenceOr::Item(schema) => {
let inline_name = self.parameter_enum_name(operation_id, ¶meter_data.name, schema);
let (native_type, _) =
self.schema_to_php_type(None, None, schema, optional, inline_name.as_deref());
let phpdoc_type = self.schema_to_phpdoc_type(None, None, schema, optional, inline_name.as_deref());
(native_type, phpdoc_type)
}
ReferenceOr::Reference { reference } => {
let base = self.extract_ref_name(reference).to_pascal_case();
if optional {
(format!("?{base}"), format!("{base}|null"))
} else {
(base.clone(), base)
}
}
},
ParameterSchemaOrContent::Content(_) => {
if optional {
("?array".to_string(), "array<string, mixed>|null".to_string())
} else {
("array".to_string(), "array<string, mixed>".to_string())
}
}
}
}
fn append_parameter_docs(&self, output: &mut String, body_doc_type: &Option<String>, return_doc_type: &str) {
if let Some(body_type_name) = body_doc_type {
output.push_str(&format!(" * @param {body_type_name} $body\n"));
}
output.push_str(&format!(" * @return {return_doc_type}\n"));
output.push_str(" */\n");
}
fn phpdoc_type_from_schema_ref(
&self,
schema_ref: &ReferenceOr<Schema>,
optional: bool,
inline_name: Option<&str>,
) -> String {
match schema_ref {
ReferenceOr::Item(schema) => self.schema_to_phpdoc_type(None, None, schema, optional, inline_name),
ReferenceOr::Reference { reference } => {
let mut base = self.extract_ref_name(reference).to_pascal_case();
if optional {
base.push_str("|null");
}
base
}
}
}
fn append_function_signature(
&self,
output: &mut String,
method_name: &str,
path_params: &[(String, String)],
query_params: &[(String, String, bool)],
body_type: &Option<String>,
return_type: &str,
) {
output.push_str(&format!(" public function {method_name}("));
let mut params = Vec::new();
for (param_name, param_type) in path_params {
params.push(format!("{param_type} ${param_name}"));
}
for (param_name, param_type, required) in query_params.iter().filter(|(_, _, required)| *required) {
let _ = required;
params.push(format!("{param_type} ${param_name}"));
}
if let Some(body_type_name) = body_type {
params.push(format!("{body_type_name} $body"));
}
for (param_name, param_type, required) in query_params.iter().filter(|(_, _, required)| !*required) {
let _ = required;
params.push(format!("{param_type} ${param_name} = null"));
}
output.push_str(¶ms.join(", "));
output.push_str(&format!("): {return_type}\n {{\n"));
}
fn append_function_body(&self, output: &mut String) {
output.push_str(" // TODO: Implement this endpoint\n");
output.push_str(" throw new \\RuntimeException('Not implemented');\n");
output.push_str(" }\n\n");
}
fn generate_main(&self) -> String {
format!(
r"
// Bootstrap Application
// This section shows how to initialize and run the application
/**
* Example using Slim Framework 4:
*
* require __DIR__ . '/vendor/autoload.php';
*
* use Slim\Factory\AppFactory;
*
* $app = AppFactory::create();
*
* // Register routes
* // Note: You'll need to manually extract route attributes and register them
* // or use a library that supports PHP 8 attributes
*
* $app->run();
*/
/**
* Example using Symfony:
*
* The #[Route] attributes are compatible with Symfony's routing.
* Simply ensure the controllers are registered as services and
* Symfony will automatically discover the routes.
*/
/**
* Application Information:
* Title: {}
* Version: {}
* OpenAPI: {}
*/
",
self.spec.info.title, self.spec.info.version, self.spec.openapi
)
}
}