Skip to main content

spikard_cli/codegen/
python.rs

1//! Python code generation from `OpenAPI` schemas
2
3use super::{PythonDtoStyle, SchemaRegistry};
4use anyhow::{Result, bail};
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 PythonGenerator {
13    spec: OpenAPI,
14    dto: PythonDtoStyle,
15    registry: SchemaRegistry,
16}
17
18struct PythonFieldSpec {
19    original_name: String,
20    field_name: String,
21    type_hint: String,
22    required: bool,
23}
24
25impl PythonGenerator {
26    #[must_use]
27    pub fn new(spec: OpenAPI, dto: PythonDtoStyle) -> Self {
28        let registry = SchemaRegistry::from_spec(&spec);
29        Self { spec, dto, registry }
30    }
31
32    pub fn generate(&self) -> Result<String> {
33        let mut output = String::new();
34
35        output.push_str(&self.generate_header());
36
37        output.push_str(&self.generate_models()?);
38
39        output.push_str(&self.generate_routes()?);
40
41        output.push_str(&self.generate_main());
42
43        Ok(output)
44    }
45
46    fn generate_header(&self) -> String {
47        let mut header = String::new();
48        header.push_str("from __future__ import annotations\n\n");
49
50        let uses_path = self.uses_path_params();
51        let uses_query = self.uses_query_params();
52        let uses_body = self.uses_request_body();
53
54        let noqa = if uses_query {
55            "# ruff: noqa: B008, I001, INP001\n\n"
56        } else {
57            "# ruff: noqa: I001, INP001\n\n"
58        };
59        header.push_str(noqa);
60
61        let uses_literal = self.uses_literal_types();
62        let uses_date = self.uses_date_types();
63        let uses_datetime = self.uses_datetime_types();
64        let uses_uuid = self.uses_uuid_types();
65
66        match self.dto {
67            PythonDtoStyle::Dataclass => {
68                header.push_str(&format!(
69                    r"# Generated by Spikard OpenAPI code generator
70# OpenAPI Version: {}
71# Title: {}
72# DO NOT EDIT - regenerate from OpenAPI schema
73
74from dataclasses import dataclass
75",
76                    self.spec.openapi, self.spec.info.title,
77                ));
78            }
79            PythonDtoStyle::Msgspec => {
80                header.push_str(&format!(
81                    r"# Generated by Spikard OpenAPI code generator
82# OpenAPI Version: {}
83# Title: {}
84# DO NOT EDIT - regenerate from OpenAPI schema
85
86import msgspec
87",
88                    self.spec.openapi, self.spec.info.title,
89                ));
90            }
91        }
92
93        if uses_literal {
94            header.push_str("from typing import Literal\n");
95        }
96        if uses_date || uses_datetime {
97            if uses_date && uses_datetime {
98                header.push_str("from datetime import date, datetime\n");
99            } else if uses_date {
100                header.push_str("from datetime import date\n");
101            } else {
102                header.push_str("from datetime import datetime\n");
103            }
104        }
105        if uses_uuid {
106            header.push_str("from uuid import UUID\n");
107        }
108
109        header.push_str("from spikard import ");
110        header.push_str(&self.spikard_imports(uses_body, uses_path, uses_query));
111        header.push_str("\n\napp = Spikard()\n\n");
112
113        header
114    }
115
116    fn generate_models(&self) -> Result<String> {
117        if self.dto == PythonDtoStyle::Msgspec {
118            return self.generate_msgspec_models();
119        }
120        self.generate_dataclass_models()
121    }
122
123    fn generate_dataclass_models(&self) -> Result<String> {
124        let mut output = String::new();
125        output.push_str("# Schema Models\n\n");
126        let mut emitted = BTreeSet::new();
127
128        if let Some(components) = &self.spec.components {
129            for (name, schema_ref) in &components.schemas {
130                match schema_ref {
131                    ReferenceOr::Item(schema) => {
132                        self.generate_dataclass_family(&name.to_pascal_case(), schema, &mut emitted, &mut output)?;
133                    }
134                    ReferenceOr::Reference { .. } => continue,
135                }
136            }
137        }
138
139        self.generate_inline_route_models(&mut emitted, &mut output)?;
140
141        Ok(output)
142    }
143
144    fn generate_msgspec_models(&self) -> Result<String> {
145        let mut output = String::new();
146        output.push_str("# Schema Models\n\n");
147        let mut emitted = BTreeSet::new();
148
149        if let Some(components) = &self.spec.components {
150            for (name, schema_ref) in &components.schemas {
151                match schema_ref {
152                    ReferenceOr::Item(schema) => {
153                        self.generate_msgspec_family(&name.to_pascal_case(), schema, &mut emitted, &mut output)?;
154                    }
155                    ReferenceOr::Reference { .. } => continue,
156                }
157            }
158        }
159
160        self.generate_inline_route_models(&mut emitted, &mut output)?;
161
162        Ok(output)
163    }
164
165    fn generate_inline_route_models(&self, emitted: &mut BTreeSet<String>, output: &mut String) -> Result<()> {
166        for path_item_ref in self.spec.paths.paths.values() {
167            let ReferenceOr::Item(path_item) = path_item_ref else {
168                continue;
169            };
170
171            for operation in [
172                path_item.get.as_ref(),
173                path_item.post.as_ref(),
174                path_item.put.as_ref(),
175                path_item.delete.as_ref(),
176                path_item.patch.as_ref(),
177            ]
178            .into_iter()
179            .flatten()
180            {
181                if let Some((class_name, schema)) = self.inline_request_body_model(operation) {
182                    self.generate_model_family(&class_name, schema, emitted, output)?;
183                }
184                if let Some((class_name, schema)) = self.inline_response_model(operation) {
185                    self.generate_model_family(&class_name, schema, emitted, output)?;
186                }
187            }
188        }
189
190        Ok(())
191    }
192
193    fn generate_model_family(
194        &self,
195        class_name: &str,
196        schema: &Schema,
197        emitted: &mut BTreeSet<String>,
198        output: &mut String,
199    ) -> Result<()> {
200        match self.dto {
201            PythonDtoStyle::Dataclass => self.generate_dataclass_family(class_name, schema, emitted, output),
202            PythonDtoStyle::Msgspec => self.generate_msgspec_family(class_name, schema, emitted, output),
203        }
204    }
205
206    fn generate_dataclass_family(
207        &self,
208        class_name: &str,
209        schema: &Schema,
210        emitted: &mut BTreeSet<String>,
211        output: &mut String,
212    ) -> Result<()> {
213        if !emitted.insert(class_name.to_string()) {
214            return Ok(());
215        }
216
217        self.generate_nested_families(class_name, schema, emitted, output)?;
218        output.push_str(&self.generate_dataclass(class_name, schema)?);
219        output.push('\n');
220        Ok(())
221    }
222
223    fn generate_msgspec_family(
224        &self,
225        class_name: &str,
226        schema: &Schema,
227        emitted: &mut BTreeSet<String>,
228        output: &mut String,
229    ) -> Result<()> {
230        if !emitted.insert(class_name.to_string()) {
231            return Ok(());
232        }
233
234        self.generate_nested_families(class_name, schema, emitted, output)?;
235        output.push_str(&self.generate_msgspec_struct(class_name, schema)?);
236        output.push('\n');
237        Ok(())
238    }
239
240    fn generate_nested_families(
241        &self,
242        parent_class_name: &str,
243        schema: &Schema,
244        emitted: &mut BTreeSet<String>,
245        output: &mut String,
246    ) -> Result<()> {
247        match &schema.schema_kind {
248            SchemaKind::Type(Type::Object(obj)) => {
249                for (prop_name, prop_schema_ref) in &obj.properties {
250                    match prop_schema_ref {
251                        ReferenceOr::Item(prop_schema) => {
252                            if let Some(class_name) = self.inline_model_name(parent_class_name, prop_name, prop_schema)
253                            {
254                                self.generate_model_family(&class_name, prop_schema, emitted, output)?;
255                            }
256                            if let Some(array_item_name) =
257                                self.inline_array_item_model_name(parent_class_name, prop_name, prop_schema)
258                                && let Some(item_schema) = inline_array_item_schema(prop_schema)
259                            {
260                                self.generate_model_family(&array_item_name, item_schema, emitted, output)?;
261                            }
262                        }
263                        ReferenceOr::Reference { .. } => {}
264                    }
265                }
266            }
267            SchemaKind::AllOf { all_of } => {
268                for schema_ref in all_of {
269                    match schema_ref {
270                        ReferenceOr::Item(item) => {
271                            self.generate_nested_families(parent_class_name, item, emitted, output)?
272                        }
273                        ReferenceOr::Reference { reference } => {
274                            if let Some(resolved) = self.resolve_schema_reference(reference) {
275                                self.generate_nested_families(parent_class_name, resolved, emitted, output)?;
276                            }
277                        }
278                    }
279                }
280            }
281            _ => {}
282        }
283
284        Ok(())
285    }
286
287    fn generate_dataclass(&self, class_name: &str, schema: &Schema) -> Result<String> {
288        if self.dto != PythonDtoStyle::Dataclass {
289            bail!("dataclass generation called for non-dataclass style");
290        }
291
292        let mut output = String::new();
293
294        output.push_str("@dataclass(slots=True, kw_only=True)\n");
295        output.push_str(&format!("class {class_name}:\n"));
296        output.push_str(&format!(
297            "    \"\"\"{}\"\"\"\n",
298            ensure_sentence(
299                schema
300                    .schema_data
301                    .description
302                    .as_deref()
303                    .map(str::trim)
304                    .filter(|value| !value.is_empty())
305                    .unwrap_or("Generated OpenAPI schema model.")
306            )
307        ));
308
309        let mut fields = Vec::new();
310        self.collect_model_fields_into(class_name, schema, &mut fields);
311
312        if !fields.is_empty() {
313            fields.sort_by_key(|field| !field.required);
314
315            for field in fields {
316                if field.required {
317                    output.push_str(&format!("    {}: {}\n", field.field_name, field.type_hint));
318                } else {
319                    output.push_str(&format!("    {}: {} = None\n", field.field_name, field.type_hint));
320                }
321            }
322        }
323
324        Ok(output)
325    }
326
327    fn generate_msgspec_struct(&self, name: &str, schema: &Schema) -> Result<String> {
328        let class_name = name.to_pascal_case();
329        let mut output = String::new();
330
331        output.push_str(&format!("class {class_name}(msgspec.Struct):\n"));
332        output.push_str(&format!(
333            "    \"\"\"{}\"\"\"\n",
334            ensure_sentence(
335                schema
336                    .schema_data
337                    .description
338                    .as_deref()
339                    .map(str::trim)
340                    .filter(|value| !value.is_empty())
341                    .unwrap_or("Generated OpenAPI schema model.")
342            )
343        ));
344
345        let mut fields = Vec::new();
346        self.collect_model_fields_into(&class_name, schema, &mut fields);
347
348        if !fields.is_empty() {
349            fields.sort_by_key(|field| !field.required);
350
351            for field in fields {
352                if field.required {
353                    output.push_str(&format!("    {}: {}\n", field.field_name, field.type_hint));
354                } else {
355                    output.push_str(&format!("    {}: {} = None\n", field.field_name, field.type_hint));
356                }
357            }
358        }
359
360        Ok(output)
361    }
362
363    fn collect_model_fields_into(&self, parent_class_name: &str, schema: &Schema, fields: &mut Vec<PythonFieldSpec>) {
364        match &schema.schema_kind {
365            SchemaKind::Type(Type::Object(obj)) => {
366                for (prop_name, prop_schema_ref) in &obj.properties {
367                    if fields.iter().any(|field| field.original_name == *prop_name) {
368                        continue;
369                    }
370
371                    let is_required = obj.required.contains(prop_name);
372                    fields.push(PythonFieldSpec {
373                        original_name: prop_name.clone(),
374                        field_name: prop_name.to_snake_case(),
375                        type_hint: self.python_type_from_boxed_schema_ref(
376                            parent_class_name,
377                            prop_name,
378                            prop_schema_ref,
379                            !is_required,
380                        ),
381                        required: is_required,
382                    });
383                }
384            }
385            SchemaKind::AllOf { all_of } => {
386                for schema_ref in all_of {
387                    match schema_ref {
388                        ReferenceOr::Item(schema) => self.collect_model_fields_into(parent_class_name, schema, fields),
389                        ReferenceOr::Reference { reference } => {
390                            if let Some(schema) = self.resolve_schema_reference(reference) {
391                                self.collect_model_fields_into(parent_class_name, schema, fields);
392                            }
393                        }
394                    }
395                }
396            }
397            _ => {}
398        }
399    }
400
401    fn resolve_schema_reference<'a>(&'a self, reference: &str) -> Option<&'a Schema> {
402        let name = reference.split('/').next_back()?;
403        self.spec
404            .components
405            .as_ref()?
406            .schemas
407            .get(name)
408            .and_then(|schema_ref| match schema_ref {
409                ReferenceOr::Item(schema) => Some(schema),
410                ReferenceOr::Reference { .. } => None,
411            })
412    }
413
414    /// Extract type name from a schema reference or inline schema
415    fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>, inline_name: Option<&str>) -> String {
416        self.python_type_from_schema_ref(None, None, schema_ref, false, inline_name)
417    }
418
419    /// Extract request body type from operation
420    fn extract_request_body_type(&self, operation: &Operation) -> Option<String> {
421        operation.request_body.as_ref().and_then(|body_ref| match body_ref {
422            ReferenceOr::Item(request_body) => request_body.content.get("application/json").and_then(|media_type| {
423                media_type.schema.as_ref().map(|schema_ref| {
424                    self.extract_type_from_schema_ref(
425                        schema_ref,
426                        operation
427                            .operation_id
428                            .as_deref()
429                            .map(|id| format!("{}RequestBody", id.to_pascal_case()))
430                            .as_deref(),
431                    )
432                })
433            }),
434            ReferenceOr::Reference { reference } => {
435                let ref_name = reference.split('/').next_back().unwrap();
436                Some(ref_name.to_pascal_case())
437            }
438        })
439    }
440
441    /// Extract response type from operation (looks for 200/201 responses)
442    fn extract_response_type(&self, operation: &Operation) -> String {
443        use openapiv3::StatusCode;
444
445        let response = operation
446            .responses
447            .responses
448            .get(&StatusCode::Code(200))
449            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
450            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
451
452        if let Some(response_ref) = response {
453            match response_ref {
454                ReferenceOr::Item(response) => {
455                    if let Some(content) = response.content.get("application/json")
456                        && let Some(schema_ref) = &content.schema
457                    {
458                        return self.extract_type_from_schema_ref(
459                            schema_ref,
460                            operation
461                                .operation_id
462                                .as_deref()
463                                .map(|id| format!("{}ResponseBody", id.to_pascal_case()))
464                                .as_deref(),
465                        );
466                    }
467                }
468                ReferenceOr::Reference { reference } => {
469                    let ref_name = reference.split('/').next_back().unwrap();
470                    return ref_name.to_pascal_case();
471                }
472            }
473        }
474
475        "dict[str, object]".to_string()
476    }
477
478    fn python_type_from_schema_ref(
479        &self,
480        parent_class_name: Option<&str>,
481        field_name: Option<&str>,
482        schema_ref: &ReferenceOr<Schema>,
483        optional: bool,
484        inline_name: Option<&str>,
485    ) -> String {
486        match schema_ref {
487            ReferenceOr::Item(schema) => {
488                self.schema_to_python_type(parent_class_name, field_name, schema, optional, inline_name)
489            }
490            ReferenceOr::Reference { reference } => self.python_type_from_reference(reference, optional),
491        }
492    }
493
494    fn python_type_from_boxed_schema_ref(
495        &self,
496        parent_class_name: &str,
497        field_name: &str,
498        schema_ref: &ReferenceOr<Box<Schema>>,
499        optional: bool,
500    ) -> String {
501        match schema_ref {
502            ReferenceOr::Item(schema) => {
503                self.schema_to_python_type(Some(parent_class_name), Some(field_name), schema, optional, None)
504            }
505            ReferenceOr::Reference { reference } => self.python_type_from_reference(reference, optional),
506        }
507    }
508
509    fn python_type_from_reference(&self, reference: &str, optional: bool) -> String {
510        let mut base = reference.split('/').next_back().unwrap().to_pascal_case();
511        if let Some(schema) = self.registry.resolve_reference(reference)
512            && schema.schema_data.nullable
513        {
514            base = self.append_optional(base, true);
515        }
516        self.append_optional(base, optional)
517    }
518
519    #[allow(clippy::only_used_in_recursion)]
520    fn schema_to_python_type(
521        &self,
522        parent_class_name: Option<&str>,
523        field_name: Option<&str>,
524        schema: &Schema,
525        optional: bool,
526        inline_name: Option<&str>,
527    ) -> String {
528        let mut base_type = if let Some(literal_type) = self.literal_type(schema) {
529            literal_type
530        } else {
531            match &schema.schema_kind {
532                SchemaKind::Type(Type::String(string_type)) => self.string_format_python_type(string_type),
533                SchemaKind::Type(Type::Number(_)) => "float".to_string(),
534                SchemaKind::Type(Type::Integer(_)) => "int".to_string(),
535                SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
536                SchemaKind::Type(Type::Array(arr)) => {
537                    let item_type = match &arr.items {
538                        Some(item_schema) => self.python_array_item_type(parent_class_name, field_name, item_schema),
539                        None => "dict[str, object]".to_string(),
540                    };
541                    format!("list[{item_type}]")
542                }
543                SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => inline_name
544                    .map(ToOwned::to_owned)
545                    .or_else(|| {
546                        parent_class_name
547                            .zip(field_name)
548                            .map(|(parent, field)| format!("{parent}{}", field.to_pascal_case()))
549                    })
550                    .unwrap_or_else(|| "dict[str, object]".to_string()),
551                SchemaKind::Type(Type::Object(_)) => "dict[str, object]".to_string(),
552                _ => "dict[str, object]".to_string(),
553            }
554        };
555
556        if schema.schema_data.nullable {
557            base_type = self.append_optional(base_type, true);
558        }
559
560        self.append_optional(base_type, optional)
561    }
562
563    fn literal_type(&self, schema: &Schema) -> Option<String> {
564        match &schema.schema_kind {
565            SchemaKind::Type(Type::String(string_type)) => {
566                let values = string_type
567                    .enumeration
568                    .iter()
569                    .flatten()
570                    .map(|value| serde_json::to_string(value).ok())
571                    .collect::<Option<Vec<_>>>()?;
572                (!values.is_empty()).then(|| format!("Literal[{}]", values.join(", ")))
573            }
574            SchemaKind::Type(Type::Integer(integer_type)) => {
575                let values = integer_type
576                    .enumeration
577                    .iter()
578                    .flatten()
579                    .map(ToString::to_string)
580                    .collect::<Vec<_>>();
581                (!values.is_empty()).then(|| format!("Literal[{}]", values.join(", ")))
582            }
583            SchemaKind::Type(Type::Number(number_type)) => {
584                let values = number_type
585                    .enumeration
586                    .iter()
587                    .flatten()
588                    .map(ToString::to_string)
589                    .collect::<Vec<_>>();
590                (!values.is_empty()).then(|| format!("Literal[{}]", values.join(", ")))
591            }
592            _ => None,
593        }
594    }
595
596    fn string_format_python_type(&self, string_type: &openapiv3::StringType) -> String {
597        match &string_type.format {
598            VariantOrUnknownOrEmpty::Item(StringFormat::Date) => "date".to_string(),
599            VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => "datetime".to_string(),
600            VariantOrUnknownOrEmpty::Item(StringFormat::Byte | StringFormat::Binary) => "bytes".to_string(),
601            VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "UUID".to_string(),
602            _ => "str".to_string(),
603        }
604    }
605
606    fn append_optional(&self, base: String, optional: bool) -> String {
607        if optional && !base.trim().ends_with("| None") {
608            format!("{base} | None")
609        } else {
610            base
611        }
612    }
613
614    fn python_array_item_type(
615        &self,
616        parent_class_name: Option<&str>,
617        field_name: Option<&str>,
618        schema_ref: &ReferenceOr<Box<Schema>>,
619    ) -> String {
620        match schema_ref {
621            ReferenceOr::Item(schema) => {
622                let inline_item_name = parent_class_name
623                    .zip(field_name)
624                    .and_then(|(parent, field)| self.inline_array_item_model_name(parent, field, schema));
625                self.schema_to_python_type(None, None, schema, false, inline_item_name.as_deref())
626            }
627            ReferenceOr::Reference { reference } => self.python_type_from_reference(reference, false),
628        }
629    }
630
631    fn inline_model_name(&self, parent_class_name: &str, field_name: &str, schema: &Schema) -> Option<String> {
632        match &schema.schema_kind {
633            SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
634                Some(format!("{parent_class_name}{}", field_name.to_pascal_case()))
635            }
636            _ => None,
637        }
638    }
639
640    fn inline_array_item_model_name(
641        &self,
642        parent_class_name: &str,
643        field_name: &str,
644        schema: &Schema,
645    ) -> Option<String> {
646        let item_schema = inline_array_item_schema(schema)?;
647        match &item_schema.schema_kind {
648            SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
649                Some(format!("{parent_class_name}{}Item", field_name.to_pascal_case()))
650            }
651            _ => None,
652        }
653    }
654
655    fn inline_request_body_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
656        let operation_id = operation.operation_id.as_deref()?;
657        let request_body = operation.request_body.as_ref()?;
658
659        match request_body {
660            ReferenceOr::Item(body) => {
661                let schema_ref = body.content.get("application/json")?.schema.as_ref()?;
662                match schema_ref {
663                    ReferenceOr::Item(schema) => {
664                        if is_named_inline_object_schema(schema) {
665                            Some((format!("{}RequestBody", operation_id.to_pascal_case()), schema))
666                        } else {
667                            None
668                        }
669                    }
670                    ReferenceOr::Reference { .. } => None,
671                }
672            }
673            ReferenceOr::Reference { .. } => None,
674        }
675    }
676
677    fn inline_response_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
678        use openapiv3::StatusCode;
679
680        let operation_id = operation.operation_id.as_deref()?;
681        let response = operation
682            .responses
683            .responses
684            .get(&StatusCode::Code(200))
685            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
686            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)))?;
687
688        match response {
689            ReferenceOr::Item(response) => {
690                let schema_ref = response.content.get("application/json")?.schema.as_ref()?;
691                match schema_ref {
692                    ReferenceOr::Item(schema) => {
693                        if is_named_inline_object_schema(schema) {
694                            Some((format!("{}ResponseBody", operation_id.to_pascal_case()), schema))
695                        } else {
696                            None
697                        }
698                    }
699                    ReferenceOr::Reference { .. } => None,
700                }
701            }
702            ReferenceOr::Reference { .. } => None,
703        }
704    }
705
706    fn uses_path_params(&self) -> bool {
707        self.spec.paths.paths.values().any(|path_item_ref| {
708            let ReferenceOr::Item(path_item) = path_item_ref else {
709                return false;
710            };
711
712            path_item.get.as_ref().is_some_and(operation_has_path_params)
713                || path_item.post.as_ref().is_some_and(operation_has_path_params)
714                || path_item.put.as_ref().is_some_and(operation_has_path_params)
715                || path_item.delete.as_ref().is_some_and(operation_has_path_params)
716                || path_item.patch.as_ref().is_some_and(operation_has_path_params)
717        })
718    }
719
720    fn uses_query_params(&self) -> bool {
721        self.spec.paths.paths.values().any(|path_item_ref| {
722            let ReferenceOr::Item(path_item) = path_item_ref else {
723                return false;
724            };
725
726            path_item.get.as_ref().is_some_and(operation_has_query_params)
727                || path_item.post.as_ref().is_some_and(operation_has_query_params)
728                || path_item.put.as_ref().is_some_and(operation_has_query_params)
729                || path_item.delete.as_ref().is_some_and(operation_has_query_params)
730                || path_item.patch.as_ref().is_some_and(operation_has_query_params)
731        })
732    }
733
734    fn uses_request_body(&self) -> bool {
735        self.spec.paths.paths.values().any(|path_item_ref| {
736            let ReferenceOr::Item(path_item) = path_item_ref else {
737                return false;
738            };
739
740            path_item.get.as_ref().is_some_and(operation_has_request_body)
741                || path_item.post.as_ref().is_some_and(operation_has_request_body)
742                || path_item.put.as_ref().is_some_and(operation_has_request_body)
743                || path_item.delete.as_ref().is_some_and(operation_has_request_body)
744                || path_item.patch.as_ref().is_some_and(operation_has_request_body)
745        })
746    }
747
748    fn uses_literal_types(&self) -> bool {
749        self.spec.components.as_ref().is_some_and(|components| {
750            components
751                .schemas
752                .values()
753                .filter_map(|schema_ref| self.registry.resolve(schema_ref))
754                .any(|schema| self.schema_uses_literal_type(schema))
755        }) || self.spec.paths.paths.values().any(|path_item_ref| {
756            let ReferenceOr::Item(path_item) = path_item_ref else {
757                return false;
758            };
759
760            [
761                path_item.get.as_ref(),
762                path_item.post.as_ref(),
763                path_item.put.as_ref(),
764                path_item.delete.as_ref(),
765                path_item.patch.as_ref(),
766            ]
767            .into_iter()
768            .flatten()
769            .any(|operation| self.operation_uses_literal_types(operation))
770        })
771    }
772
773    fn uses_date_types(&self) -> bool {
774        self.uses_special_string_type(PythonSpecialStringType::Date)
775    }
776
777    fn uses_datetime_types(&self) -> bool {
778        self.uses_special_string_type(PythonSpecialStringType::DateTime)
779    }
780
781    fn uses_uuid_types(&self) -> bool {
782        self.uses_special_string_type(PythonSpecialStringType::Uuid)
783    }
784
785    fn uses_special_string_type(&self, target: PythonSpecialStringType) -> bool {
786        self.spec.components.as_ref().is_some_and(|components| {
787            components
788                .schemas
789                .values()
790                .filter_map(|schema_ref| self.registry.resolve(schema_ref))
791                .any(|schema| self.schema_uses_special_string_type(schema, target))
792        }) || self.spec.paths.paths.values().any(|path_item_ref| {
793            let ReferenceOr::Item(path_item) = path_item_ref else {
794                return false;
795            };
796
797            [
798                path_item.get.as_ref(),
799                path_item.post.as_ref(),
800                path_item.put.as_ref(),
801                path_item.delete.as_ref(),
802                path_item.patch.as_ref(),
803            ]
804            .into_iter()
805            .flatten()
806            .any(|operation| self.operation_uses_special_string_type(operation, target))
807        })
808    }
809
810    fn operation_uses_literal_types(&self, operation: &Operation) -> bool {
811        operation.parameters.iter().any(|parameter_ref| {
812            let ReferenceOr::Item(parameter) = parameter_ref else {
813                return false;
814            };
815
816            match parameter {
817                Parameter::Path { parameter_data, .. }
818                | Parameter::Query { parameter_data, .. }
819                | Parameter::Header { parameter_data, .. }
820                | Parameter::Cookie { parameter_data, .. } => self.parameter_uses_literal_type(parameter_data),
821            }
822        }) || self
823            .inline_request_body_model(operation)
824            .is_some_and(|(_, schema)| self.schema_uses_literal_type(schema))
825            || self
826                .inline_response_model(operation)
827                .is_some_and(|(_, schema)| self.schema_uses_literal_type(schema))
828            || operation
829                .request_body
830                .as_ref()
831                .and_then(|body_ref| match body_ref {
832                    ReferenceOr::Item(request_body) => request_body
833                        .content
834                        .get("application/json")
835                        .and_then(|media_type| media_type.schema.as_ref())
836                        .and_then(|schema_ref| self.registry.resolve(schema_ref)),
837                    ReferenceOr::Reference { .. } => None,
838                })
839                .is_some_and(|schema| self.schema_uses_literal_type(schema))
840    }
841
842    fn parameter_uses_literal_type(&self, parameter_data: &ParameterData) -> bool {
843        let ParameterSchemaOrContent::Schema(schema_ref) = &parameter_data.format else {
844            return false;
845        };
846        self.registry
847            .resolve(schema_ref)
848            .is_some_and(|schema| self.schema_uses_literal_type(schema))
849    }
850
851    fn operation_uses_special_string_type(&self, operation: &Operation, target: PythonSpecialStringType) -> bool {
852        operation.parameters.iter().any(|parameter_ref| {
853            let ReferenceOr::Item(parameter) = parameter_ref else {
854                return false;
855            };
856
857            match parameter {
858                Parameter::Path { parameter_data, .. }
859                | Parameter::Query { parameter_data, .. }
860                | Parameter::Header { parameter_data, .. }
861                | Parameter::Cookie { parameter_data, .. } => {
862                    self.parameter_uses_special_string_type(parameter_data, target)
863                }
864            }
865        }) || operation
866            .request_body
867            .as_ref()
868            .and_then(|body_ref| match body_ref {
869                ReferenceOr::Item(request_body) => request_body
870                    .content
871                    .get("application/json")
872                    .and_then(|media_type| media_type.schema.as_ref())
873                    .and_then(|schema_ref| self.registry.resolve(schema_ref)),
874                ReferenceOr::Reference { .. } => None,
875            })
876            .is_some_and(|schema| self.schema_uses_special_string_type(schema, target))
877            || operation
878                .responses
879                .responses
880                .values()
881                .filter_map(|response_ref| match response_ref {
882                    ReferenceOr::Item(response) => response
883                        .content
884                        .get("application/json")
885                        .and_then(|media_type| media_type.schema.as_ref())
886                        .and_then(|schema_ref| self.registry.resolve(schema_ref)),
887                    ReferenceOr::Reference { .. } => None,
888                })
889                .any(|schema| self.schema_uses_special_string_type(schema, target))
890    }
891
892    fn parameter_uses_special_string_type(
893        &self,
894        parameter_data: &ParameterData,
895        target: PythonSpecialStringType,
896    ) -> bool {
897        let ParameterSchemaOrContent::Schema(schema_ref) = &parameter_data.format else {
898            return false;
899        };
900        self.registry
901            .resolve(schema_ref)
902            .is_some_and(|schema| self.schema_uses_special_string_type(schema, target))
903    }
904
905    fn schema_uses_literal_type(&self, schema: &Schema) -> bool {
906        if self.literal_type(schema).is_some() {
907            return true;
908        }
909
910        match &schema.schema_kind {
911            SchemaKind::Type(Type::Array(array_type)) => array_type
912                .items
913                .as_ref()
914                .and_then(|item_schema| match item_schema {
915                    ReferenceOr::Item(item) => Some(item.as_ref()),
916                    ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
917                })
918                .is_some_and(|item| self.schema_uses_literal_type(item)),
919            SchemaKind::Type(Type::Object(object_type)) => {
920                object_type.properties.values().any(|schema_ref| match schema_ref {
921                    ReferenceOr::Item(schema) => self.schema_uses_literal_type(schema),
922                    ReferenceOr::Reference { reference } => self
923                        .resolve_schema_reference(reference)
924                        .is_some_and(|schema| self.schema_uses_literal_type(schema)),
925                })
926            }
927            SchemaKind::AllOf { all_of } => all_of.iter().any(|schema_ref| match schema_ref {
928                ReferenceOr::Item(schema) => self.schema_uses_literal_type(schema),
929                ReferenceOr::Reference { reference } => self
930                    .resolve_schema_reference(reference)
931                    .is_some_and(|schema| self.schema_uses_literal_type(schema)),
932            }),
933            _ => false,
934        }
935    }
936
937    fn schema_uses_special_string_type(&self, schema: &Schema, target: PythonSpecialStringType) -> bool {
938        if self.schema_matches_special_string_type(schema, target) {
939            return true;
940        }
941
942        match &schema.schema_kind {
943            SchemaKind::Type(Type::Array(array_type)) => array_type
944                .items
945                .as_ref()
946                .and_then(|item_schema| match item_schema {
947                    ReferenceOr::Item(item) => Some(item.as_ref()),
948                    ReferenceOr::Reference { reference } => self.resolve_schema_reference(reference),
949                })
950                .is_some_and(|item| self.schema_uses_special_string_type(item, target)),
951            SchemaKind::Type(Type::Object(object_type)) => {
952                object_type.properties.values().any(|schema_ref| match schema_ref {
953                    ReferenceOr::Item(schema) => self.schema_uses_special_string_type(schema, target),
954                    ReferenceOr::Reference { reference } => self
955                        .resolve_schema_reference(reference)
956                        .is_some_and(|schema| self.schema_uses_special_string_type(schema, target)),
957                })
958            }
959            SchemaKind::AllOf { all_of } => all_of.iter().any(|schema_ref| match schema_ref {
960                ReferenceOr::Item(schema) => self.schema_uses_special_string_type(schema, target),
961                ReferenceOr::Reference { reference } => self
962                    .resolve_schema_reference(reference)
963                    .is_some_and(|schema| self.schema_uses_special_string_type(schema, target)),
964            }),
965            _ => false,
966        }
967    }
968
969    fn schema_matches_special_string_type(&self, schema: &Schema, target: PythonSpecialStringType) -> bool {
970        let SchemaKind::Type(Type::String(string_type)) = &schema.schema_kind else {
971            return false;
972        };
973
974        match (&string_type.format, target) {
975            (VariantOrUnknownOrEmpty::Item(StringFormat::Date), PythonSpecialStringType::Date) => true,
976            (VariantOrUnknownOrEmpty::Item(StringFormat::DateTime), PythonSpecialStringType::DateTime) => true,
977            (VariantOrUnknownOrEmpty::Unknown(format), PythonSpecialStringType::Uuid) if format == "uuid" => true,
978            _ => false,
979        }
980    }
981
982    fn parameter_type_hint(&self, parameter_data: &ParameterData) -> String {
983        match &parameter_data.format {
984            ParameterSchemaOrContent::Schema(schema_ref) => {
985                self.python_type_from_schema_ref(None, None, schema_ref, !parameter_data.required, None)
986            }
987            ParameterSchemaOrContent::Content(_) => {
988                self.append_optional("dict[str, object]".to_string(), !parameter_data.required)
989            }
990        }
991    }
992
993    fn spikard_imports(&self, uses_body: bool, uses_path: bool, uses_query: bool) -> String {
994        let mut imports = Vec::new();
995        if uses_body {
996            imports.push("Body");
997        }
998        if uses_path {
999            imports.push("Path");
1000        }
1001        if uses_query {
1002            imports.push("Query");
1003        }
1004        imports.push("Request");
1005        imports.push("Spikard");
1006        imports.push("route");
1007        imports.join(", ")
1008    }
1009
1010    fn generate_routes(&self) -> Result<String> {
1011        let mut output = String::new();
1012        output.push_str("\n# Route Handlers\n\n");
1013
1014        for (path, path_item_ref) in &self.spec.paths.paths {
1015            let path_item = match path_item_ref {
1016                ReferenceOr::Item(item) => item,
1017                ReferenceOr::Reference { .. } => continue,
1018            };
1019
1020            if let Some(op) = &path_item.get {
1021                output.push_str(&self.generate_route_handler(path, "get", op)?);
1022            }
1023            if let Some(op) = &path_item.post {
1024                output.push_str(&self.generate_route_handler(path, "post", op)?);
1025            }
1026            if let Some(op) = &path_item.put {
1027                output.push_str(&self.generate_route_handler(path, "put", op)?);
1028            }
1029            if let Some(op) = &path_item.delete {
1030                output.push_str(&self.generate_route_handler(path, "delete", op)?);
1031            }
1032            if let Some(op) = &path_item.patch {
1033                output.push_str(&self.generate_route_handler(path, "patch", op)?);
1034            }
1035        }
1036
1037        Ok(output)
1038    }
1039
1040    fn generate_route_handler(&self, path: &str, method: &str, operation: &Operation) -> Result<String> {
1041        let mut output = String::new();
1042
1043        let func_name = operation
1044            .operation_id
1045            .as_ref()
1046            .map(|id| id.to_snake_case())
1047            .unwrap_or_else(|| {
1048                format!(
1049                    "{}_{}",
1050                    method,
1051                    path.replace('/', "_").replace(['{', '}'], "").trim_matches('_')
1052                )
1053            });
1054
1055        let mut path_params = Vec::new();
1056        let mut query_params = Vec::new();
1057
1058        for param_ref in &operation.parameters {
1059            if let ReferenceOr::Item(param) = param_ref {
1060                match param {
1061                    Parameter::Path { parameter_data, .. } => {
1062                        path_params.push((parameter_data.name.clone(), self.parameter_type_hint(parameter_data)));
1063                    }
1064                    Parameter::Query { parameter_data, .. } => {
1065                        let type_hint = self.parameter_type_hint(parameter_data);
1066                        query_params.push((parameter_data.name.clone(), type_hint, parameter_data.required));
1067                    }
1068                    _ => {}
1069                }
1070            }
1071        }
1072
1073        let body_type = self.extract_request_body_type(operation);
1074
1075        let return_type = self.extract_response_type(operation);
1076
1077        output.push_str(&format!(
1078            "@route(\"{}\", methods=[\"{}\"])\n",
1079            path,
1080            method.to_uppercase()
1081        ));
1082
1083        output.push_str(&format!("def {func_name}(request: Request"));
1084
1085        for (param_name, param_type) in &path_params {
1086            output.push_str(&format!(", {}: Path[{}]", param_name.to_snake_case(), param_type));
1087        }
1088
1089        for (param_name, param_type, _required) in query_params.iter().filter(|(_, _, required)| *required) {
1090            output.push_str(&format!(", {}: Query[{}]", param_name.to_snake_case(), param_type));
1091        }
1092
1093        for (param_name, param_type, required) in query_params.iter().filter(|(_, _, required)| !*required) {
1094            let _ = required;
1095            output.push_str(&format!(
1096                ", {}: Query[{}] = Query(default=None)",
1097                param_name.to_snake_case(),
1098                param_type
1099            ));
1100        }
1101
1102        if let Some(body_type_name) = &body_type {
1103            output.push_str(&format!(", body: Body[{body_type_name}]"));
1104        }
1105
1106        output.push_str(&format!(") -> {return_type}:\n"));
1107
1108        let docstring =
1109            summarize_operation_doc(operation).unwrap_or_else(|| format!("Handle {} {}.", method.to_uppercase(), path));
1110        output.push_str(&format!("    \"\"\"{docstring}\"\"\"\n"));
1111
1112        output.push_str("    raise NotImplementedError(\"Implement this endpoint\")\n\n");
1113
1114        Ok(output)
1115    }
1116
1117    fn generate_main(&self) -> String {
1118        r#"
1119# Run the application
1120if __name__ == "__main__":
1121    from spikard.config import ServerConfig
1122
1123    app.run(config=ServerConfig(host="0.0.0.0", port=8000))
1124"#
1125        .to_string()
1126    }
1127}
1128
1129fn is_named_inline_object_schema(schema: &Schema) -> bool {
1130    matches!(&schema.schema_kind, SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty())
1131}
1132
1133fn inline_array_item_schema(schema: &Schema) -> Option<&Schema> {
1134    match &schema.schema_kind {
1135        SchemaKind::Type(Type::Array(array_type)) => match &array_type.items {
1136            Some(ReferenceOr::Item(item_schema)) => Some(item_schema),
1137            _ => None,
1138        },
1139        _ => None,
1140    }
1141}
1142
1143fn summarize_operation_doc(operation: &Operation) -> Option<String> {
1144    operation
1145        .summary
1146        .as_deref()
1147        .or(operation.description.as_deref())
1148        .map(str::trim)
1149        .filter(|value| !value.is_empty())
1150        .map(|value| {
1151            let collapsed = value.split_whitespace().collect::<Vec<_>>().join(" ");
1152            if collapsed.ends_with(['.', '!', '?']) {
1153                collapsed
1154            } else {
1155                format!("{collapsed}.")
1156            }
1157        })
1158}
1159
1160fn ensure_sentence(text: &str) -> String {
1161    let trimmed = text.trim();
1162    if trimmed.ends_with(['.', '!', '?']) {
1163        trimmed.to_string()
1164    } else {
1165        format!("{trimmed}.")
1166    }
1167}
1168
1169fn operation_has_path_params(operation: &Operation) -> bool {
1170    operation
1171        .parameters
1172        .iter()
1173        .any(|param_ref| matches!(param_ref, ReferenceOr::Item(Parameter::Path { .. })))
1174}
1175
1176fn operation_has_query_params(operation: &Operation) -> bool {
1177    operation
1178        .parameters
1179        .iter()
1180        .any(|param_ref| matches!(param_ref, ReferenceOr::Item(Parameter::Query { .. })))
1181}
1182
1183fn operation_has_request_body(operation: &Operation) -> bool {
1184    operation.request_body.is_some()
1185}
1186
1187#[derive(Clone, Copy)]
1188enum PythonSpecialStringType {
1189    Date,
1190    DateTime,
1191    Uuid,
1192}