1use std::collections::{BTreeMap, HashSet};
8
9use crate::codegen::traits::file_writer::FileInfo;
10use crate::generators::multipart::{MultipartValueEncoding, multipart_parts_for_request_body};
11use crate::generators::request_inputs::{RequestInputPlan, request_input_for_operation};
12use crate::generators::response_names::response_entry_name as response_variant_name;
13use crate::ir::types::{
14 IrOperation, IrParameter, IrPrimitive, IrRequestBody, IrResponse, IrSpec, IrTypeExpr,
15 ParameterLocation,
16};
17use heck::{ToPascalCase, ToSnakeCase};
18use sigil_stitch::code_block::CodeBlock;
19use sigil_stitch::lang::python::Python;
20use sigil_stitch::prelude::*;
21
22use super::emit_models::{
23 api_type_name, future_annotations_header, is_object_schema, python_field_name,
24};
25
26pub fn generate_api_files(
28 ir: &IrSpec,
29 header: &str,
30 request_inputs: &RequestInputPlan,
31) -> Result<Vec<FileInfo>, String> {
32 let by_tag = group_by_tag(&ir.operations);
33 let mut files = Vec::with_capacity(by_tag.len());
34 for (tag, ops) in &by_tag {
35 let stem = tag.to_snake_case();
36 let filename = format!("{stem}_api.py");
37 let body = emit_api_file(tag, ops, ir, header, request_inputs);
38 files.push(FileInfo::api(filename, body));
39 }
40 Ok(files)
41}
42
43fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
44 let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
45 for op in operations {
46 let tags: Vec<String> = if op.tags.is_empty() {
47 vec!["default".to_string()]
48 } else {
49 op.tags.clone()
50 };
51 for tag in tags {
52 out.entry(tag).or_default().push(op);
53 }
54 }
55 out
56}
57
58fn emit_api_file(
59 tag: &str,
60 ops: &[&IrOperation],
61 ir: &IrSpec,
62 header: &str,
63 request_inputs: &RequestInputPlan,
64) -> String {
65 let class_name = format!("{}Api", tag.to_pascal_case());
66 let plans: Vec<OpPlan> = ops
67 .iter()
68 .map(|op| plan_operation(op, ir, request_inputs))
69 .collect();
70
71 let client_type = TypeName::importable("..runtime.client", "Client");
72 let error_type = TypeName::importable("..runtime.errors", "ApiError");
73
74 let init_body = CodeBlock::of("self._client = client", ()).expect("static body");
76 let init = FunSpec::builder("__init__")
77 .add_param(ParameterSpec::of("self", TypeName::primitive("")))
78 .add_param(ParameterSpec::of("client", client_type))
79 .returns(TypeName::primitive("None"))
80 .body(init_body)
81 .build()
82 .expect("__init__ FunSpec builds");
83
84 let mut cls = TypeSpec::builder(&class_name, TypeKind::Class).add_method(init);
85
86 for plan in &plans {
87 cls = cls.add_method(build_api_method(plan, ir, &error_type));
88 }
89
90 let mut fb = FileSpec::builder_with(&format!("{}_api.py", tag.to_snake_case()), Python::new())
91 .header(future_annotations_header())
92 .add_code(build_error_classes_block(&plans, ir))
93 .add_type(cls.build().expect("API TypeSpec builds"));
94 if plans
95 .iter()
96 .flat_map(|plan| &plan.error_responses)
97 .any(|response| response.decoding == Some(ResponseDecoding::Json))
98 {
99 fb = fb.add_import(ImportSpec::side_effect("json"));
100 }
101 if plans.iter().any(|plan| {
102 plan.body.as_ref().is_some_and(|body| {
103 body.multipart_parts.as_ref().is_some_and(|parts| {
104 parts
105 .iter()
106 .any(|part| part.value_encoding == MultipartValueEncoding::Json)
107 })
108 })
109 }) {
110 fb = fb.add_import(ImportSpec::side_effect("json"));
111 }
112 let file = fb.build().expect("API FileSpec builds");
113
114 let body = file.render(120).unwrap_or_default();
115 let mut content = String::with_capacity(header.len() + body.len());
116 content.push_str(header);
117 content.push_str(&body);
118 content
119}
120
121fn build_api_method(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> FunSpec {
122 let mut fun = FunSpec::builder(&plan.method_name);
123
124 fun = fun.add_param(ParameterSpec::of("self", TypeName::primitive("")));
126
127 for p in &plan.path_params {
129 fun = fun.add_param(ParameterSpec::of(
130 &p.var_name,
131 api_type_name(&p.param.type_expr),
132 ));
133 }
134
135 let has_keyword_params =
137 !plan.query_params.is_empty() || !plan.header_params.is_empty() || plan.body.is_some();
138 if has_keyword_params {
139 fun = fun.add_param(ParameterSpec::of("*", TypeName::primitive("")));
140 }
141
142 for p in plan.query_params.iter().chain(&plan.header_params) {
144 if p.param.required {
145 fun = fun.add_param(ParameterSpec::of(
146 &p.var_name,
147 api_type_name(&p.param.type_expr),
148 ));
149 }
150 }
151
152 if let Some(b) = &plan.body {
154 let ty = api_type_name(&b.type_expr);
155 if b.required {
156 fun = fun.add_param(ParameterSpec::of(&b.var_name, ty));
157 } else {
158 fun = fun.add_param(
159 ParameterSpec::builder(&b.var_name, TypeName::optional(ty))
160 .default_value(CodeBlock::of("None", ()).expect("None"))
161 .build()
162 .expect("optional body param"),
163 );
164 }
165 }
166
167 for p in plan.query_params.iter().chain(&plan.header_params) {
169 if !p.param.required {
170 let param_ty = api_type_name(&p.param.type_expr);
171 let param_ty = if is_already_optional(&p.param.type_expr) {
172 param_ty
173 } else {
174 TypeName::optional(param_ty)
175 };
176 fun = fun.add_param(
177 ParameterSpec::builder(&p.var_name, param_ty)
178 .default_value(CodeBlock::of("None", ()).expect("None"))
179 .build()
180 .expect("optional param"),
181 );
182 }
183 }
184
185 let return_type = if plan.typed_responses.is_empty() {
187 TypeName::primitive("None")
188 } else {
189 response_type_name(&plan.typed_responses[0])
190 };
191 fun = fun.returns(return_type);
192
193 if let Some(summary) = &plan.op.summary {
195 fun = fun.doc(&format!("{summary}."));
196 }
197
198 fun = fun.body(build_method_body(plan, ir, error_type));
200
201 fun.build().expect("API method FunSpec builds")
202}
203
204fn build_method_body(plan: &OpPlan<'_>, ir: &IrSpec, _error_type: &TypeName) -> CodeBlock {
205 let mut cb = CodeBlock::builder();
206
207 if plan.path_params.is_empty() {
209 cb.add_statement(&format!("path = \"{}\"", plan.op.path), ());
210 } else {
211 let mut path_template = plan.op.path.clone();
212 for p in &plan.path_params {
213 let placeholder = format!("{{{}}}", p.param.name);
214 let replacement = format!("{{{}}}", p.var_name);
215 path_template = path_template.replace(&placeholder, &replacement);
216 }
217 cb.add_statement("path = %V", VerbatimStrArg(path_template));
218 }
219
220 let has_query = !plan.query_params.is_empty();
222 if has_query {
223 cb.add_statement("params: dict[str, str] = {}", ());
224 for p in &plan.query_params {
225 let stringify = render_stringify(&p.var_name, &p.param.type_expr);
226 if p.param.required {
227 cb.add_statement(&format!("params[\"{}\"] = {stringify}", p.param.name), ());
228 } else {
229 cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
230 cb.add_statement(&format!("params[\"{}\"] = {stringify}%<", p.param.name), ());
231 }
232 }
233 }
234
235 let body_content_type = plan.body.as_ref().and_then(|body| {
237 let base = media_type_base(&body.media_type);
238 if base != "multipart/form-data" {
239 Some(body.media_type.as_str())
240 } else {
241 None
242 }
243 });
244 let has_headers = !plan.header_params.is_empty() || body_content_type.is_some();
245 if has_headers {
246 cb.add_statement("headers: dict[str, str] = {}", ());
247 if let Some(media_type) = body_content_type {
248 cb.add_statement(&format!("headers[\"Content-Type\"] = \"{media_type}\""), ());
249 }
250 for p in &plan.header_params {
251 let stringify = render_stringify(&p.var_name, &p.param.type_expr);
252 if p.param.required {
253 cb.add_statement(&format!("headers[\"{}\"] = {stringify}", p.param.name), ());
254 } else {
255 cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
256 cb.add_statement(
257 &format!("headers[\"{}\"] = {stringify}%<", p.param.name),
258 (),
259 );
260 }
261 }
262 }
263
264 let body_expr = if let Some(b) = &plan.body {
266 if is_object_type(&b.type_expr, ir) {
267 if b.required {
268 format!("{}.to_dict()", b.var_name)
269 } else {
270 format!(
271 "{}.to_dict() if {} is not None else None",
272 b.var_name, b.var_name
273 )
274 }
275 } else if is_array_of_objects(&b.type_expr, ir) {
276 if b.required {
277 format!("[item.to_dict() for item in {}]", b.var_name)
278 } else {
279 format!(
280 "[item.to_dict() for item in {}] if {} is not None else None",
281 b.var_name, b.var_name
282 )
283 }
284 } else {
285 b.var_name.clone()
286 }
287 } else {
288 String::new()
289 };
290
291 let mut request_args = vec![
293 format!("\"{}\"", plan.op.method.to_uppercase()),
294 "path".to_string(),
295 ];
296 if has_query {
297 request_args.push("params=params".to_string());
298 }
299 if let Some(body) = &plan.body {
300 if media_type_base(&body.media_type) == "multipart/form-data" {
301 if let Some(parts) = &body.multipart_parts {
302 emit_multipart_data(&mut cb, body, parts, ir);
303 request_args.push("files=files if files else None".to_string());
304 } else {
305 cb.add_statement(
306 "raise ValueError(\"unsupported multipart request body: schema must be object-shaped\")",
307 (),
308 );
309 }
310 } else {
311 match body.encoding {
312 BodyEncoding::Json => request_args.push(format!("json={body_expr}")),
313 BodyEncoding::FormUrlEncoded => request_args.push(format!("data={body_expr}")),
314 BodyEncoding::TextPlain | BodyEncoding::OctetStream => {
315 request_args.push(format!("content={body_expr}"));
316 }
317 BodyEncoding::Xml | BodyEncoding::Other => {
318 if body.required {
319 cb.add_statement(
320 &format!(
321 "raise ValueError(\"unsupported request body media type: {}\")",
322 body.media_type
323 ),
324 (),
325 );
326 } else {
327 cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
328 cb.add_statement(
329 &format!(
330 "raise ValueError(\"unsupported request body media type: {}\")%<",
331 body.media_type
332 ),
333 (),
334 );
335 }
336 }
337 BodyEncoding::Multipart => unreachable!("multipart handled separately"),
338 }
339 }
340 }
341 if has_headers {
342 request_args.push("headers=headers".to_string());
343 }
344
345 cb.add_code(
346 sigil_quote!(Python {
347 response = self._client.request($for(arg in &request_args; separator = ", ") { $L(arg.as_str()) })
348 })
349 .expect("request call"),
350 );
351
352 cb.add_code(emit_error_raise(plan, "response.reason_phrase"));
354
355 if !plan.typed_responses.is_empty() {
357 let tr = &plan.typed_responses[0];
358 let parse_expr = render_response_parse(tr, ir);
359 cb.add_statement(&format!("return {parse_expr}"), ());
360 } else {
361 cb.add_statement("return None", ());
362 }
363
364 cb.build().expect("API method body builds")
365}
366
367fn emit_multipart_data(
368 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
369 body: &BodyBinding,
370 parts: &[MultipartPart],
371 ir: &IrSpec,
372) {
373 cb.add_statement("files: dict[str, object] = {}", ());
374 if !body.required {
375 cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
376 }
377 for part in parts {
378 let access = format!("{}.{}", body.var_name, part.field_name);
379 if part.required {
380 emit_required_multipart_part(cb, part, &access, ir);
381 } else {
382 cb.add_statement(&format!("if {access} is not None:%>"), ());
383 emit_required_multipart_part(cb, part, &access, ir);
384 cb.add_statement("%<", ());
385 }
386 }
387 if !body.required {
388 cb.add_statement("%<", ());
389 }
390}
391
392fn emit_required_multipart_part(
393 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
394 part: &MultipartPart,
395 access: &str,
396 ir: &IrSpec,
397) {
398 cb.add_code(multipart_part_assignment(part, access, ir));
399}
400
401fn multipart_part_assignment(part: &MultipartPart, access: &str, ir: &IrSpec) -> CodeBlock {
402 let binary_stmt = format!(
403 "files[\"{}\"] = ({}.filename_or_default(\"{}\"), {}.data, \"{}\")",
404 part.wire_name, access, part.wire_name, access, part.content_type
405 );
406 let json_value = render_multipart_json_value(access, &part.type_expr, ir);
407 let json_stmt = format!(
408 "files[\"{}\"] = (None, json.dumps({json_value}), \"{}\")",
409 part.wire_name, part.content_type
410 );
411 let unsupported_stmt = "raise ValueError(\"unsupported multipart part content type\")";
412 let scalar_stmt = format!(
413 "files[\"{}\"] = (None, str({access}), \"{}\")",
414 part.wire_name, part.content_type
415 );
416
417 sigil_quote!(Python {
418 $if(part.is_binary) {
419 $L(binary_stmt.as_str())
420 } $else_if(part.value_encoding == MultipartValueEncoding::Json) {
421 $L(json_stmt.as_str())
422 } $else_if(part.value_encoding == MultipartValueEncoding::Unsupported) {
423 $L(unsupported_stmt)
424 } $else {
425 $L(scalar_stmt.as_str())
426 }
427 })
428 .expect("multipart part assignment builds")
429}
430
431fn render_multipart_json_value(access: &str, expr: &IrTypeExpr, ir: &IrSpec) -> String {
432 match expr {
433 IrTypeExpr::Named(name) if is_object_schema(name, ir) => format!("{access}.to_dict()"),
434 IrTypeExpr::Nullable(inner) => render_multipart_json_value(access, inner, ir),
435 IrTypeExpr::Array(inner) => {
436 if let IrTypeExpr::Named(name) = inner.as_ref()
437 && is_object_schema(name, ir)
438 {
439 format!("[item.to_dict() for item in {access}]")
440 } else {
441 access.to_string()
442 }
443 }
444 _ => access.to_string(),
445 }
446}
447
448fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
449 match type_expr {
450 IrTypeExpr::Primitive(
451 IrPrimitive::String
452 | IrPrimitive::Date
453 | IrPrimitive::DateTime
454 | IrPrimitive::Uuid
455 | IrPrimitive::StringWithFormat(_),
456 )
457 | IrTypeExpr::StringLiteral(_)
458 | IrTypeExpr::StringEnum(_)
459 | IrTypeExpr::Named(_) => format!("str({var})"),
460 IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
461 IrTypeExpr::Primitive(
462 IrPrimitive::Integer
463 | IrPrimitive::IntegerWithFormat(_)
464 | IrPrimitive::Number
465 | IrPrimitive::NumberWithFormat(_),
466 ) => format!("str({var})"),
467 IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
468 IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
469 _ => format!("str({var})"),
470 }
471}
472
473fn response_type_name(response: &TypedResponse) -> TypeName {
474 match response.decoding {
475 ResponseDecoding::Json => api_type_name(&response.type_expr),
476 ResponseDecoding::Text => TypeName::primitive("str"),
477 ResponseDecoding::Bytes => TypeName::primitive("bytes"),
478 }
479}
480
481fn render_response_parse(response: &TypedResponse, ir: &IrSpec) -> String {
482 match response.decoding {
483 ResponseDecoding::Json => render_json_response_parse(&response.type_expr, ir),
484 ResponseDecoding::Text => "response.text".to_string(),
485 ResponseDecoding::Bytes => "response.content".to_string(),
486 }
487}
488
489fn render_json_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
490 match type_expr {
491 IrTypeExpr::Named(name) => {
492 let py_name = name.to_pascal_case();
493 if is_object_schema(name, ir) {
494 format!("{py_name}.from_dict(response.json())")
495 } else {
496 "response.json() # type: ignore[return-value]".to_string()
497 }
498 }
499 IrTypeExpr::Array(inner) => {
500 if let IrTypeExpr::Named(name) = inner.as_ref()
501 && is_object_schema(name, ir)
502 {
503 let py_name = name.to_pascal_case();
504 return format!("[{py_name}.from_dict(item) for item in response.json()]");
505 }
506 "response.json() # type: ignore[return-value]".to_string()
507 }
508 IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
509 "response.text".to_string()
510 }
511 _ => "response.json() # type: ignore[return-value]".to_string(),
512 }
513}
514
515fn build_error_classes_block(plans: &[OpPlan<'_>], ir: &IrSpec) -> CodeBlock {
516 let mut cb = CodeBlock::builder();
517 for plan in plans {
518 let mut seen = HashSet::new();
519 let mut detail_class_names = Vec::new();
520 for response in &plan.error_responses {
521 if !seen.insert(response.class_name.clone()) {
522 continue;
523 }
524 detail_class_names.push(response.class_name.clone());
525 cb.add_statement(&format!("class {}:%>", response.class_name), ());
526 cb.add_statement(
527 "def __init__(self, status_code: int, headers: %T[str, str], raw_body: bytes) -> None:%>",
528 (TypeName::importable("collections.abc", "Mapping"),),
529 );
530 cb.add_statement("self.status_code: int = status_code", ());
531 cb.add_statement(
532 "self.headers: %T[str, str] = headers",
533 (TypeName::importable("collections.abc", "Mapping"),),
534 );
535 if let (Some(type_expr), Some(decoding)) = (&response.type_expr, response.decoding) {
536 let return_ty = error_body_return_type(type_expr, decoding);
537 cb.add_statement("self.raw_body: bytes = raw_body", ());
538 cb.add_statement("self._body_loaded: bool = False", ());
539 cb.add_statement("self._body_value: %T | None = None", (return_ty,));
540 cb.add_statement("self._body_error: Exception | None = None%<", ());
541 cb.add_code(error_body_property(type_expr, decoding, ir));
542 } else {
543 cb.add_statement("self.raw_body: bytes = raw_body%<", ());
544 }
545 cb.add("%<", ());
546 cb.add_line();
547 }
548 let unexpected = format!("{}Unexpected", plan.error_type.trim_end_matches("Error"));
549 detail_class_names.push(unexpected.clone());
550 cb.add_statement(&format!("class {unexpected}:%>"), ());
551 cb.add_statement(
552 "def __init__(self, status_code: int, headers: %T[str, str], raw_body: bytes) -> None:%>",
553 (TypeName::importable("collections.abc", "Mapping"),),
554 );
555 cb.add_statement("self.status_code: int = status_code", ());
556 cb.add_statement(
557 "self.headers: %T[str, str] = headers",
558 (TypeName::importable("collections.abc", "Mapping"),),
559 );
560 cb.add_statement("self.raw_body: bytes = raw_body%<", ());
561 cb.add_statement("@property", ());
562 cb.add_statement("def body(self) -> bytes:%>", ());
563 cb.add_statement("return self.raw_body%<%<", ());
564 cb.add_line();
565
566 let detail_type = format!("{}Detail", plan.error_type);
567 cb.add_statement(&format!("type {detail_type} = (%>"), ());
568 for (index, class_name) in detail_class_names.iter().enumerate() {
569 let prefix = if index == 0 { "" } else { "| " };
570 let suffix = if index + 1 == detail_class_names.len() {
571 "%<"
572 } else {
573 ""
574 };
575 cb.add_statement(&format!("{prefix}{class_name}{suffix}"), ());
576 }
577 cb.add_statement(")", ());
578 cb.add_line();
579
580 cb.add_statement(
581 &format!("class {}(%T):%>", plan.error_type),
582 (TypeName::importable("..runtime.errors", "ApiError"),),
583 );
584 cb.add_statement(
585 &format!(
586 "def __init__(self, status_code: int, status: str, body: bytes, detail: {detail_type}, headers: %T[str, str] | None = None, response: object | None = None) -> None:%>"
587 ),
588 (TypeName::importable("collections.abc", "Mapping"),),
589 );
590 cb.add_statement(&format!("self.detail: {detail_type} = detail"), ());
591 cb.add_statement(
592 "super().__init__(status_code, status, body, headers=headers, response=response)%<%<",
593 (),
594 );
595 cb.add_line();
596 }
597 cb.build().expect("Python error classes block builds")
598}
599
600fn error_body_property(
601 type_expr: &IrTypeExpr,
602 decoding: ResponseDecoding,
603 ir: &IrSpec,
604) -> CodeBlock {
605 let mut cb = CodeBlock::builder();
606 match decoding {
607 ResponseDecoding::Json => {
608 let return_ty = api_type_name(type_expr);
609 let parse_expr = render_error_json_body_parse(type_expr, ir);
610 cb.add_statement("@property", ());
611 cb.add_statement("def body(self) -> %T:%>", (return_ty.clone(),));
612 cb.add_statement("if not self._body_loaded:%>", ());
613 cb.add_statement("try:%>", ());
614 cb.add_statement(&format!("self._body_value = {parse_expr}%<"), ());
615 cb.add_statement("except Exception as exc:%>", ());
616 cb.add_statement("self._body_error = exc%<", ());
617 cb.add_statement("self._body_loaded = True%<", ());
618 cb.add_statement("if self._body_error is not None:%>", ());
619 cb.add_statement("raise self._body_error%<", ());
620 cb.add_statement("if self._body_value is None:%>", ());
621 cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
622 cb.add_statement("return self._body_value%<", ());
623 }
624 ResponseDecoding::Text => {
625 cb.add_statement("@property", ());
626 cb.add_statement("def body(self) -> str:%>", ());
627 cb.add_statement("if not self._body_loaded:%>", ());
628 cb.add_statement("try:%>", ());
629 cb.add_statement("self._body_value = self.raw_body.decode(\"utf-8\")%<", ());
630 cb.add_statement("except Exception as exc:%>", ());
631 cb.add_statement("self._body_error = exc%<", ());
632 cb.add_statement("self._body_loaded = True%<", ());
633 cb.add_statement("if self._body_error is not None:%>", ());
634 cb.add_statement("raise self._body_error%<", ());
635 cb.add_statement("if self._body_value is None:%>", ());
636 cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
637 cb.add_statement("return self._body_value%<", ());
638 }
639 ResponseDecoding::Bytes => {
640 cb.add_statement("@property", ());
641 cb.add_statement("def body(self) -> bytes:%>", ());
642 cb.add_statement("if not self._body_loaded:%>", ());
643 cb.add_statement("self._body_value = self.raw_body", ());
644 cb.add_statement("self._body_loaded = True", ());
645 cb.add_statement("if self._body_value is None:%>", ());
646 cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
647 cb.add_statement("return self._body_value%<", ());
648 }
649 }
650 cb.build().expect("Python error body property builds")
651}
652
653fn error_body_return_type(type_expr: &IrTypeExpr, decoding: ResponseDecoding) -> TypeName {
654 match decoding {
655 ResponseDecoding::Json => api_type_name(type_expr),
656 ResponseDecoding::Text => TypeName::primitive("str"),
657 ResponseDecoding::Bytes => TypeName::primitive("bytes"),
658 }
659}
660
661fn render_error_json_body_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
662 match type_expr {
663 IrTypeExpr::Named(name) => {
664 let py_name = name.to_pascal_case();
665 if is_object_schema(name, ir) {
666 format!("{py_name}.from_dict(json.loads(self.raw_body.decode(\"utf-8\")))")
667 } else {
668 "json.loads(self.raw_body.decode(\"utf-8\")) # type: ignore[return-value]"
669 .to_string()
670 }
671 }
672 IrTypeExpr::Array(inner) => {
673 if let IrTypeExpr::Named(name) = inner.as_ref()
674 && is_object_schema(name, ir)
675 {
676 let py_name = name.to_pascal_case();
677 return format!(
678 "[{py_name}.from_dict(item) for item in json.loads(self.raw_body.decode(\"utf-8\"))]"
679 );
680 }
681 "json.loads(self.raw_body.decode(\"utf-8\")) # type: ignore[return-value]".to_string()
682 }
683 _ => {
684 "json.loads(self.raw_body.decode(\"utf-8\")) # type: ignore[return-value]".to_string()
685 }
686 }
687}
688
689fn emit_error_raise(plan: &OpPlan<'_>, reason_expr: &str) -> CodeBlock {
690 let mut cb = CodeBlock::builder();
691 cb.add_statement("if not (200 <= response.status_code < 300):%>", ());
692 cb.add_code(error_detail_assignment(plan));
693 cb.add_statement(
694 &format!(
695 "raise {}(response.status_code, {reason_expr}, response.content, detail, headers=response.headers, response=response)%<",
696 plan.error_type
697 ),
698 (),
699 );
700 cb.build().expect("Python error raise builds")
701}
702
703fn error_detail_assignment(plan: &OpPlan<'_>) -> CodeBlock {
704 let mut cb = CodeBlock::builder();
705 let mut exact: Vec<&ErrorResponse> = plan
706 .error_responses
707 .iter()
708 .filter(|r| r.status.parse::<u16>().is_ok())
709 .collect();
710 exact.sort_by_key(|r| r.status.parse::<u16>().unwrap());
711 let wildcards: Vec<&ErrorResponse> = plan
712 .error_responses
713 .iter()
714 .filter(|r| r.status.ends_with("XX"))
715 .collect();
716 let default = plan
717 .error_responses
718 .iter()
719 .find(|r| r.status.eq_ignore_ascii_case("default"));
720
721 let mut emitted = false;
722 for response in exact {
723 let keyword = if emitted { "elif" } else { "if" };
724 cb.add_statement(
725 &format!("{keyword} response.status_code == {}:%>", response.status),
726 (),
727 );
728 cb.add_statement(&format!("{}%<", detail_ctor_statement(response)), ());
729 emitted = true;
730 }
731 for response in wildcards {
732 let (low, high) = wildcard_status_range(&response.status);
733 let keyword = if emitted { "elif" } else { "if" };
734 cb.add_statement(
735 &format!("{keyword} {low} <= response.status_code < {high}:%>"),
736 (),
737 );
738 cb.add_statement(&format!("{}%<", detail_ctor_statement(response)), ());
739 emitted = true;
740 }
741 let unexpected = format!("{}Unexpected", plan.error_type.trim_end_matches("Error"));
742 if let Some(default) = default {
743 if emitted {
744 cb.add_statement("else:%>", ());
745 cb.add_statement(&format!("{}%<", detail_ctor_statement(default)), ());
746 } else {
747 cb.add_statement(&detail_ctor_statement(default), ());
748 }
749 } else if emitted {
750 cb.add_statement("else:%>", ());
751 cb.add_statement(
752 &format!(
753 "detail = {unexpected}(response.status_code, response.headers, response.content)%<"
754 ),
755 (),
756 );
757 } else {
758 cb.add_statement(
759 &format!(
760 "detail = {unexpected}(response.status_code, response.headers, response.content)"
761 ),
762 (),
763 );
764 }
765 cb.build().expect("Python error detail assignment builds")
766}
767
768fn detail_ctor_statement(response: &ErrorResponse) -> String {
769 format!(
770 "detail = {}(response.status_code, response.headers, response.content)",
771 response.class_name
772 )
773}
774
775fn wildcard_status_range(status: &str) -> (u16, u16) {
776 match status.to_uppercase().as_str() {
777 "1XX" => (100, 200),
778 "2XX" => (200, 300),
779 "3XX" => (300, 400),
780 "4XX" => (400, 500),
781 "5XX" => (500, 600),
782 _ => (0, 1000),
783 }
784}
785
786fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
787 if let IrTypeExpr::Named(name) = type_expr {
788 return is_object_schema(name, ir);
789 }
790 false
791}
792
793fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
794 if let IrTypeExpr::Array(inner) = type_expr
795 && let IrTypeExpr::Named(name) = inner.as_ref()
796 {
797 return is_object_schema(name, ir);
798 }
799 false
800}
801
802struct OpPlan<'a> {
807 op: &'a IrOperation,
808 method_name: String,
809 error_type: String,
810 path_params: Vec<ParamBinding<'a>>,
811 query_params: Vec<ParamBinding<'a>>,
812 header_params: Vec<ParamBinding<'a>>,
813 body: Option<BodyBinding>,
814 typed_responses: Vec<TypedResponse>,
815 error_responses: Vec<ErrorResponse>,
816}
817
818struct ParamBinding<'a> {
819 param: &'a IrParameter,
820 var_name: String,
821}
822
823struct BodyBinding {
824 var_name: String,
825 type_expr: IrTypeExpr,
826 required: bool,
827 media_type: String,
828 encoding: BodyEncoding,
829 multipart_parts: Option<Vec<MultipartPart>>,
830}
831
832struct MultipartPart {
833 wire_name: String,
834 field_name: String,
835 type_expr: IrTypeExpr,
836 is_binary: bool,
837 required: bool,
838 content_type: String,
839 value_encoding: MultipartValueEncoding,
840}
841
842#[derive(Clone, Copy, PartialEq, Eq)]
843enum BodyEncoding {
844 Json,
845 Multipart,
846 FormUrlEncoded,
847 Xml,
848 TextPlain,
849 OctetStream,
850 Other,
851}
852
853#[derive(Clone, Copy, PartialEq, Eq)]
854enum ResponseDecoding {
855 Json,
856 Text,
857 Bytes,
858}
859
860struct TypedResponse {
861 type_expr: IrTypeExpr,
862 decoding: ResponseDecoding,
863}
864
865#[derive(Clone)]
866struct ErrorResponse {
867 status: String,
868 class_name: String,
869 type_expr: Option<IrTypeExpr>,
870 decoding: Option<ResponseDecoding>,
871}
872
873fn plan_operation<'a>(
874 op: &'a IrOperation,
875 ir: &IrSpec,
876 request_inputs: &RequestInputPlan,
877) -> OpPlan<'a> {
878 let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
879 let method_name = op_id.to_snake_case();
880 let error_type = format!("{}Error", op_id.to_pascal_case());
881
882 let mut used_names: HashSet<String> = HashSet::new();
883 used_names.insert("self".to_string());
884
885 let mut path_params = Vec::new();
886 let mut query_params = Vec::new();
887 let mut header_params = Vec::new();
888
889 for p in &op.parameters {
890 let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
891 let binding = ParamBinding { param: p, var_name };
892 match p.location {
893 ParameterLocation::Path => path_params.push(binding),
894 ParameterLocation::Query => query_params.push(binding),
895 ParameterLocation::Header => header_params.push(binding),
896 ParameterLocation::Cookie => header_params.push(binding),
897 }
898 }
899
900 let body = op
901 .request_body
902 .as_ref()
903 .and_then(|b| plan_body(op, b, ir, request_inputs, &mut used_names));
904
905 let typed_responses = op
906 .responses
907 .iter()
908 .filter(|r| is_success_status(&r.status))
909 .filter_map(plan_response)
910 .collect();
911 let error_responses = op
912 .responses
913 .iter()
914 .filter(|r| !is_success_status(&r.status))
915 .map(|r| plan_error_response(&op_id, r))
916 .collect();
917
918 OpPlan {
919 op,
920 method_name,
921 error_type,
922 path_params,
923 query_params,
924 header_params,
925 body,
926 typed_responses,
927 error_responses,
928 }
929}
930
931fn plan_body(
932 op: &IrOperation,
933 b: &IrRequestBody,
934 ir: &IrSpec,
935 request_inputs: &RequestInputPlan,
936 used_names: &mut HashSet<String>,
937) -> Option<BodyBinding> {
938 let (media_type, t) = pick_body_content(b)?;
939 let encoding = body_encoding(&media_type);
940 let var_name = unique_name("body", used_names);
941 let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
942 multipart_parts_for(b, &media_type, ir)
943 } else {
944 None
945 };
946 Some(BodyBinding {
947 var_name,
948 type_expr: if encoding == BodyEncoding::Multipart {
949 request_input_for_operation(request_inputs, op, &media_type)
950 .map(|input| IrTypeExpr::Named(input.name.clone()))
951 .unwrap_or(t)
952 } else {
953 t
954 },
955 required: b.required,
956 media_type,
957 encoding,
958 multipart_parts,
959 })
960}
961
962fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
963 let (media_type, t) = pick_response_content(r)?;
964 Some(TypedResponse {
965 type_expr: t,
966 decoding: response_decoding(&media_type),
967 })
968}
969
970fn plan_error_response(op_id: &str, r: &IrResponse) -> ErrorResponse {
971 match pick_response_content(r) {
972 Some((media_type, t)) => ErrorResponse {
973 status: r.status.clone(),
974 class_name: format!(
975 "{}{}",
976 op_id.to_pascal_case(),
977 response_variant_name(&r.status)
978 ),
979 type_expr: Some(t),
980 decoding: Some(response_decoding(&media_type)),
981 },
982 None => ErrorResponse {
983 status: r.status.clone(),
984 class_name: format!(
985 "{}{}",
986 op_id.to_pascal_case(),
987 response_variant_name(&r.status)
988 ),
989 type_expr: None,
990 decoding: None,
991 },
992 }
993}
994
995fn is_success_status(status: &str) -> bool {
996 status
997 .parse::<u16>()
998 .is_ok_and(|code| (200..300).contains(&code))
999 || status.eq_ignore_ascii_case("2XX")
1000}
1001
1002fn body_encoding(media_type: &str) -> BodyEncoding {
1003 let base = media_type_base(media_type);
1004 if base == "multipart/form-data" {
1005 BodyEncoding::Multipart
1006 } else if is_json_media_type(media_type) {
1007 BodyEncoding::Json
1008 } else if base == "application/x-www-form-urlencoded" {
1009 BodyEncoding::FormUrlEncoded
1010 } else if is_xml_media_type(media_type) {
1011 BodyEncoding::Xml
1012 } else if base == "text/plain" {
1013 BodyEncoding::TextPlain
1014 } else if base == "application/octet-stream" {
1015 BodyEncoding::OctetStream
1016 } else {
1017 BodyEncoding::Other
1018 }
1019}
1020
1021fn response_decoding(media_type: &str) -> ResponseDecoding {
1022 let base = media_type_base(media_type);
1023 if is_json_media_type(media_type) {
1024 ResponseDecoding::Json
1025 } else if base == "text/plain" || is_xml_media_type(media_type) {
1026 ResponseDecoding::Text
1027 } else {
1028 ResponseDecoding::Bytes
1029 }
1030}
1031
1032fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
1033 pick_media_type(&body.content, |media_type| {
1034 media_type_base(media_type) == "application/json"
1035 })
1036 .or_else(|| pick_media_type(&body.content, is_json_media_type))
1037 .or_else(|| {
1038 pick_media_type(&body.content, |media_type| {
1039 media_type_base(media_type) == "multipart/form-data"
1040 })
1041 })
1042 .or_else(|| {
1043 pick_media_type(&body.content, |media_type| {
1044 media_type_base(media_type) == "application/x-www-form-urlencoded"
1045 })
1046 })
1047 .or_else(|| pick_media_type(&body.content, is_xml_media_type))
1048 .or_else(|| {
1049 pick_media_type(&body.content, |media_type| {
1050 media_type_base(media_type) == "text/plain"
1051 })
1052 })
1053 .or_else(|| {
1054 pick_media_type(&body.content, |media_type| {
1055 media_type_base(media_type) == "application/octet-stream"
1056 })
1057 })
1058 .or_else(|| pick_first_content(&body.content))
1059}
1060
1061fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
1062 pick_media_type(&r.content, |media_type| {
1063 media_type_base(media_type) == "application/json"
1064 })
1065 .or_else(|| pick_media_type(&r.content, is_json_media_type))
1066 .or_else(|| {
1067 pick_media_type(&r.content, |media_type| {
1068 media_type_base(media_type) == "application/octet-stream"
1069 })
1070 })
1071 .or_else(|| {
1072 pick_media_type(&r.content, |media_type| {
1073 media_type_base(media_type) == "text/plain"
1074 })
1075 })
1076 .or_else(|| pick_media_type(&r.content, is_xml_media_type))
1077 .or_else(|| pick_first_content(&r.content))
1078}
1079
1080fn pick_media_type(
1081 content: &indexmap::IndexMap<String, IrTypeExpr>,
1082 predicate: impl Fn(&str) -> bool,
1083) -> Option<(String, IrTypeExpr)> {
1084 content
1085 .iter()
1086 .find(|(media_type, _)| predicate(media_type))
1087 .map(|(media_type, t)| (media_type.clone(), t.clone()))
1088}
1089
1090fn pick_first_content(
1091 content: &indexmap::IndexMap<String, IrTypeExpr>,
1092) -> Option<(String, IrTypeExpr)> {
1093 content
1094 .iter()
1095 .next()
1096 .map(|(media_type, t)| (media_type.clone(), t.clone()))
1097}
1098
1099fn media_type_base(media_type: &str) -> String {
1100 media_type
1101 .split(';')
1102 .next()
1103 .unwrap_or(media_type)
1104 .trim()
1105 .to_ascii_lowercase()
1106}
1107
1108fn is_json_media_type(media_type: &str) -> bool {
1109 let base = media_type_base(media_type);
1110 base == "application/json" || base.ends_with("+json")
1111}
1112
1113fn is_xml_media_type(media_type: &str) -> bool {
1114 let base = media_type_base(media_type);
1115 base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
1116}
1117
1118fn multipart_parts_for(
1119 body: &IrRequestBody,
1120 media_type: &str,
1121 ir: &IrSpec,
1122) -> Option<Vec<MultipartPart>> {
1123 multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
1124 parts
1125 .into_iter()
1126 .map(|part| MultipartPart {
1127 field_name: python_field_name(&part.wire_name),
1128 wire_name: part.wire_name,
1129 type_expr: part.type_expr,
1130 is_binary: part.is_binary,
1131 required: part.required,
1132 content_type: part.content_type,
1133 value_encoding: part.value_encoding,
1134 })
1135 .collect()
1136 })
1137}
1138
1139fn python_param_name(name: &str) -> String {
1140 let snake = name.to_snake_case();
1141 if snake.is_empty() {
1142 return "param".to_string();
1143 }
1144 match snake.as_str() {
1145 "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
1146 | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
1147 | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
1148 | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
1149 format!("{snake}_")
1150 }
1151 _ => snake,
1152 }
1153}
1154
1155fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
1156 if used.insert(desired.to_string()) {
1157 return desired.to_string();
1158 }
1159 for i in 2..=u32::MAX {
1160 let candidate = format!("{desired}{i}");
1161 if used.insert(candidate.clone()) {
1162 return candidate;
1163 }
1164 }
1165 unreachable!("name collision space exhausted")
1166}
1167
1168fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
1169 if !op_id.is_empty() {
1170 return op_id.to_string();
1171 }
1172 let path_part: String = path
1173 .chars()
1174 .map(|c| if c.is_alphanumeric() { c } else { '_' })
1175 .collect();
1176 format!("{method}_{path_part}")
1177}
1178
1179fn is_already_optional(expr: &IrTypeExpr) -> bool {
1182 matches!(expr, IrTypeExpr::Nullable(_))
1183}