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 {
383 let is_required = obj.required.contains(prop_name);
384 let field_name = Self::to_camel_case(prop_name);
385
386 let (type_hint, nullable, phpdoc_type) = match prop_schema_ref {
387 ReferenceOr::Item(prop_schema) => {
388 let (type_hint, nullable) =
389 self.schema_to_php_type(Some(class_name), Some(prop_name), prop_schema, !is_required, None);
390 let phpdoc_type =
391 self.schema_to_phpdoc_type(Some(class_name), Some(prop_name), prop_schema, !is_required, None);
392 (type_hint, nullable, phpdoc_type)
393 }
394 ReferenceOr::Reference { reference } => {
395 let ref_name = self.extract_ref_name(reference);
396 let ref_type = ref_name.to_pascal_case();
397 if is_required {
398 (ref_type.clone(), false, ref_type)
399 } else {
400 (format!("?{ref_type}"), true, format!("{ref_type}|null"))
401 }
402 }
403 };
404
405 let prop_line = self.build_property_line(&type_hint, &field_name, is_required, nullable);
406 let doc_line = format!(" * @param {phpdoc_type} ${field_name}\n");
407
408 if is_required {
409 required_props.push(prop_line);
410 required_docs.push(doc_line);
411 } else {
412 optional_props.push(prop_line);
413 optional_docs.push(doc_line);
414 }
415 }
416
417 let mut property_lines = required_props;
418 property_lines.extend(optional_props);
419 let mut property_docs = required_docs;
420 property_docs.extend(optional_docs);
421
422 Ok((property_lines, property_docs))
423 }
424
425 fn build_property_line(&self, type_hint: &str, field_name: &str, is_required: bool, nullable: bool) -> String {
427 if is_required {
428 format!(" public {type_hint} ${field_name},\n")
429 } else if nullable {
430 format!(" public {type_hint} ${field_name} = null,\n")
431 } else {
432 format!(" public ?{type_hint} ${field_name} = null,\n")
433 }
434 }
435
436 fn escape_php_string(s: &str) -> String {
439 s.chars()
440 .flat_map(|c| match c {
441 '\\' => vec!['\\', '\\'],
442 '\'' => vec!['\\', '\''],
443 '\n' => vec!['\\', 'n'],
444 '\r' => vec!['\\', 'r'],
445 '\t' => vec!['\\', 't'],
446 _ => vec![c],
447 })
448 .collect()
449 }
450
451 fn extract_ref_name(&self, reference: &str) -> String {
453 reference.split('/').next_back().unwrap_or("UnknownType").to_string()
454 }
455
456 fn resolve_schema_reference<'a>(&'a self, reference: &str) -> Option<&'a Schema> {
457 let name = reference.split('/').next_back()?;
458 self.spec
459 .components
460 .as_ref()?
461 .schemas
462 .get(name)
463 .and_then(|schema_ref| match schema_ref {
464 ReferenceOr::Item(schema) => Some(schema),
465 ReferenceOr::Reference { .. } => None,
466 })
467 }
468
469 fn uses_uuid_types(&self) -> bool {
470 self.spec.components.as_ref().is_some_and(|components| {
471 components
472 .schemas
473 .values()
474 .filter_map(|schema_ref| match schema_ref {
475 ReferenceOr::Item(schema) => Some(schema),
476 ReferenceOr::Reference { .. } => None,
477 })
478 .any(|schema| self.schema_uses_uuid_type(schema))
479 }) || self.spec.paths.paths.values().any(|path_item_ref| {
480 let ReferenceOr::Item(path_item) = path_item_ref else {
481 return false;
482 };
483
484 [
485 path_item.get.as_ref(),
486 path_item.post.as_ref(),
487 path_item.put.as_ref(),
488 path_item.delete.as_ref(),
489 path_item.patch.as_ref(),
490 ]
491 .into_iter()
492 .flatten()
493 .any(|operation| self.operation_uses_uuid_type(operation))
494 })
495 }
496
497 fn operation_uses_uuid_type(&self, operation: &Operation) -> bool {
498 operation.parameters.iter().any(|parameter_ref| {
499 let ReferenceOr::Item(parameter) = parameter_ref else {
500 return false;
501 };
502 match parameter {
503 Parameter::Path { parameter_data, .. }
504 | Parameter::Query { parameter_data, .. }
505 | Parameter::Header { parameter_data, .. }
506 | Parameter::Cookie { parameter_data, .. } => {
507 let ParameterSchemaOrContent::Schema(schema_ref) = ¶meter_data.format else {
508 return false;
509 };
510 match schema_ref {
511 ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
512 ReferenceOr::Reference { reference } => self
513 .resolve_schema_reference(reference)
514 .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
515 }
516 }
517 }
518 }) || operation
519 .request_body
520 .as_ref()
521 .and_then(|body_ref| match body_ref {
522 ReferenceOr::Item(request_body) => request_body
523 .content
524 .get("application/json")
525 .and_then(|media_type| media_type.schema.as_ref())
526 .and_then(|schema_ref| match schema_ref {
527 ReferenceOr::Item(schema) => Some(schema),
528 ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
529 }),
530 ReferenceOr::Reference { .. } => None,
531 })
532 .is_some_and(|schema| self.schema_uses_uuid_type(schema))
533 }
534
535 fn schema_uses_uuid_type(&self, schema: &Schema) -> bool {
536 let SchemaKind::Type(ty) = &schema.schema_kind else {
537 if let SchemaKind::AllOf { all_of } = &schema.schema_kind {
538 return all_of.iter().any(|schema_ref| match schema_ref {
539 ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
540 ReferenceOr::Reference { reference } => self
541 .resolve_schema_reference(reference)
542 .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
543 });
544 }
545 return false;
546 };
547
548 match ty {
549 Type::String(string_type) => {
550 matches!(&string_type.format, VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid")
551 }
552 Type::Array(array_type) => array_type
553 .items
554 .as_ref()
555 .and_then(|item_schema| match item_schema {
556 ReferenceOr::Item(schema) => Some(schema.as_ref()),
557 ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
558 })
559 .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
560 Type::Object(object_type) => object_type.properties.values().any(|schema_ref| match schema_ref {
561 ReferenceOr::Item(schema) => self.schema_uses_uuid_type(schema),
562 ReferenceOr::Reference { reference } => self
563 .resolve_schema_reference(reference)
564 .is_some_and(|schema| self.schema_uses_uuid_type(schema)),
565 }),
566 _ => false,
567 }
568 }
569
570 fn to_camel_case(s: &str) -> String {
572 let snake = s.to_snake_case();
573 let mut chars = snake.chars();
574 match chars.next() {
575 None => String::new(),
576 Some(first) => {
577 let mut result = first.to_lowercase().collect::<String>();
578 let mut capitalize_next = false;
579 for c in chars {
580 if c == '_' {
581 capitalize_next = true;
582 } else if capitalize_next {
583 result.push_str(&c.to_uppercase().to_string());
584 capitalize_next = false;
585 } else {
586 result.push(c);
587 }
588 }
589 result
590 }
591 }
592 }
593
594 fn inline_model_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
595 match &schema.schema_kind {
596 SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
597 Some(format!("{parent_class_name}{}", field_name.to_pascal_case()))
598 }
599 _ => None,
600 }
601 }
602
603 fn inline_enum_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
604 Self::is_enum_schema(schema).then(|| format!("{parent_class_name}{}", field_name.to_pascal_case()))
605 }
606
607 fn inline_array_item_model_name(
608 &self,
609 parent_class_name: &str,
610 field_name: &str,
611 schema: &Schema,
612 ) -> Option<String> {
613 let item_schema = Self::inline_array_item_schema(schema)?;
614 match &item_schema.schema_kind {
615 SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
616 Some(format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
617 }
618 _ => None,
619 }
620 }
621
622 fn inline_array_item_enum_name(
623 &self,
624 parent_class_name: &str,
625 field_name: &str,
626 schema: &Schema,
627 ) -> Option<String> {
628 let item_schema = Self::inline_array_item_schema(schema)?;
629 Self::is_enum_schema(item_schema).then(|| format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
630 }
631
632 fn inline_array_item_schema(schema: &Schema) -> Option<&Schema> {
633 match &schema.schema_kind {
634 SchemaKind::Type(Type::Array(array_type)) => match &array_type.items {
635 Some(ReferenceOr::Item(item_schema)) => Some(item_schema),
636 _ => None,
637 },
638 _ => None,
639 }
640 }
641
642 fn parameter_enum_name(&self, operation_id: Option<&str>, parameter_name: &str, schema: &Schema) -> Option<String> {
643 Self::is_enum_schema(schema).then(|| {
644 let operation_prefix = operation_id
645 .map(str::to_pascal_case)
646 .unwrap_or_else(|| "Operation".to_string());
647 format!("{operation_prefix}{}", parameter_name.to_pascal_case())
648 })
649 }
650
651 fn inline_request_body_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
652 let operation_id = operation.operation_id.as_deref()?;
653 let request_body = operation.request_body.as_ref()?;
654
655 match request_body {
656 ReferenceOr::Item(body) => {
657 let schema_ref = body.content.get("application/json")?.schema.as_ref()?;
658 match schema_ref {
659 ReferenceOr::Item(schema) if Self::is_named_inline_object_schema(schema) => {
660 Some((format!("{}RequestBody", operation_id.to_pascal_case()), schema))
661 }
662 _ => None,
663 }
664 }
665 ReferenceOr::Reference { .. } => None,
666 }
667 }
668
669 fn inline_response_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
670 use openapiv3::StatusCode;
671
672 let operation_id = operation.operation_id.as_deref()?;
673 let response = operation
674 .responses
675 .responses
676 .get(&StatusCode::Code(200))
677 .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
678 .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)))?;
679
680 match response {
681 ReferenceOr::Item(response) => {
682 let schema_ref = response.content.get("application/json")?.schema.as_ref()?;
683 match schema_ref {
684 ReferenceOr::Item(schema) if Self::is_named_inline_object_schema(schema) => {
685 Some((format!("{}ResponseBody", operation_id.to_pascal_case()), schema))
686 }
687 _ => None,
688 }
689 }
690 ReferenceOr::Reference { .. } => None,
691 }
692 }
693
694 fn is_named_inline_object_schema(schema: &Schema) -> bool {
695 matches!(&schema.schema_kind, SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty())
696 }
697
698 fn is_enum_schema(schema: &Schema) -> bool {
699 matches!(&schema.schema_kind, SchemaKind::Type(Type::String(string_type)) if !string_type.enumeration.is_empty())
700 }
701
702 fn enum_case_name(value: &str) -> String {
703 let mut name = value
704 .chars()
705 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
706 .collect::<String>()
707 .to_pascal_case();
708 if name.is_empty() {
709 name = "Value".to_string();
710 }
711 if name.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
712 name.insert_str(0, "Value");
713 }
714 name
715 }
716
717 fn enum_type_name(
718 &self,
719 parent_class_name: Option<&str>,
720 field_name: Option<&str>,
721 schema: &Schema,
722 inline_name: Option<&str>,
723 ) -> Option<String> {
724 Self::is_enum_schema(schema).then(|| {
725 inline_name
726 .map(ToOwned::to_owned)
727 .or_else(|| {
728 parent_class_name
729 .zip(field_name)
730 .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
731 })
732 .unwrap_or_else(|| "StringBackedEnum".to_string())
733 })
734 }
735
736 fn string_format_php_type(&self, string_type: &openapiv3::StringType) -> String {
737 match &string_type.format {
738 VariantOrUnknownOrEmpty::Item(StringFormat::Date | StringFormat::DateTime) => {
739 "\\DateTimeImmutable".to_string()
740 }
741 VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "UuidValue".to_string(),
742 _ => "string".to_string(),
743 }
744 }
745
746 fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>, inline_name: Option<&str>) -> String {
748 match schema_ref {
749 ReferenceOr::Reference { reference } => self.extract_ref_name(reference).to_pascal_case(),
750 ReferenceOr::Item(schema) => self.schema_to_php_type(None, None, schema, false, inline_name).0,
751 }
752 }
753
754 fn extract_request_body_type(&self, operation: &Operation) -> Option<String> {
756 operation.request_body.as_ref().and_then(|body_ref| match body_ref {
757 ReferenceOr::Item(request_body) => self.extract_json_schema_type(
758 request_body.content.get("application/json"),
759 operation
760 .operation_id
761 .as_deref()
762 .map(|id| format!("{}RequestBody", id.to_pascal_case()))
763 .as_deref(),
764 ),
765 ReferenceOr::Reference { reference } => {
766 let ref_name = self.extract_ref_name(reference);
767 Some(ref_name.to_pascal_case())
768 }
769 })
770 }
771
772 fn extract_json_schema_type(
774 &self,
775 media_type: Option<&openapiv3::MediaType>,
776 inline_name: Option<&str>,
777 ) -> Option<String> {
778 media_type.and_then(|mt| {
779 mt.schema
780 .as_ref()
781 .map(|schema_ref| self.extract_type_from_schema_ref(schema_ref, inline_name))
782 })
783 }
784
785 fn extract_json_schema_doc_type(
786 &self,
787 media_type: Option<&openapiv3::MediaType>,
788 inline_name: Option<&str>,
789 ) -> Option<String> {
790 media_type.and_then(|mt| {
791 mt.schema
792 .as_ref()
793 .map(|schema_ref| self.phpdoc_type_from_schema_ref(schema_ref, false, inline_name))
794 })
795 }
796
797 fn extract_response_type(&self, operation: &Operation) -> String {
799 use openapiv3::StatusCode;
800
801 let response = operation
802 .responses
803 .responses
804 .get(&StatusCode::Code(200))
805 .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
806 .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
807
808 response
809 .and_then(|response_ref| {
810 self.extract_response_type_from_ref(
811 response_ref,
812 operation
813 .operation_id
814 .as_deref()
815 .map(|id| format!("{}ResponseBody", id.to_pascal_case()))
816 .as_deref(),
817 )
818 })
819 .unwrap_or_else(|| "array".to_string())
820 }
821
822 fn extract_request_body_doc_type(&self, operation: &Operation) -> Option<String> {
823 operation.request_body.as_ref().and_then(|body_ref| match body_ref {
824 ReferenceOr::Item(request_body) => self.extract_json_schema_doc_type(
825 request_body.content.get("application/json"),
826 operation
827 .operation_id
828 .as_deref()
829 .map(|id| format!("{}RequestBody", id.to_pascal_case()))
830 .as_deref(),
831 ),
832 ReferenceOr::Reference { reference } => {
833 let ref_name = self.extract_ref_name(reference);
834 Some(ref_name.to_pascal_case())
835 }
836 })
837 }
838
839 fn extract_response_doc_type(&self, operation: &Operation) -> String {
840 use openapiv3::StatusCode;
841
842 let response = operation
843 .responses
844 .responses
845 .get(&StatusCode::Code(200))
846 .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
847 .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
848
849 response
850 .and_then(|response_ref| {
851 self.extract_response_doc_type_from_ref(
852 response_ref,
853 operation
854 .operation_id
855 .as_deref()
856 .map(|id| format!("{}ResponseBody", id.to_pascal_case()))
857 .as_deref(),
858 )
859 })
860 .unwrap_or_else(|| "array<string, mixed>".to_string())
861 }
862
863 fn extract_response_type_from_ref(
865 &self,
866 response_ref: &ReferenceOr<openapiv3::Response>,
867 inline_name: Option<&str>,
868 ) -> Option<String> {
869 match response_ref {
870 ReferenceOr::Item(response) => {
871 let media_type = response.content.get("application/json")?;
872 let schema_ref = media_type.schema.as_ref()?;
873 Some(self.extract_type_from_schema_ref(schema_ref, inline_name))
874 }
875 ReferenceOr::Reference { reference } => {
876 let ref_name = self.extract_ref_name(reference);
877 Some(ref_name.to_pascal_case())
878 }
879 }
880 }
881
882 fn extract_response_doc_type_from_ref(
883 &self,
884 response_ref: &ReferenceOr<openapiv3::Response>,
885 inline_name: Option<&str>,
886 ) -> Option<String> {
887 match response_ref {
888 ReferenceOr::Item(response) => {
889 let media_type = response.content.get("application/json")?;
890 let schema_ref = media_type.schema.as_ref()?;
891 Some(self.phpdoc_type_from_schema_ref(schema_ref, false, inline_name))
892 }
893 ReferenceOr::Reference { reference } => {
894 let ref_name = self.extract_ref_name(reference);
895 Some(ref_name.to_pascal_case())
896 }
897 }
898 }
899
900 fn schema_to_php_type(
904 &self,
905 parent_class_name: Option<&str>,
906 field_name: Option<&str>,
907 schema: &Schema,
908 optional: bool,
909 inline_name: Option<&str>,
910 ) -> (String, bool) {
911 let base_type = if let Some(enum_type) = self.enum_type_name(parent_class_name, field_name, schema, inline_name)
912 {
913 enum_type
914 } else {
915 match &schema.schema_kind {
916 SchemaKind::Type(Type::String(string_type)) => self.string_format_php_type(string_type),
917 SchemaKind::Type(Type::Number(_)) => "float".to_string(),
918 SchemaKind::Type(Type::Integer(_)) => "int".to_string(),
919 SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
920 SchemaKind::Type(Type::Array(_)) => "array".to_string(),
921 SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => inline_name
922 .map(ToOwned::to_owned)
923 .or_else(|| {
924 parent_class_name
925 .zip(field_name)
926 .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
927 })
928 .unwrap_or_else(|| "array".to_string()),
929 SchemaKind::Type(Type::Object(_)) => "array".to_string(),
930 _ => "mixed".to_string(),
931 }
932 };
933
934 if optional {
935 (format!("?{base_type}"), true)
936 } else {
937 (base_type, false)
938 }
939 }
940
941 #[allow(dead_code)]
943 fn schema_to_phpdoc_type(
944 &self,
945 parent_class_name: Option<&str>,
946 field_name: Option<&str>,
947 schema: &Schema,
948 optional: bool,
949 inline_name: Option<&str>,
950 ) -> String {
951 let base_type = if let Some(enum_type) = self.enum_type_name(parent_class_name, field_name, schema, inline_name)
952 {
953 enum_type
954 } else {
955 match &schema.schema_kind {
956 SchemaKind::Type(Type::String(string_type)) => {
957 if string_type.enumeration.is_empty() {
958 self.string_format_php_type(string_type)
959 } else {
960 string_type
961 .enumeration
962 .iter()
963 .flatten()
964 .map(|value| format!("'{}'", Self::escape_php_string(value)))
965 .collect::<Vec<_>>()
966 .join("|")
967 }
968 }
969 SchemaKind::Type(Type::Number(number_type)) => {
970 if number_type.enumeration.is_empty() {
971 "float".to_string()
972 } else {
973 number_type
974 .enumeration
975 .iter()
976 .flatten()
977 .map(ToString::to_string)
978 .collect::<Vec<_>>()
979 .join("|")
980 }
981 }
982 SchemaKind::Type(Type::Integer(integer_type)) => {
983 if integer_type.enumeration.is_empty() {
984 "int".to_string()
985 } else {
986 integer_type
987 .enumeration
988 .iter()
989 .flatten()
990 .map(ToString::to_string)
991 .collect::<Vec<_>>()
992 .join("|")
993 }
994 }
995 SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
996 SchemaKind::Type(Type::Array(arr)) => {
997 let item_type = match &arr.items {
998 Some(ReferenceOr::Item(item_schema)) => self.schema_to_phpdoc_type(
999 None,
1000 None,
1001 item_schema,
1002 false,
1003 parent_class_name
1004 .zip(field_name)
1005 .and_then(|(parent, field)| {
1006 self.inline_array_item_model_name(parent, field, schema)
1007 .or_else(|| self.inline_array_item_enum_name(parent, field, schema))
1008 })
1009 .as_deref(),
1010 ),
1011 Some(ReferenceOr::Reference { reference }) => {
1012 let ref_name = reference.split('/').next_back().unwrap();
1013 ref_name.to_pascal_case()
1014 }
1015 None => "mixed".to_string(),
1016 };
1017 format!("list<{item_type}>")
1018 }
1019 SchemaKind::Type(Type::Object(obj)) => {
1020 if obj.properties.is_empty() {
1021 "array<string, mixed>".to_string()
1022 } else {
1023 inline_name
1024 .map(ToOwned::to_owned)
1025 .or_else(|| {
1026 parent_class_name
1027 .zip(field_name)
1028 .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
1029 })
1030 .unwrap_or_else(|| {
1031 let mut entries = Vec::new();
1032 for (prop_name, prop_schema_ref) in &obj.properties {
1033 let is_required = obj.required.contains(prop_name);
1034 let prop_type = match prop_schema_ref {
1035 ReferenceOr::Item(prop_schema) => self.schema_to_phpdoc_type(
1036 parent_class_name,
1037 Some(prop_name),
1038 prop_schema,
1039 !is_required,
1040 None,
1041 ),
1042 ReferenceOr::Reference { reference } => {
1043 let ref_name = reference.split('/').next_back().unwrap();
1044 let mut base = ref_name.to_pascal_case();
1045 if !is_required {
1046 base.push_str("|null");
1047 }
1048 base
1049 }
1050 };
1051 if is_required {
1052 entries.push(format!("{prop_name}: {prop_type}"));
1053 } else {
1054 entries.push(format!("{prop_name}?: {prop_type}"));
1055 }
1056 }
1057 format!("array{{{}}}", entries.join(", "))
1058 })
1059 }
1060 }
1061 _ => "mixed".to_string(),
1062 }
1063 };
1064
1065 if optional {
1066 format!("{base_type}|null")
1067 } else {
1068 base_type
1069 }
1070 }
1071
1072 fn generate_controllers(&self) -> Result<String> {
1074 let mut output = String::new();
1075 output.push_str("\n// Controller Classes\n\n");
1076
1077 let controllers = self.group_operations_by_controller();
1078
1079 for (controller_name, routes) in controllers {
1080 output.push_str(&self.generate_controller_class(&controller_name, &routes)?);
1081 output.push('\n');
1082 }
1083
1084 Ok(output)
1085 }
1086
1087 fn group_operations_by_controller(&self) -> std::collections::HashMap<String, Vec<(String, String, Operation)>> {
1089 let mut controllers: std::collections::HashMap<String, Vec<(String, String, Operation)>> =
1090 std::collections::HashMap::new();
1091
1092 for (path, path_item_ref) in &self.spec.paths.paths {
1093 let path_item = match path_item_ref {
1094 ReferenceOr::Item(item) => item,
1095 ReferenceOr::Reference { .. } => continue,
1096 };
1097
1098 let controller_name = self.extract_controller_name(path);
1099 self.add_path_operations(&mut controllers, path, &controller_name, path_item);
1100 }
1101
1102 controllers
1103 }
1104
1105 fn add_path_operations(
1107 &self,
1108 controllers: &mut std::collections::HashMap<String, Vec<(String, String, Operation)>>,
1109 path: &str,
1110 controller_name: &str,
1111 path_item: &openapiv3::PathItem,
1112 ) {
1113 let methods = [
1114 ("GET", &path_item.get),
1115 ("POST", &path_item.post),
1116 ("PUT", &path_item.put),
1117 ("DELETE", &path_item.delete),
1118 ("PATCH", &path_item.patch),
1119 ];
1120
1121 for (method, op_opt) in methods {
1122 if let Some(op) = op_opt {
1123 controllers.entry(controller_name.to_string()).or_default().push((
1124 path.to_string(),
1125 method.to_string(),
1126 op.clone(),
1127 ));
1128 }
1129 }
1130 }
1131
1132 fn extract_controller_name(&self, path: &str) -> String {
1133 let segments: Vec<&str> = path
1134 .split('/')
1135 .filter(|s| !s.is_empty() && !s.starts_with('{'))
1136 .collect();
1137
1138 if let Some(first_segment) = segments.first() {
1139 format!("{}Controller", first_segment.to_pascal_case())
1140 } else {
1141 "DefaultController".to_string()
1142 }
1143 }
1144
1145 fn generate_controller_class(
1146 &self,
1147 controller_name: &str,
1148 routes: &[(String, String, Operation)],
1149 ) -> Result<String> {
1150 let mut output = String::new();
1151
1152 output.push_str("/**\n");
1153 output.push_str(&format!(" * {controller_name} - Generated controller for API routes\n"));
1154 output.push_str(" */\n");
1155
1156 output.push_str(&format!("class {controller_name}\n{{\n"));
1157
1158 for (path, method, operation) in routes {
1159 output.push_str(&self.generate_route_handler(path, method, operation)?);
1160 }
1161
1162 output.push_str("}\n");
1163
1164 Ok(output)
1165 }
1166
1167 fn generate_route_handler(&self, path: &str, method: &str, operation: &Operation) -> Result<String> {
1169 let mut output = String::new();
1170 let method_name = self.extract_handler_method_name(operation, method, path);
1171 let (path_params, query_params, body_type, return_type) =
1172 self.extract_handler_parameters(operation, &mut output)?;
1173
1174 output.push_str(&format!(
1175 " #[Route('{}', methods: ['{}'])]\n",
1176 path,
1177 method.to_uppercase()
1178 ));
1179
1180 self.append_function_signature(
1181 &mut output,
1182 &method_name,
1183 &path_params,
1184 &query_params,
1185 &body_type,
1186 &return_type,
1187 );
1188 self.append_function_body(&mut output);
1189
1190 Ok(output)
1191 }
1192
1193 fn extract_handler_method_name(&self, operation: &Operation, method: &str, path: &str) -> String {
1195 operation
1196 .operation_id
1197 .as_ref()
1198 .map(|id| Self::to_camel_case(id))
1199 .unwrap_or_else(|| {
1200 Self::to_camel_case(&format!(
1201 "{}_{}",
1202 method.to_lowercase(),
1203 path.replace('/', "_").replace(['{', '}'], "").trim_matches('_')
1204 ))
1205 })
1206 }
1207
1208 #[allow(clippy::type_complexity)]
1210 fn extract_handler_parameters(
1211 &self,
1212 operation: &Operation,
1213 output: &mut String,
1214 ) -> Result<(
1215 Vec<(String, String)>,
1216 Vec<(String, String, bool)>,
1217 Option<String>,
1218 String,
1219 )> {
1220 output.push_str(" /**\n");
1221 self.append_operation_description(output, operation);
1222
1223 let (path_params, query_params) = self.extract_path_and_query_params(operation, output);
1224 let body_type = self.extract_request_body_type(operation);
1225 let return_type = self.extract_response_type(operation);
1226 let body_doc_type = self.extract_request_body_doc_type(operation);
1227 let return_doc_type = self.extract_response_doc_type(operation);
1228
1229 self.append_parameter_docs(output, &body_doc_type, &return_doc_type);
1230
1231 Ok((path_params, query_params, body_type, return_type))
1232 }
1233
1234 fn append_operation_description(&self, output: &mut String, operation: &Operation) {
1236 if let Some(summary) = &operation.summary {
1237 output.push_str(&format!(" * {summary}\n"));
1238 }
1239 if let Some(description) = &operation.description {
1240 output.push_str(&format!(" * \n * {description}\n"));
1241 }
1242 output.push_str(" * \n");
1243 }
1244
1245 #[allow(clippy::type_complexity)]
1247 fn extract_path_and_query_params(
1248 &self,
1249 operation: &Operation,
1250 output: &mut String,
1251 ) -> (Vec<(String, String)>, Vec<(String, String, bool)>) {
1252 let mut path_params = Vec::new();
1253 let mut query_params = Vec::new();
1254
1255 for param_ref in &operation.parameters {
1256 if let ReferenceOr::Item(param) = param_ref {
1257 self.process_parameter(
1258 operation.operation_id.as_deref(),
1259 param,
1260 &mut path_params,
1261 &mut query_params,
1262 output,
1263 );
1264 }
1265 }
1266
1267 (path_params, query_params)
1268 }
1269
1270 fn process_parameter(
1272 &self,
1273 operation_id: Option<&str>,
1274 param: &Parameter,
1275 path_params: &mut Vec<(String, String)>,
1276 query_params: &mut Vec<(String, String, bool)>,
1277 output: &mut String,
1278 ) {
1279 match param {
1280 Parameter::Path { parameter_data, .. } => {
1281 let param_name = Self::to_camel_case(¶meter_data.name);
1282 let (native_type, phpdoc_type) = self.parameter_types(operation_id, parameter_data, false);
1283 path_params.push((param_name.clone(), native_type));
1284 output.push_str(&format!(" * @param {phpdoc_type} ${param_name}\n"));
1285 }
1286 Parameter::Query { parameter_data, .. } => {
1287 let param_name = Self::to_camel_case(¶meter_data.name);
1288 let required = parameter_data.required;
1289 let (native_type, phpdoc_type) = self.parameter_types(operation_id, parameter_data, !required);
1290 query_params.push((param_name.clone(), native_type, required));
1291 output.push_str(&format!(" * @param {phpdoc_type} ${param_name}\n"));
1292 }
1293 _ => {}
1294 }
1295 }
1296
1297 fn parameter_types(
1298 &self,
1299 operation_id: Option<&str>,
1300 parameter_data: &ParameterData,
1301 optional: bool,
1302 ) -> (String, String) {
1303 match ¶meter_data.format {
1304 ParameterSchemaOrContent::Schema(schema_ref) => match schema_ref {
1305 ReferenceOr::Item(schema) => {
1306 let inline_name = self.parameter_enum_name(operation_id, ¶meter_data.name, schema);
1307 let (native_type, _) =
1308 self.schema_to_php_type(None, None, schema, optional, inline_name.as_deref());
1309 let phpdoc_type = self.schema_to_phpdoc_type(None, None, schema, optional, inline_name.as_deref());
1310 (native_type, phpdoc_type)
1311 }
1312 ReferenceOr::Reference { reference } => {
1313 let base = self.extract_ref_name(reference).to_pascal_case();
1314 if optional {
1315 (format!("?{base}"), format!("{base}|null"))
1316 } else {
1317 (base.clone(), base)
1318 }
1319 }
1320 },
1321 ParameterSchemaOrContent::Content(_) => {
1322 if optional {
1323 ("?array".to_string(), "array<string, mixed>|null".to_string())
1324 } else {
1325 ("array".to_string(), "array<string, mixed>".to_string())
1326 }
1327 }
1328 }
1329 }
1330
1331 fn append_parameter_docs(&self, output: &mut String, body_doc_type: &Option<String>, return_doc_type: &str) {
1333 if let Some(body_type_name) = body_doc_type {
1334 output.push_str(&format!(" * @param {body_type_name} $body\n"));
1335 }
1336 output.push_str(&format!(" * @return {return_doc_type}\n"));
1337 output.push_str(" */\n");
1338 }
1339
1340 fn phpdoc_type_from_schema_ref(
1341 &self,
1342 schema_ref: &ReferenceOr<Schema>,
1343 optional: bool,
1344 inline_name: Option<&str>,
1345 ) -> String {
1346 match schema_ref {
1347 ReferenceOr::Item(schema) => self.schema_to_phpdoc_type(None, None, schema, optional, inline_name),
1348 ReferenceOr::Reference { reference } => {
1349 let mut base = self.extract_ref_name(reference).to_pascal_case();
1350 if optional {
1351 base.push_str("|null");
1352 }
1353 base
1354 }
1355 }
1356 }
1357
1358 fn append_function_signature(
1360 &self,
1361 output: &mut String,
1362 method_name: &str,
1363 path_params: &[(String, String)],
1364 query_params: &[(String, String, bool)],
1365 body_type: &Option<String>,
1366 return_type: &str,
1367 ) {
1368 output.push_str(&format!(" public function {method_name}("));
1369
1370 let mut params = Vec::new();
1371
1372 for (param_name, param_type) in path_params {
1373 params.push(format!("{param_type} ${param_name}"));
1374 }
1375
1376 for (param_name, param_type, required) in query_params.iter().filter(|(_, _, required)| *required) {
1377 let _ = required;
1378 params.push(format!("{param_type} ${param_name}"));
1379 }
1380
1381 if let Some(body_type_name) = body_type {
1382 params.push(format!("{body_type_name} $body"));
1383 }
1384
1385 for (param_name, param_type, required) in query_params.iter().filter(|(_, _, required)| !*required) {
1386 let _ = required;
1387 params.push(format!("{param_type} ${param_name} = null"));
1388 }
1389
1390 output.push_str(¶ms.join(", "));
1391 output.push_str(&format!("): {return_type}\n {{\n"));
1392 }
1393
1394 fn append_function_body(&self, output: &mut String) {
1396 output.push_str(" // Implement this endpoint\n");
1397 output.push_str(" throw new \\RuntimeException('Not implemented');\n");
1398 output.push_str(" }\n\n");
1399 }
1400
1401 fn generate_main(&self) -> String {
1402 format!(
1403 r"
1404// Bootstrap Application
1405// This section shows how to initialize and run the application
1406
1407/**
1408 * Example using Slim Framework 4:
1409 *
1410 * require __DIR__ . '/vendor/autoload.php';
1411 *
1412 * use Slim\Factory\AppFactory;
1413 *
1414 * $app = AppFactory::create();
1415 *
1416 * // Register routes
1417 * // Note: You'll need to manually extract route attributes and register them
1418 * // or use a library that supports PHP 8 attributes
1419 *
1420 * $app->run();
1421 */
1422
1423/**
1424 * Example using Symfony:
1425 *
1426 * The #[Route] attributes are compatible with Symfony's routing.
1427 * Simply ensure the controllers are registered as services and
1428 * Symfony will automatically discover the routes.
1429 */
1430
1431/**
1432 * Application Information:
1433 * Title: {}
1434 * Version: {}
1435 * OpenAPI: {}
1436 */
1437",
1438 self.spec.info.title, self.spec.info.version, self.spec.openapi
1439 )
1440 }
1441}