1use 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 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 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 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)) = ¶meter_data.format else {
321 continue;
322 };
323
324 let Some(enum_name) = self.parameter_enum_name(operation_id, ¶meter_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 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 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 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 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 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 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 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 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) = ¶meter_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 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 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 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 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 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 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 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 "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 #[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 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 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 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 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 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 #[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 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 #[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 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(¶meter_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(¶meter_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 ¶meter_data.format {
1309 ParameterSchemaOrContent::Schema(schema_ref) => match schema_ref {
1310 ReferenceOr::Item(schema) => {
1311 let inline_name = self.parameter_enum_name(operation_id, ¶meter_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 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 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(¶ms.join(", "));
1396 output.push_str(&format!("): {return_type}\n {{\n"));
1397 }
1398
1399 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}