1use 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 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 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 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) = ¶meter_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) = ¶meter_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 ¶meter_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}