Skip to main content

spikard_cli/codegen/
php.rs

1//! PHP code generation from `OpenAPI` schemas
2
3use super::PhpDtoStyle;
4use anyhow::Result;
5use heck::{ToPascalCase, ToSnakeCase};
6use openapiv3::{
7    OpenAPI, Operation, Parameter, ParameterData, ParameterSchemaOrContent, ReferenceOr, Schema, SchemaKind,
8    StringFormat, Type, VariantOrUnknownOrEmpty,
9};
10use std::collections::BTreeSet;
11
12pub struct PhpGenerator {
13    spec: OpenAPI,
14    style: PhpDtoStyle,
15}
16
17impl PhpGenerator {
18    #[must_use]
19    pub const fn new(spec: OpenAPI, style: PhpDtoStyle) -> Self {
20        Self { spec, style }
21    }
22
23    /// Generate complete PHP code from `OpenAPI` spec
24    /// Returns the complete PHP file as a string
25    pub fn generate(&self) -> Result<String> {
26        let mut output = String::new();
27        match self.style {
28            PhpDtoStyle::ReadonlyClass => {}
29        }
30
31        output.push_str(&self.generate_header());
32        output.push_str(&self.generate_models()?);
33        output.push_str(&self.generate_controllers()?);
34        output.push_str(&self.generate_main());
35
36        Ok(output)
37    }
38
39    /// Generate PHP file header with strict types and namespace declaration
40    fn generate_header(&self) -> String {
41        let title = Self::escape_php_string(&self.spec.info.title);
42        let openapi = Self::escape_php_string(&self.spec.openapi);
43
44        format!(
45            "<?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"
46        )
47    }
48
49    fn generate_models(&self) -> Result<String> {
50        let mut output = String::new();
51        output.push_str("// Schema Models\n\n");
52        let mut emitted_models = BTreeSet::new();
53        let mut emitted_enums = BTreeSet::new();
54
55        if self.uses_uuid_types() {
56            output.push_str(&self.generate_uuid_value_class());
57            output.push('\n');
58        }
59
60        if let Some(components) = &self.spec.components {
61            for (name, schema_ref) in &components.schemas {
62                match schema_ref {
63                    ReferenceOr::Item(schema) => {
64                        self.generate_schema_family(
65                            &name.to_pascal_case(),
66                            schema,
67                            &mut emitted_models,
68                            &mut emitted_enums,
69                            &mut output,
70                        )?;
71                    }
72                    ReferenceOr::Reference { .. } => {
73                        continue;
74                    }
75                }
76            }
77        }
78
79        self.generate_inline_route_models(&mut emitted_models, &mut emitted_enums, &mut output)?;
80        self.generate_inline_parameter_enums(&mut emitted_enums, &mut output)?;
81
82        Ok(output)
83    }
84
85    /// Generate a PHP model class for an `OpenAPI` schema
86    fn generate_model_class(&self, class_name: &str, schema: &Schema) -> Result<String> {
87        let mut output = String::new();
88
89        self.append_php_doc(&mut output, &schema.schema_data.description, &class_name);
90
91        output.push_str(&format!("readonly class {class_name}\n{{\n"));
92
93        match &schema.schema_kind {
94            SchemaKind::Type(Type::Object(obj)) => {
95                if obj.properties.is_empty() {
96                    output.push_str("    // Empty schema\n");
97                } else {
98                    self.append_constructor(&mut output, class_name, obj)?;
99                }
100            }
101            _ => {
102                output.push_str("    // Unsupported schema type\n");
103            }
104        }
105
106        output.push_str("}\n");
107
108        Ok(output)
109    }
110
111    fn generate_enum_class(&self, enum_name: &str, schema: &Schema) -> Result<String> {
112        let mut output = String::new();
113
114        self.append_php_doc(&mut output, &schema.schema_data.description, enum_name);
115        output.push_str(&format!("enum {enum_name}: string\n{{\n"));
116
117        let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind else {
118            output.push_str("}\n");
119            return Ok(output);
120        };
121
122        for value in string_type.enumeration.iter().flatten() {
123            output.push_str(&format!(
124                "    case {} = '{}';\n",
125                Self::enum_case_name(value),
126                Self::escape_php_string(value)
127            ));
128        }
129
130        output.push_str("}\n");
131        Ok(output)
132    }
133
134    fn generate_uuid_value_class(&self) -> String {
135        String::from(
136            "/**\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",
137        )
138    }
139
140    fn generate_schema_family(
141        &self,
142        class_name: &str,
143        schema: &Schema,
144        emitted_models: &mut BTreeSet<String>,
145        emitted_enums: &mut BTreeSet<String>,
146        output: &mut String,
147    ) -> Result<()> {
148        if Self::is_enum_schema(schema) {
149            return self.generate_enum_family(class_name, schema, emitted_enums, output);
150        }
151
152        if !emitted_models.insert(class_name.to_string()) {
153            return Ok(());
154        }
155
156        self.generate_nested_model_families(class_name, schema, emitted_models, emitted_enums, output)?;
157        output.push_str(&self.generate_model_class(class_name, schema)?);
158        output.push('\n');
159        Ok(())
160    }
161
162    fn generate_enum_family(
163        &self,
164        enum_name: &str,
165        schema: &Schema,
166        emitted_enums: &mut BTreeSet<String>,
167        output: &mut String,
168    ) -> Result<()> {
169        if !emitted_enums.insert(enum_name.to_string()) {
170            return Ok(());
171        }
172
173        output.push_str(&self.generate_enum_class(enum_name, schema)?);
174        output.push('\n');
175        Ok(())
176    }
177
178    fn generate_nested_model_families(
179        &self,
180        parent_class_name: &str,
181        schema: &Schema,
182        emitted_models: &mut BTreeSet<String>,
183        emitted_enums: &mut BTreeSet<String>,
184        output: &mut String,
185    ) -> Result<()> {
186        match &schema.schema_kind {
187            SchemaKind::Type(Type::Object(obj)) => {
188                for (prop_name, prop_schema_ref) in &obj.properties {
189                    match prop_schema_ref {
190                        ReferenceOr::Item(prop_schema) => {
191                            if let Some(enum_name) = self.inline_enum_name(parent_class_name, prop_name, prop_schema) {
192                                self.generate_enum_family(&enum_name, prop_schema, emitted_enums, output)?;
193                            }
194                            if let Some(class_name) = self.inline_model_name(parent_class_name, prop_name, prop_schema)
195                            {
196                                self.generate_schema_family(
197                                    &class_name,
198                                    prop_schema,
199                                    emitted_models,
200                                    emitted_enums,
201                                    output,
202                                )?;
203                            }
204                            if let Some(array_item_name) =
205                                self.inline_array_item_model_name(parent_class_name, prop_name, prop_schema)
206                                && let Some(item_schema) = Self::inline_array_item_schema(prop_schema)
207                            {
208                                self.generate_schema_family(
209                                    &array_item_name,
210                                    item_schema,
211                                    emitted_models,
212                                    emitted_enums,
213                                    output,
214                                )?;
215                            }
216                            if let Some(array_item_enum_name) =
217                                self.inline_array_item_enum_name(parent_class_name, prop_name, prop_schema)
218                                && let Some(item_schema) = Self::inline_array_item_schema(prop_schema)
219                            {
220                                self.generate_enum_family(&array_item_enum_name, item_schema, emitted_enums, output)?;
221                            }
222                        }
223                        ReferenceOr::Reference { .. } => {}
224                    }
225                }
226            }
227            SchemaKind::AllOf { all_of } => {
228                for schema_ref in all_of {
229                    match schema_ref {
230                        ReferenceOr::Item(item_schema) => self.generate_nested_model_families(
231                            parent_class_name,
232                            item_schema,
233                            emitted_models,
234                            emitted_enums,
235                            output,
236                        )?,
237                        ReferenceOr::Reference { reference } => {
238                            if let Some(item_schema) = self.resolve_schema_reference(reference) {
239                                self.generate_nested_model_families(
240                                    parent_class_name,
241                                    item_schema,
242                                    emitted_models,
243                                    emitted_enums,
244                                    output,
245                                )?;
246                            }
247                        }
248                    }
249                }
250            }
251            _ => {}
252        }
253
254        Ok(())
255    }
256
257    fn generate_inline_route_models(
258        &self,
259        emitted_models: &mut BTreeSet<String>,
260        emitted_enums: &mut BTreeSet<String>,
261        output: &mut String,
262    ) -> Result<()> {
263        for path_item_ref in self.spec.paths.paths.values() {
264            let ReferenceOr::Item(path_item) = path_item_ref else {
265                continue;
266            };
267
268            for operation in [
269                path_item.get.as_ref(),
270                path_item.post.as_ref(),
271                path_item.put.as_ref(),
272                path_item.delete.as_ref(),
273                path_item.patch.as_ref(),
274            ]
275            .into_iter()
276            .flatten()
277            {
278                if let Some((class_name, schema)) = self.inline_request_body_model(operation) {
279                    self.generate_schema_family(&class_name, schema, emitted_models, emitted_enums, output)?;
280                }
281                if let Some((class_name, schema)) = self.inline_response_model(operation) {
282                    self.generate_schema_family(&class_name, schema, emitted_models, emitted_enums, output)?;
283                }
284            }
285        }
286
287        Ok(())
288    }
289
290    fn generate_inline_parameter_enums(&self, emitted_enums: &mut BTreeSet<String>, output: &mut String) -> Result<()> {
291        for path_item_ref in self.spec.paths.paths.values() {
292            let ReferenceOr::Item(path_item) = path_item_ref else {
293                continue;
294            };
295
296            for operation in [
297                path_item.get.as_ref(),
298                path_item.post.as_ref(),
299                path_item.put.as_ref(),
300                path_item.delete.as_ref(),
301                path_item.patch.as_ref(),
302            ]
303            .into_iter()
304            .flatten()
305            {
306                let operation_id = operation.operation_id.as_deref();
307
308                for parameter_ref in &operation.parameters {
309                    let ReferenceOr::Item(parameter) = parameter_ref else {
310                        continue;
311                    };
312
313                    let parameter_data = match parameter {
314                        Parameter::Path { parameter_data, .. }
315                        | Parameter::Query { parameter_data, .. }
316                        | Parameter::Header { parameter_data, .. }
317                        | Parameter::Cookie { parameter_data, .. } => parameter_data,
318                    };
319
320                    let ParameterSchemaOrContent::Schema(ReferenceOr::Item(schema)) = &parameter_data.format else {
321                        continue;
322                    };
323
324                    let Some(enum_name) = self.parameter_enum_name(operation_id, &parameter_data.name, schema) else {
325                        continue;
326                    };
327
328                    self.generate_enum_family(&enum_name, schema, emitted_enums, output)?;
329                }
330            }
331        }
332
333        Ok(())
334    }
335
336    /// Append `PHPDoc` comment block to output
337    fn append_php_doc(&self, output: &mut String, description: &Option<String>, class_name: &str) {
338        output.push_str("/**\n");
339        if let Some(desc) = description {
340            let escaped = Self::escape_php_string(desc);
341            output.push_str(&format!(" * {escaped}\n"));
342        } else {
343            output.push_str(&format!(" * {class_name} model\n"));
344        }
345        output.push_str(" */\n");
346    }
347
348    /// Append constructor method with typed properties
349    fn append_constructor(&self, output: &mut String, class_name: &str, obj: &openapiv3::ObjectType) -> Result<()> {
350        let (property_lines, property_docs) = self.build_constructor_params(class_name, obj)?;
351
352        if !property_docs.is_empty() {
353            output.push_str("    /**\n");
354            for doc_line in &property_docs {
355                output.push_str(doc_line);
356            }
357            output.push_str("     */\n");
358        }
359
360        output.push_str("    public function __construct(\n");
361
362        let props_str = property_lines.join("");
363        let props_str = props_str.trim_end_matches(",\n").to_string() + "\n";
364        output.push_str(&props_str);
365
366        output.push_str("    ) {}\n");
367        Ok(())
368    }
369
370    /// Build constructor parameter declarations for object properties
371    /// Partitions properties so required parameters come first (PHP 8.1+ requirement)
372    fn build_constructor_params(
373        &self,
374        class_name: &str,
375        obj: &openapiv3::ObjectType,
376    ) -> Result<(Vec<String>, Vec<String>)> {
377        let mut required_props = Vec::new();
378        let mut optional_props = Vec::new();
379        let mut required_docs = Vec::new();
380        let mut optional_docs = Vec::new();
381
382        // First pass: partition properties into required and optional
383        for (prop_name, prop_schema_ref) in &obj.properties {
384            let is_required = obj.required.contains(prop_name);
385            let field_name = Self::to_camel_case(prop_name);
386
387            let (type_hint, nullable, phpdoc_type) = match prop_schema_ref {
388                ReferenceOr::Item(prop_schema) => {
389                    let (type_hint, nullable) =
390                        self.schema_to_php_type(Some(class_name), Some(prop_name), prop_schema, !is_required, None);
391                    let phpdoc_type =
392                        self.schema_to_phpdoc_type(Some(class_name), Some(prop_name), prop_schema, !is_required, None);
393                    (type_hint, nullable, phpdoc_type)
394                }
395                ReferenceOr::Reference { reference } => {
396                    let ref_name = self.extract_ref_name(reference);
397                    let ref_type = ref_name.to_pascal_case();
398                    if is_required {
399                        (ref_type.clone(), false, ref_type)
400                    } else {
401                        (format!("?{ref_type}"), true, format!("{ref_type}|null"))
402                    }
403                }
404            };
405
406            let prop_line = self.build_property_line(&type_hint, &field_name, is_required, nullable);
407            let doc_line = format!("     * @param {phpdoc_type} ${field_name}\n");
408
409            if is_required {
410                required_props.push(prop_line);
411                required_docs.push(doc_line);
412            } else {
413                optional_props.push(prop_line);
414                optional_docs.push(doc_line);
415            }
416        }
417
418        // Combine: required first, then optional
419        let mut property_lines = required_props;
420        property_lines.extend(optional_props);
421        let mut property_docs = required_docs;
422        property_docs.extend(optional_docs);
423
424        Ok((property_lines, property_docs))
425    }
426
427    /// Build a single property parameter line for constructor
428    fn build_property_line(&self, type_hint: &str, field_name: &str, is_required: bool, nullable: bool) -> String {
429        if is_required {
430            format!("        public {type_hint} ${field_name},\n")
431        } else if nullable {
432            format!("        public {type_hint} ${field_name} = null,\n")
433        } else {
434            format!("        public ?{type_hint} ${field_name} = null,\n")
435        }
436    }
437
438    /// Escape PHP string content for safe output
439    /// Handles special characters in PHP string context
440    fn escape_php_string(s: &str) -> String {
441        s.chars()
442            .flat_map(|c| match c {
443                '\\' => vec!['\\', '\\'],
444                '\'' => vec!['\\', '\''],
445                '\n' => vec!['\\', 'n'],
446                '\r' => vec!['\\', 'r'],
447                '\t' => vec!['\\', 't'],
448                _ => vec![c],
449            })
450            .collect()
451    }
452
453    /// Extract the last component of a JSON reference path
454    fn extract_ref_name(&self, reference: &str) -> String {
455        reference.split('/').next_back().unwrap_or("UnknownType").to_string()
456    }
457
458    fn resolve_schema_reference<'a>(&'a self, reference: &str) -> Option<&'a Schema> {
459        let name = reference.split('/').next_back()?;
460        self.spec
461            .components
462            .as_ref()?
463            .schemas
464            .get(name)
465            .and_then(|schema_ref| match schema_ref {
466                ReferenceOr::Item(schema) => Some(schema),
467                ReferenceOr::Reference { .. } => None,
468            })
469    }
470
471    fn uses_uuid_types(&self) -> bool {
472        self.spec.components.as_ref().is_some_and(|components| {
473            components
474                .schemas
475                .values()
476                .filter_map(|schema_ref| match schema_ref {
477                    ReferenceOr::Item(schema) => Some(schema),
478                    ReferenceOr::Reference { .. } => None,
479                })
480                .any(|schema| self.schema_uses_uuid_type(schema))
481        }) || self.spec.paths.paths.values().any(|path_item_ref| {
482            let ReferenceOr::Item(path_item) = path_item_ref else {
483                return false;
484            };
485
486            [
487                path_item.get.as_ref(),
488                path_item.post.as_ref(),
489                path_item.put.as_ref(),
490                path_item.delete.as_ref(),
491                path_item.patch.as_ref(),
492            ]
493            .into_iter()
494            .flatten()
495            .any(|operation| self.operation_uses_uuid_type(operation))
496        })
497    }
498
499    fn operation_uses_uuid_type(&self, operation: &Operation) -> bool {
500        operation.parameters.iter().any(|parameter_ref| {
501            let ReferenceOr::Item(parameter) = parameter_ref else {
502                return false;
503            };
504            match parameter {
505                Parameter::Path { parameter_data, .. }
506                | Parameter::Query { parameter_data, .. }
507                | Parameter::Header { parameter_data, .. }
508                | Parameter::Cookie { parameter_data, .. } => {
509                    let ParameterSchemaOrContent::Schema(schema_ref) = &parameter_data.format else {
510                        return false;
511                    };
512                    match schema_ref {
513                        ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
514                        ReferenceOr::Reference { reference } => self
515                            .resolve_schema_reference(reference)
516                            .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
517                    }
518                }
519            }
520        }) || operation
521            .request_body
522            .as_ref()
523            .and_then(|body_ref| match body_ref {
524                ReferenceOr::Item(request_body) => request_body
525                    .content
526                    .get("application/json")
527                    .and_then(|media_type| media_type.schema.as_ref())
528                    .and_then(|schema_ref| match schema_ref {
529                        ReferenceOr::Item(schema) => Some(schema),
530                        ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
531                    }),
532                ReferenceOr::Reference { .. } => None,
533            })
534            .is_some_and(|schema| self.schema_uses_uuid_type(schema))
535    }
536
537    fn schema_uses_uuid_type(&self, schema: &Schema) -> bool {
538        let SchemaKind::Type(ty) = &schema.schema_kind else {
539            if let SchemaKind::AllOf { all_of } = &schema.schema_kind {
540                return all_of.iter().any(|schema_ref| match schema_ref {
541                    ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
542                    ReferenceOr::Reference { reference } => self
543                        .resolve_schema_reference(reference)
544                        .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
545                });
546            }
547            return false;
548        };
549
550        match ty {
551            Type::String(string_type) => {
552                matches!(&string_type.format, VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid")
553            }
554            Type::Array(array_type) => array_type
555                .items
556                .as_ref()
557                .and_then(|item_schema| match item_schema {
558                    ReferenceOr::Item(schema) => Some(schema.as_ref()),
559                    ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
560                })
561                .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
562            Type::Object(object_type) => object_type.properties.values().any(|schema_ref| match schema_ref {
563                ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
564                ReferenceOr::Reference { reference } => self
565                    .resolve_schema_reference(reference)
566                    .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
567            }),
568            _ => false,
569        }
570    }
571
572    /// Convert `snake_case` or kebab-case to camelCase
573    fn to_camel_case(s: &str) -> String {
574        let snake = s.to_snake_case();
575        let mut chars = snake.chars();
576        match chars.next() {
577            None => String::new(),
578            Some(first) => {
579                let mut result = first.to_lowercase().collect::<String>();
580                let mut capitalize_next = false;
581                for c in chars {
582                    if c == '_' {
583                        capitalize_next = true;
584                    } else if capitalize_next {
585                        result.push_str(&c.to_uppercase().to_string());
586                        capitalize_next = false;
587                    } else {
588                        result.push(c);
589                    }
590                }
591                result
592            }
593        }
594    }
595
596    fn inline_model_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
597        match &schema.schema_kind {
598            SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
599                Some(format!("{parent_class_name}{}", field_name.to_pascal_case()))
600            }
601            _ => None,
602        }
603    }
604
605    fn inline_enum_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
606        Self::is_enum_schema(schema).then(|| format!("{parent_class_name}{}", field_name.to_pascal_case()))
607    }
608
609    fn inline_array_item_model_name(
610        &self,
611        parent_class_name: &str,
612        field_name: &str,
613        schema: &Schema,
614    ) -> Option<String> {
615        let item_schema = Self::inline_array_item_schema(schema)?;
616        match &item_schema.schema_kind {
617            SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
618                Some(format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
619            }
620            _ => None,
621        }
622    }
623
624    fn inline_array_item_enum_name(
625        &self,
626        parent_class_name: &str,
627        field_name: &str,
628        schema: &Schema,
629    ) -> Option<String> {
630        let item_schema = Self::inline_array_item_schema(schema)?;
631        Self::is_enum_schema(item_schema).then(|| format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
632    }
633
634    fn inline_array_item_schema(schema: &Schema) -> Option<&Schema> {
635        match &schema.schema_kind {
636            SchemaKind::Type(Type::Array(array_type)) => match &array_type.items {
637                Some(ReferenceOr::Item(item_schema)) => Some(item_schema),
638                _ => None,
639            },
640            _ => None,
641        }
642    }
643
644    fn parameter_enum_name(&self, operation_id: Option<&str>, parameter_name: &str, schema: &Schema) -> Option<String> {
645        Self::is_enum_schema(schema).then(|| {
646            let operation_prefix = operation_id
647                .map(str::to_pascal_case)
648                .unwrap_or_else(|| "Operation".to_string());
649            format!("{operation_prefix}{}", parameter_name.to_pascal_case())
650        })
651    }
652
653    fn inline_request_body_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
654        let operation_id = operation.operation_id.as_deref()?;
655        let request_body = operation.request_body.as_ref()?;
656
657        match request_body {
658            ReferenceOr::Item(body) => {
659                let schema_ref = body.content.get("application/json")?.schema.as_ref()?;
660                match schema_ref {
661                    ReferenceOr::Item(schema) if Self::is_named_inline_object_schema(schema) => {
662                        Some((format!("{}RequestBody", operation_id.to_pascal_case()), schema))
663                    }
664                    _ => None,
665                }
666            }
667            ReferenceOr::Reference { .. } => None,
668        }
669    }
670
671    fn inline_response_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
672        use openapiv3::StatusCode;
673
674        let operation_id = operation.operation_id.as_deref()?;
675        let response = operation
676            .responses
677            .responses
678            .get(&StatusCode::Code(200))
679            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
680            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)))?;
681
682        match response {
683            ReferenceOr::Item(response) => {
684                let schema_ref = response.content.get("application/json")?.schema.as_ref()?;
685                match schema_ref {
686                    ReferenceOr::Item(schema) if Self::is_named_inline_object_schema(schema) => {
687                        Some((format!("{}ResponseBody", operation_id.to_pascal_case()), schema))
688                    }
689                    _ => None,
690                }
691            }
692            ReferenceOr::Reference { .. } => None,
693        }
694    }
695
696    fn is_named_inline_object_schema(schema: &Schema) -> bool {
697        matches!(&schema.schema_kind, SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty())
698    }
699
700    fn is_enum_schema(schema: &Schema) -> bool {
701        matches!(&schema.schema_kind, SchemaKind::Type(Type::String(string_type)) if !string_type.enumeration.is_empty())
702    }
703
704    fn enum_case_name(value: &str) -> String {
705        let mut name = value
706            .chars()
707            .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
708            .collect::<String>()
709            .to_pascal_case();
710        if name.is_empty() {
711            name = "Value".to_string();
712        }
713        if name.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
714            name.insert_str(0, "Value");
715        }
716        name
717    }
718
719    fn enum_type_name(
720        &self,
721        parent_class_name: Option<&str>,
722        field_name: Option<&str>,
723        schema: &Schema,
724        inline_name: Option<&str>,
725    ) -> Option<String> {
726        Self::is_enum_schema(schema).then(|| {
727            inline_name
728                .map(ToOwned::to_owned)
729                .or_else(|| {
730                    parent_class_name
731                        .zip(field_name)
732                        .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
733                })
734                .unwrap_or_else(|| "StringBackedEnum".to_string())
735        })
736    }
737
738    fn string_format_php_type(&self, string_type: &openapiv3::StringType) -> String {
739        match &string_type.format {
740            VariantOrUnknownOrEmpty::Item(StringFormat::Date | StringFormat::DateTime) => {
741                "\\DateTimeImmutable".to_string()
742            }
743            VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "UuidValue".to_string(),
744            _ => "string".to_string(),
745        }
746    }
747
748    /// Extract type name from a schema reference or inline schema
749    fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>, inline_name: Option<&str>) -> String {
750        match schema_ref {
751            ReferenceOr::Reference { reference } => self.extract_ref_name(reference).to_pascal_case(),
752            ReferenceOr::Item(schema) => self.schema_to_php_type(None, None, schema, false, inline_name).0,
753        }
754    }
755
756    /// Extract request body type from operation if present
757    fn extract_request_body_type(&self, operation: &Operation) -> Option<String> {
758        operation.request_body.as_ref().and_then(|body_ref| match body_ref {
759            ReferenceOr::Item(request_body) => self.extract_json_schema_type(
760                request_body.content.get("application/json"),
761                operation
762                    .operation_id
763                    .as_deref()
764                    .map(|id| format!("{}RequestBody", id.to_pascal_case()))
765                    .as_deref(),
766            ),
767            ReferenceOr::Reference { reference } => {
768                let ref_name = self.extract_ref_name(reference);
769                Some(ref_name.to_pascal_case())
770            }
771        })
772    }
773
774    /// Extract JSON schema type from media type content
775    fn extract_json_schema_type(
776        &self,
777        media_type: Option<&openapiv3::MediaType>,
778        inline_name: Option<&str>,
779    ) -> Option<String> {
780        media_type.and_then(|mt| {
781            mt.schema
782                .as_ref()
783                .map(|schema_ref| self.extract_type_from_schema_ref(schema_ref, inline_name))
784        })
785    }
786
787    fn extract_json_schema_doc_type(
788        &self,
789        media_type: Option<&openapiv3::MediaType>,
790        inline_name: Option<&str>,
791    ) -> Option<String> {
792        media_type.and_then(|mt| {
793            mt.schema
794                .as_ref()
795                .map(|schema_ref| self.phpdoc_type_from_schema_ref(schema_ref, false, inline_name))
796        })
797    }
798
799    /// Extract response type from operation (looks for 200/201 responses)
800    fn extract_response_type(&self, operation: &Operation) -> String {
801        use openapiv3::StatusCode;
802
803        let response = operation
804            .responses
805            .responses
806            .get(&StatusCode::Code(200))
807            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
808            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
809
810        response
811            .and_then(|response_ref| {
812                self.extract_response_type_from_ref(
813                    response_ref,
814                    operation
815                        .operation_id
816                        .as_deref()
817                        .map(|id| format!("{}ResponseBody", id.to_pascal_case()))
818                        .as_deref(),
819                )
820            })
821            .unwrap_or_else(|| "array".to_string())
822    }
823
824    fn extract_request_body_doc_type(&self, operation: &Operation) -> Option<String> {
825        operation.request_body.as_ref().and_then(|body_ref| match body_ref {
826            ReferenceOr::Item(request_body) => self.extract_json_schema_doc_type(
827                request_body.content.get("application/json"),
828                operation
829                    .operation_id
830                    .as_deref()
831                    .map(|id| format!("{}RequestBody", id.to_pascal_case()))
832                    .as_deref(),
833            ),
834            ReferenceOr::Reference { reference } => {
835                let ref_name = self.extract_ref_name(reference);
836                Some(ref_name.to_pascal_case())
837            }
838        })
839    }
840
841    fn extract_response_doc_type(&self, operation: &Operation) -> String {
842        use openapiv3::StatusCode;
843
844        let response = operation
845            .responses
846            .responses
847            .get(&StatusCode::Code(200))
848            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
849            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
850
851        response
852            .and_then(|response_ref| {
853                self.extract_response_doc_type_from_ref(
854                    response_ref,
855                    operation
856                        .operation_id
857                        .as_deref()
858                        .map(|id| format!("{}ResponseBody", id.to_pascal_case()))
859                        .as_deref(),
860                )
861            })
862            .unwrap_or_else(|| "array<string, mixed>".to_string())
863    }
864
865    /// Extract response type from a single response reference
866    fn extract_response_type_from_ref(
867        &self,
868        response_ref: &ReferenceOr<openapiv3::Response>,
869        inline_name: Option<&str>,
870    ) -> Option<String> {
871        match response_ref {
872            ReferenceOr::Item(response) => {
873                let media_type = response.content.get("application/json")?;
874                let schema_ref = media_type.schema.as_ref()?;
875                Some(self.extract_type_from_schema_ref(schema_ref, inline_name))
876            }
877            ReferenceOr::Reference { reference } => {
878                let ref_name = self.extract_ref_name(reference);
879                Some(ref_name.to_pascal_case())
880            }
881        }
882    }
883
884    fn extract_response_doc_type_from_ref(
885        &self,
886        response_ref: &ReferenceOr<openapiv3::Response>,
887        inline_name: Option<&str>,
888    ) -> Option<String> {
889        match response_ref {
890            ReferenceOr::Item(response) => {
891                let media_type = response.content.get("application/json")?;
892                let schema_ref = media_type.schema.as_ref()?;
893                Some(self.phpdoc_type_from_schema_ref(schema_ref, false, inline_name))
894            }
895            ReferenceOr::Reference { reference } => {
896                let ref_name = self.extract_ref_name(reference);
897                Some(ref_name.to_pascal_case())
898            }
899        }
900    }
901
902    /// Convert `OpenAPI` schema to PHP type hint
903    /// Returns (`type_hint`, `is_already_nullable`)
904    /// Uses native PHP type syntax for type hints (valid in PHP 7.4+)
905    fn schema_to_php_type(
906        &self,
907        parent_class_name: Option<&str>,
908        field_name: Option<&str>,
909        schema: &Schema,
910        optional: bool,
911        inline_name: Option<&str>,
912    ) -> (String, bool) {
913        let base_type = if let Some(enum_type) = self.enum_type_name(parent_class_name, field_name, schema, inline_name)
914        {
915            enum_type
916        } else {
917            match &schema.schema_kind {
918                SchemaKind::Type(Type::String(string_type)) => self.string_format_php_type(string_type),
919                SchemaKind::Type(Type::Number(_)) => "float".to_string(),
920                SchemaKind::Type(Type::Integer(_)) => "int".to_string(),
921                SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
922                SchemaKind::Type(Type::Array(_)) => {
923                    // Use plain 'array' for native type hints; generic syntax is only for PHPDoc
924                    "array".to_string()
925                }
926                SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => inline_name
927                    .map(ToOwned::to_owned)
928                    .or_else(|| {
929                        parent_class_name
930                            .zip(field_name)
931                            .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
932                    })
933                    .unwrap_or_else(|| "array".to_string()),
934                SchemaKind::Type(Type::Object(_)) => "array".to_string(),
935                _ => "mixed".to_string(),
936            }
937        };
938
939        if optional {
940            (format!("?{base_type}"), true)
941        } else {
942            (base_type, false)
943        }
944    }
945
946    /// Generate `PHPDoc` type annotation for arrays
947    #[allow(dead_code)]
948    fn schema_to_phpdoc_type(
949        &self,
950        parent_class_name: Option<&str>,
951        field_name: Option<&str>,
952        schema: &Schema,
953        optional: bool,
954        inline_name: Option<&str>,
955    ) -> String {
956        let base_type = if let Some(enum_type) = self.enum_type_name(parent_class_name, field_name, schema, inline_name)
957        {
958            enum_type
959        } else {
960            match &schema.schema_kind {
961                SchemaKind::Type(Type::String(string_type)) => {
962                    if string_type.enumeration.is_empty() {
963                        self.string_format_php_type(string_type)
964                    } else {
965                        string_type
966                            .enumeration
967                            .iter()
968                            .flatten()
969                            .map(|value| format!("'{}'", Self::escape_php_string(value)))
970                            .collect::<Vec<_>>()
971                            .join("|")
972                    }
973                }
974                SchemaKind::Type(Type::Number(number_type)) => {
975                    if number_type.enumeration.is_empty() {
976                        "float".to_string()
977                    } else {
978                        number_type
979                            .enumeration
980                            .iter()
981                            .flatten()
982                            .map(ToString::to_string)
983                            .collect::<Vec<_>>()
984                            .join("|")
985                    }
986                }
987                SchemaKind::Type(Type::Integer(integer_type)) => {
988                    if integer_type.enumeration.is_empty() {
989                        "int".to_string()
990                    } else {
991                        integer_type
992                            .enumeration
993                            .iter()
994                            .flatten()
995                            .map(ToString::to_string)
996                            .collect::<Vec<_>>()
997                            .join("|")
998                    }
999                }
1000                SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
1001                SchemaKind::Type(Type::Array(arr)) => {
1002                    let item_type = match &arr.items {
1003                        Some(ReferenceOr::Item(item_schema)) => self.schema_to_phpdoc_type(
1004                            None,
1005                            None,
1006                            item_schema,
1007                            false,
1008                            parent_class_name
1009                                .zip(field_name)
1010                                .and_then(|(parent, field)| {
1011                                    self.inline_array_item_model_name(parent, field, schema)
1012                                        .or_else(|| self.inline_array_item_enum_name(parent, field, schema))
1013                                })
1014                                .as_deref(),
1015                        ),
1016                        Some(ReferenceOr::Reference { reference }) => {
1017                            let ref_name = reference.split('/').next_back().unwrap();
1018                            ref_name.to_pascal_case()
1019                        }
1020                        None => "mixed".to_string(),
1021                    };
1022                    format!("list<{item_type}>")
1023                }
1024                SchemaKind::Type(Type::Object(obj)) => {
1025                    if obj.properties.is_empty() {
1026                        "array<string, mixed>".to_string()
1027                    } else {
1028                        inline_name
1029                            .map(ToOwned::to_owned)
1030                            .or_else(|| {
1031                                parent_class_name
1032                                    .zip(field_name)
1033                                    .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
1034                            })
1035                            .unwrap_or_else(|| {
1036                                let mut entries = Vec::new();
1037                                for (prop_name, prop_schema_ref) in &obj.properties {
1038                                    let is_required = obj.required.contains(prop_name);
1039                                    let prop_type = match prop_schema_ref {
1040                                        ReferenceOr::Item(prop_schema) => self.schema_to_phpdoc_type(
1041                                            parent_class_name,
1042                                            Some(prop_name),
1043                                            prop_schema,
1044                                            !is_required,
1045                                            None,
1046                                        ),
1047                                        ReferenceOr::Reference { reference } => {
1048                                            let ref_name = reference.split('/').next_back().unwrap();
1049                                            let mut base = ref_name.to_pascal_case();
1050                                            if !is_required {
1051                                                base.push_str("|null");
1052                                            }
1053                                            base
1054                                        }
1055                                    };
1056                                    if is_required {
1057                                        entries.push(format!("{prop_name}: {prop_type}"));
1058                                    } else {
1059                                        entries.push(format!("{prop_name}?: {prop_type}"));
1060                                    }
1061                                }
1062                                format!("array{{{}}}", entries.join(", "))
1063                            })
1064                    }
1065                }
1066                _ => "mixed".to_string(),
1067            }
1068        };
1069
1070        if optional {
1071            format!("{base_type}|null")
1072        } else {
1073            base_type
1074        }
1075    }
1076
1077    /// Generate all controller classes from API paths
1078    fn generate_controllers(&self) -> Result<String> {
1079        let mut output = String::new();
1080        output.push_str("\n// Controller Classes\n\n");
1081
1082        let controllers = self.group_operations_by_controller();
1083
1084        for (controller_name, routes) in controllers {
1085            output.push_str(&self.generate_controller_class(&controller_name, &routes)?);
1086            output.push('\n');
1087        }
1088
1089        Ok(output)
1090    }
1091
1092    /// Group operations by controller name based on API path
1093    fn group_operations_by_controller(&self) -> std::collections::HashMap<String, Vec<(String, String, Operation)>> {
1094        let mut controllers: std::collections::HashMap<String, Vec<(String, String, Operation)>> =
1095            std::collections::HashMap::new();
1096
1097        for (path, path_item_ref) in &self.spec.paths.paths {
1098            let path_item = match path_item_ref {
1099                ReferenceOr::Item(item) => item,
1100                ReferenceOr::Reference { .. } => continue,
1101            };
1102
1103            let controller_name = self.extract_controller_name(path);
1104            self.add_path_operations(&mut controllers, path, &controller_name, path_item);
1105        }
1106
1107        controllers
1108    }
1109
1110    /// Add all HTTP method operations from a path item to controllers map
1111    fn add_path_operations(
1112        &self,
1113        controllers: &mut std::collections::HashMap<String, Vec<(String, String, Operation)>>,
1114        path: &str,
1115        controller_name: &str,
1116        path_item: &openapiv3::PathItem,
1117    ) {
1118        let methods = [
1119            ("GET", &path_item.get),
1120            ("POST", &path_item.post),
1121            ("PUT", &path_item.put),
1122            ("DELETE", &path_item.delete),
1123            ("PATCH", &path_item.patch),
1124        ];
1125
1126        for (method, op_opt) in methods {
1127            if let Some(op) = op_opt {
1128                controllers.entry(controller_name.to_string()).or_default().push((
1129                    path.to_string(),
1130                    method.to_string(),
1131                    op.clone(),
1132                ));
1133            }
1134        }
1135    }
1136
1137    fn extract_controller_name(&self, path: &str) -> String {
1138        let segments: Vec<&str> = path
1139            .split('/')
1140            .filter(|s| !s.is_empty() && !s.starts_with('{'))
1141            .collect();
1142
1143        if let Some(first_segment) = segments.first() {
1144            format!("{}Controller", first_segment.to_pascal_case())
1145        } else {
1146            "DefaultController".to_string()
1147        }
1148    }
1149
1150    fn generate_controller_class(
1151        &self,
1152        controller_name: &str,
1153        routes: &[(String, String, Operation)],
1154    ) -> Result<String> {
1155        let mut output = String::new();
1156
1157        output.push_str("/**\n");
1158        output.push_str(&format!(" * {controller_name} - Generated controller for API routes\n"));
1159        output.push_str(" */\n");
1160
1161        output.push_str(&format!("class {controller_name}\n{{\n"));
1162
1163        for (path, method, operation) in routes {
1164            output.push_str(&self.generate_route_handler(path, method, operation)?);
1165        }
1166
1167        output.push_str("}\n");
1168
1169        Ok(output)
1170    }
1171
1172    /// Generate a single route handler method
1173    fn generate_route_handler(&self, path: &str, method: &str, operation: &Operation) -> Result<String> {
1174        let mut output = String::new();
1175        let method_name = self.extract_handler_method_name(operation, method, path);
1176        let (path_params, query_params, body_type, return_type) =
1177            self.extract_handler_parameters(operation, &mut output)?;
1178
1179        output.push_str(&format!(
1180            "    #[Route('{}', methods: ['{}'])]\n",
1181            path,
1182            method.to_uppercase()
1183        ));
1184
1185        self.append_function_signature(
1186            &mut output,
1187            &method_name,
1188            &path_params,
1189            &query_params,
1190            &body_type,
1191            &return_type,
1192        );
1193        self.append_function_body(&mut output);
1194
1195        Ok(output)
1196    }
1197
1198    /// Extract handler method name from operation ID or generate from path/method
1199    fn extract_handler_method_name(&self, operation: &Operation, method: &str, path: &str) -> String {
1200        operation
1201            .operation_id
1202            .as_ref()
1203            .map(|id| Self::to_camel_case(id))
1204            .unwrap_or_else(|| {
1205                Self::to_camel_case(&format!(
1206                    "{}_{}",
1207                    method.to_lowercase(),
1208                    path.replace('/', "_").replace(['{', '}'], "").trim_matches('_')
1209                ))
1210            })
1211    }
1212
1213    /// Extract and document handler parameters from operation
1214    #[allow(clippy::type_complexity)]
1215    fn extract_handler_parameters(
1216        &self,
1217        operation: &Operation,
1218        output: &mut String,
1219    ) -> Result<(
1220        Vec<(String, String)>,
1221        Vec<(String, String, bool)>,
1222        Option<String>,
1223        String,
1224    )> {
1225        output.push_str("    /**\n");
1226        self.append_operation_description(output, operation);
1227
1228        let (path_params, query_params) = self.extract_path_and_query_params(operation, output);
1229        let body_type = self.extract_request_body_type(operation);
1230        let return_type = self.extract_response_type(operation);
1231        let body_doc_type = self.extract_request_body_doc_type(operation);
1232        let return_doc_type = self.extract_response_doc_type(operation);
1233
1234        self.append_parameter_docs(output, &body_doc_type, &return_doc_type);
1235
1236        Ok((path_params, query_params, body_type, return_type))
1237    }
1238
1239    /// Append operation summary and description to `PHPDoc`
1240    fn append_operation_description(&self, output: &mut String, operation: &Operation) {
1241        if let Some(summary) = &operation.summary {
1242            output.push_str(&format!("     * {summary}\n"));
1243        }
1244        if let Some(description) = &operation.description {
1245            output.push_str(&format!("     * \n     * {description}\n"));
1246        }
1247        output.push_str("     * \n");
1248    }
1249
1250    /// Extract path and query parameters from operation
1251    #[allow(clippy::type_complexity)]
1252    fn extract_path_and_query_params(
1253        &self,
1254        operation: &Operation,
1255        output: &mut String,
1256    ) -> (Vec<(String, String)>, Vec<(String, String, bool)>) {
1257        let mut path_params = Vec::new();
1258        let mut query_params = Vec::new();
1259
1260        for param_ref in &operation.parameters {
1261            if let ReferenceOr::Item(param) = param_ref {
1262                self.process_parameter(
1263                    operation.operation_id.as_deref(),
1264                    param,
1265                    &mut path_params,
1266                    &mut query_params,
1267                    output,
1268                );
1269            }
1270        }
1271
1272        (path_params, query_params)
1273    }
1274
1275    /// Process a single parameter and add to path or query params
1276    fn process_parameter(
1277        &self,
1278        operation_id: Option<&str>,
1279        param: &Parameter,
1280        path_params: &mut Vec<(String, String)>,
1281        query_params: &mut Vec<(String, String, bool)>,
1282        output: &mut String,
1283    ) {
1284        match param {
1285            Parameter::Path { parameter_data, .. } => {
1286                let param_name = Self::to_camel_case(&parameter_data.name);
1287                let (native_type, phpdoc_type) = self.parameter_types(operation_id, parameter_data, false);
1288                path_params.push((param_name.clone(), native_type));
1289                output.push_str(&format!("     * @param {phpdoc_type} ${param_name}\n"));
1290            }
1291            Parameter::Query { parameter_data, .. } => {
1292                let param_name = Self::to_camel_case(&parameter_data.name);
1293                let required = parameter_data.required;
1294                let (native_type, phpdoc_type) = self.parameter_types(operation_id, parameter_data, !required);
1295                query_params.push((param_name.clone(), native_type, required));
1296                output.push_str(&format!("     * @param {phpdoc_type} ${param_name}\n"));
1297            }
1298            _ => {}
1299        }
1300    }
1301
1302    fn parameter_types(
1303        &self,
1304        operation_id: Option<&str>,
1305        parameter_data: &ParameterData,
1306        optional: bool,
1307    ) -> (String, String) {
1308        match &parameter_data.format {
1309            ParameterSchemaOrContent::Schema(schema_ref) => match schema_ref {
1310                ReferenceOr::Item(schema) => {
1311                    let inline_name = self.parameter_enum_name(operation_id, &parameter_data.name, schema);
1312                    let (native_type, _) =
1313                        self.schema_to_php_type(None, None, schema, optional, inline_name.as_deref());
1314                    let phpdoc_type = self.schema_to_phpdoc_type(None, None, schema, optional, inline_name.as_deref());
1315                    (native_type, phpdoc_type)
1316                }
1317                ReferenceOr::Reference { reference } => {
1318                    let base = self.extract_ref_name(reference).to_pascal_case();
1319                    if optional {
1320                        (format!("?{base}"), format!("{base}|null"))
1321                    } else {
1322                        (base.clone(), base)
1323                    }
1324                }
1325            },
1326            ParameterSchemaOrContent::Content(_) => {
1327                if optional {
1328                    ("?array".to_string(), "array<string, mixed>|null".to_string())
1329                } else {
1330                    ("array".to_string(), "array<string, mixed>".to_string())
1331                }
1332            }
1333        }
1334    }
1335
1336    /// Append parameter documentation to `PHPDoc`
1337    fn append_parameter_docs(&self, output: &mut String, body_doc_type: &Option<String>, return_doc_type: &str) {
1338        if let Some(body_type_name) = body_doc_type {
1339            output.push_str(&format!("     * @param {body_type_name} $body\n"));
1340        }
1341        output.push_str(&format!("     * @return {return_doc_type}\n"));
1342        output.push_str("     */\n");
1343    }
1344
1345    fn phpdoc_type_from_schema_ref(
1346        &self,
1347        schema_ref: &ReferenceOr<Schema>,
1348        optional: bool,
1349        inline_name: Option<&str>,
1350    ) -> String {
1351        match schema_ref {
1352            ReferenceOr::Item(schema) => self.schema_to_phpdoc_type(None, None, schema, optional, inline_name),
1353            ReferenceOr::Reference { reference } => {
1354                let mut base = self.extract_ref_name(reference).to_pascal_case();
1355                if optional {
1356                    base.push_str("|null");
1357                }
1358                base
1359            }
1360        }
1361    }
1362
1363    /// Append function signature to output
1364    fn append_function_signature(
1365        &self,
1366        output: &mut String,
1367        method_name: &str,
1368        path_params: &[(String, String)],
1369        query_params: &[(String, String, bool)],
1370        body_type: &Option<String>,
1371        return_type: &str,
1372    ) {
1373        output.push_str(&format!("    public function {method_name}("));
1374
1375        let mut params = Vec::new();
1376
1377        for (param_name, param_type) in path_params {
1378            params.push(format!("{param_type} ${param_name}"));
1379        }
1380
1381        for (param_name, param_type, required) in query_params.iter().filter(|(_, _, required)| *required) {
1382            let _ = required;
1383            params.push(format!("{param_type} ${param_name}"));
1384        }
1385
1386        if let Some(body_type_name) = body_type {
1387            params.push(format!("{body_type_name} $body"));
1388        }
1389
1390        for (param_name, param_type, required) in query_params.iter().filter(|(_, _, required)| !*required) {
1391            let _ = required;
1392            params.push(format!("{param_type} ${param_name} = null"));
1393        }
1394
1395        output.push_str(&params.join(", "));
1396        output.push_str(&format!("): {return_type}\n    {{\n"));
1397    }
1398
1399    /// Append function body (TODO stub)
1400    fn append_function_body(&self, output: &mut String) {
1401        output.push_str("        // TODO: Implement this endpoint\n");
1402        output.push_str("        throw new \\RuntimeException('Not implemented');\n");
1403        output.push_str("    }\n\n");
1404    }
1405
1406    fn generate_main(&self) -> String {
1407        format!(
1408            r"
1409// Bootstrap Application
1410// This section shows how to initialize and run the application
1411
1412/**
1413 * Example using Slim Framework 4:
1414 *
1415 * require __DIR__ . '/vendor/autoload.php';
1416 *
1417 * use Slim\Factory\AppFactory;
1418 *
1419 * $app = AppFactory::create();
1420 *
1421 * // Register routes
1422 * // Note: You'll need to manually extract route attributes and register them
1423 * // or use a library that supports PHP 8 attributes
1424 *
1425 * $app->run();
1426 */
1427
1428/**
1429 * Example using Symfony:
1430 *
1431 * The #[Route] attributes are compatible with Symfony's routing.
1432 * Simply ensure the controllers are registered as services and
1433 * Symfony will automatically discover the routes.
1434 */
1435
1436/**
1437 * Application Information:
1438 * Title: {}
1439 * Version: {}
1440 * OpenAPI: {}
1441 */
1442",
1443            self.spec.info.title, self.spec.info.version, self.spec.openapi
1444        )
1445    }
1446}