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
314 | BodyEncoding::TextPlain
315 | BodyEncoding::OctetStream => request_args.push(format!("data={body_expr}")),
316 BodyEncoding::Xml | BodyEncoding::Other => {
317 if body.required {
318 cb.add_statement(
319 &format!(
320 "raise ValueError(\"unsupported request body media type: {}\")",
321 body.media_type
322 ),
323 (),
324 );
325 } else {
326 cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
327 cb.add_statement(
328 &format!(
329 "raise ValueError(\"unsupported request body media type: {}\")%<",
330 body.media_type
331 ),
332 (),
333 );
334 }
335 }
336 BodyEncoding::Multipart => unreachable!("multipart handled separately"),
337 }
338 }
339 }
340 if has_headers {
341 request_args.push("headers=headers".to_string());
342 }
343
344 cb.add_code(
345 sigil_quote!(Python {
346 response = self._client.request($for(arg in &request_args; separator = ", ") { $L(arg.as_str()) })
347 })
348 .expect("request call"),
349 );
350
351 cb.add_code(emit_error_raise(plan, "response.reason"));
353
354 if !plan.typed_responses.is_empty() {
356 let tr = &plan.typed_responses[0];
357 let parse_expr = render_response_parse(tr, ir);
358 cb.add_statement(&format!("return {parse_expr}"), ());
359 } else {
360 cb.add_statement("return None", ());
361 }
362
363 cb.build().expect("API method body builds")
364}
365
366fn emit_multipart_data(
367 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
368 body: &BodyBinding,
369 parts: &[MultipartPart],
370 ir: &IrSpec,
371) {
372 cb.add_statement("files: dict[str, object] = {}", ());
373 if !body.required {
374 cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
375 }
376 for part in parts {
377 let access = format!("{}.{}", body.var_name, part.field_name);
378 if part.required {
379 emit_required_multipart_part(cb, part, &access, ir);
380 } else {
381 cb.add_statement(&format!("if {access} is not None:%>"), ());
382 emit_required_multipart_part(cb, part, &access, ir);
383 cb.add_statement("%<", ());
384 }
385 }
386 if !body.required {
387 cb.add_statement("%<", ());
388 }
389}
390
391fn emit_required_multipart_part(
392 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
393 part: &MultipartPart,
394 access: &str,
395 ir: &IrSpec,
396) {
397 cb.add_code(multipart_part_assignment(part, access, ir));
398}
399
400fn multipart_part_assignment(part: &MultipartPart, access: &str, ir: &IrSpec) -> CodeBlock {
401 let binary_stmt = format!(
402 "files[\"{}\"] = ({}.filename_or_default(\"{}\"), {}.data, \"{}\")",
403 part.wire_name, access, part.wire_name, access, part.content_type
404 );
405 let json_value = render_multipart_json_value(access, &part.type_expr, ir);
406 let json_stmt = format!(
407 "files[\"{}\"] = (None, json.dumps({json_value}), \"{}\")",
408 part.wire_name, part.content_type
409 );
410 let unsupported_stmt = "raise ValueError(\"unsupported multipart part content type\")";
411 let scalar_stmt = format!(
412 "files[\"{}\"] = (None, str({access}), \"{}\")",
413 part.wire_name, part.content_type
414 );
415
416 sigil_quote!(Python {
417 $if(part.is_binary) {
418 $L(binary_stmt.as_str())
419 } $else_if(part.value_encoding == MultipartValueEncoding::Json) {
420 $L(json_stmt.as_str())
421 } $else_if(part.value_encoding == MultipartValueEncoding::Unsupported) {
422 $L(unsupported_stmt)
423 } $else {
424 $L(scalar_stmt.as_str())
425 }
426 })
427 .expect("multipart part assignment builds")
428}
429
430fn render_multipart_json_value(access: &str, expr: &IrTypeExpr, ir: &IrSpec) -> String {
431 match expr {
432 IrTypeExpr::Named(name) if is_object_schema(name, ir) => format!("{access}.to_dict()"),
433 IrTypeExpr::Nullable(inner) => render_multipart_json_value(access, inner, ir),
434 IrTypeExpr::Array(inner) => {
435 if let IrTypeExpr::Named(name) = inner.as_ref()
436 && is_object_schema(name, ir)
437 {
438 format!("[item.to_dict() for item in {access}]")
439 } else {
440 access.to_string()
441 }
442 }
443 _ => access.to_string(),
444 }
445}
446
447fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
448 match type_expr {
449 IrTypeExpr::Primitive(
450 IrPrimitive::String
451 | IrPrimitive::Date
452 | IrPrimitive::DateTime
453 | IrPrimitive::Uuid
454 | IrPrimitive::StringWithFormat(_),
455 )
456 | IrTypeExpr::StringLiteral(_)
457 | IrTypeExpr::StringEnum(_)
458 | IrTypeExpr::Named(_) => format!("str({var})"),
459 IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
460 IrTypeExpr::Primitive(
461 IrPrimitive::Integer
462 | IrPrimitive::IntegerWithFormat(_)
463 | IrPrimitive::Number
464 | IrPrimitive::NumberWithFormat(_),
465 ) => format!("str({var})"),
466 IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
467 IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
468 _ => format!("str({var})"),
469 }
470}
471
472fn response_type_name(response: &TypedResponse) -> TypeName {
473 match response.decoding {
474 ResponseDecoding::Json => api_type_name(&response.type_expr),
475 ResponseDecoding::Text => TypeName::primitive("str"),
476 ResponseDecoding::Bytes => TypeName::primitive("bytes"),
477 }
478}
479
480fn render_response_parse(response: &TypedResponse, ir: &IrSpec) -> String {
481 match response.decoding {
482 ResponseDecoding::Json => render_json_response_parse(&response.type_expr, ir),
483 ResponseDecoding::Text => "response.text".to_string(),
484 ResponseDecoding::Bytes => "response.content".to_string(),
485 }
486}
487
488fn render_json_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
489 match type_expr {
490 IrTypeExpr::Named(name) => {
491 let py_name = name.to_pascal_case();
492 if is_object_schema(name, ir) {
493 format!("{py_name}.from_dict(response.json())")
494 } else {
495 "response.json() # type: ignore[return-value]".to_string()
496 }
497 }
498 IrTypeExpr::Array(inner) => {
499 if let IrTypeExpr::Named(name) = inner.as_ref()
500 && is_object_schema(name, ir)
501 {
502 let py_name = name.to_pascal_case();
503 return format!("[{py_name}.from_dict(item) for item in response.json()]");
504 }
505 "response.json() # type: ignore[return-value]".to_string()
506 }
507 IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
508 "response.text".to_string()
509 }
510 _ => "response.json() # type: ignore[return-value]".to_string(),
511 }
512}
513
514fn build_error_classes_block(plans: &[OpPlan<'_>], ir: &IrSpec) -> CodeBlock {
515 let mut cb = CodeBlock::builder();
516 for plan in plans {
517 let mut seen = HashSet::new();
518 let mut detail_class_names = Vec::new();
519 for response in &plan.error_responses {
520 if !seen.insert(response.class_name.clone()) {
521 continue;
522 }
523 detail_class_names.push(response.class_name.clone());
524 cb.add_statement(&format!("class {}:%>", response.class_name), ());
525 cb.add_statement(
526 "def __init__(self, status_code: int, headers: %T[str, str], raw_body: bytes) -> None:%>",
527 (TypeName::importable("collections.abc", "Mapping"),),
528 );
529 cb.add_statement("self.status_code: int = status_code", ());
530 cb.add_statement(
531 "self.headers: %T[str, str] = headers",
532 (TypeName::importable("collections.abc", "Mapping"),),
533 );
534 if let (Some(type_expr), Some(decoding)) = (&response.type_expr, response.decoding) {
535 let return_ty = error_body_return_type(type_expr, decoding);
536 cb.add_statement("self.raw_body: bytes = raw_body", ());
537 cb.add_statement("self._body_loaded: bool = False", ());
538 cb.add_statement("self._body_value: %T | None = None", (return_ty,));
539 cb.add_statement("self._body_error: Exception | None = None%<", ());
540 cb.add_code(error_body_property(type_expr, decoding, ir));
541 } else {
542 cb.add_statement("self.raw_body: bytes = raw_body%<", ());
543 }
544 cb.add("%<", ());
545 cb.add_line();
546 }
547 let unexpected = format!("{}Unexpected", plan.error_type.trim_end_matches("Error"));
548 detail_class_names.push(unexpected.clone());
549 cb.add_statement(&format!("class {unexpected}:%>"), ());
550 cb.add_statement(
551 "def __init__(self, status_code: int, headers: %T[str, str], raw_body: bytes) -> None:%>",
552 (TypeName::importable("collections.abc", "Mapping"),),
553 );
554 cb.add_statement("self.status_code: int = status_code", ());
555 cb.add_statement(
556 "self.headers: %T[str, str] = headers",
557 (TypeName::importable("collections.abc", "Mapping"),),
558 );
559 cb.add_statement("self.raw_body: bytes = raw_body%<", ());
560 cb.add_statement("@property", ());
561 cb.add_statement("def body(self) -> bytes:%>", ());
562 cb.add_statement("return self.raw_body%<%<", ());
563 cb.add_line();
564
565 let detail_type = format!("{}Detail", plan.error_type);
566 cb.add_statement(&format!("type {detail_type} = (%>"), ());
567 for (index, class_name) in detail_class_names.iter().enumerate() {
568 let prefix = if index == 0 { "" } else { "| " };
569 let suffix = if index + 1 == detail_class_names.len() {
570 "%<"
571 } else {
572 ""
573 };
574 cb.add_statement(&format!("{prefix}{class_name}{suffix}"), ());
575 }
576 cb.add_statement(")", ());
577 cb.add_line();
578
579 cb.add_statement(
580 &format!("class {}(%T):%>", plan.error_type),
581 (TypeName::importable("..runtime.errors", "ApiError"),),
582 );
583 cb.add_statement(
584 &format!(
585 "def __init__(self, status_code: int, status: str, body: bytes, detail: {detail_type}, headers: %T[str, str] | None = None, response: object | None = None) -> None:%>"
586 ),
587 (TypeName::importable("collections.abc", "Mapping"),),
588 );
589 cb.add_statement(&format!("self.detail: {detail_type} = detail"), ());
590 cb.add_statement(
591 "super().__init__(status_code, status, body, headers=headers, response=response)%<%<",
592 (),
593 );
594 cb.add_line();
595 }
596 cb.build().expect("Python error classes block builds")
597}
598
599fn error_body_property(
600 type_expr: &IrTypeExpr,
601 decoding: ResponseDecoding,
602 ir: &IrSpec,
603) -> CodeBlock {
604 let mut cb = CodeBlock::builder();
605 match decoding {
606 ResponseDecoding::Json => {
607 let return_ty = api_type_name(type_expr);
608 let parse_expr = render_error_json_body_parse(type_expr, ir);
609 cb.add_statement("@property", ());
610 cb.add_statement("def body(self) -> %T:%>", (return_ty.clone(),));
611 cb.add_statement("if not self._body_loaded:%>", ());
612 cb.add_statement("try:%>", ());
613 cb.add_statement(&format!("self._body_value = {parse_expr}%<"), ());
614 cb.add_statement("except Exception as exc:%>", ());
615 cb.add_statement("self._body_error = exc%<", ());
616 cb.add_statement("self._body_loaded = True%<", ());
617 cb.add_statement("if self._body_error is not None:%>", ());
618 cb.add_statement("raise self._body_error%<", ());
619 cb.add_statement("if self._body_value is None:%>", ());
620 cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
621 cb.add_statement("return self._body_value%<", ());
622 }
623 ResponseDecoding::Text => {
624 cb.add_statement("@property", ());
625 cb.add_statement("def body(self) -> str:%>", ());
626 cb.add_statement("if not self._body_loaded:%>", ());
627 cb.add_statement("try:%>", ());
628 cb.add_statement("self._body_value = self.raw_body.decode(\"utf-8\")%<", ());
629 cb.add_statement("except Exception as exc:%>", ());
630 cb.add_statement("self._body_error = exc%<", ());
631 cb.add_statement("self._body_loaded = True%<", ());
632 cb.add_statement("if self._body_error is not None:%>", ());
633 cb.add_statement("raise self._body_error%<", ());
634 cb.add_statement("if self._body_value is None:%>", ());
635 cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
636 cb.add_statement("return self._body_value%<", ());
637 }
638 ResponseDecoding::Bytes => {
639 cb.add_statement("@property", ());
640 cb.add_statement("def body(self) -> bytes:%>", ());
641 cb.add_statement("if not self._body_loaded:%>", ());
642 cb.add_statement("self._body_value = self.raw_body", ());
643 cb.add_statement("self._body_loaded = True", ());
644 cb.add_statement("if self._body_value is None:%>", ());
645 cb.add_statement("raise RuntimeError(\"error body was not decoded\")%<", ());
646 cb.add_statement("return self._body_value%<", ());
647 }
648 }
649 cb.build().expect("Python error body property builds")
650}
651
652fn error_body_return_type(type_expr: &IrTypeExpr, decoding: ResponseDecoding) -> TypeName {
653 match decoding {
654 ResponseDecoding::Json => api_type_name(type_expr),
655 ResponseDecoding::Text => TypeName::primitive("str"),
656 ResponseDecoding::Bytes => TypeName::primitive("bytes"),
657 }
658}
659
660fn render_error_json_body_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
661 match type_expr {
662 IrTypeExpr::Named(name) => {
663 let py_name = name.to_pascal_case();
664 if is_object_schema(name, ir) {
665 format!("{py_name}.from_dict(json.loads(self.raw_body.decode(\"utf-8\")))")
666 } else {
667 "json.loads(self.raw_body.decode(\"utf-8\")) # type: ignore[return-value]"
668 .to_string()
669 }
670 }
671 IrTypeExpr::Array(inner) => {
672 if let IrTypeExpr::Named(name) = inner.as_ref()
673 && is_object_schema(name, ir)
674 {
675 let py_name = name.to_pascal_case();
676 return format!(
677 "[{py_name}.from_dict(item) for item in json.loads(self.raw_body.decode(\"utf-8\"))]"
678 );
679 }
680 "json.loads(self.raw_body.decode(\"utf-8\")) # type: ignore[return-value]".to_string()
681 }
682 _ => {
683 "json.loads(self.raw_body.decode(\"utf-8\")) # type: ignore[return-value]".to_string()
684 }
685 }
686}
687
688fn emit_error_raise(plan: &OpPlan<'_>, reason_expr: &str) -> CodeBlock {
689 let mut cb = CodeBlock::builder();
690 cb.add_statement("if not (200 <= response.status_code < 300):%>", ());
691 cb.add_code(error_detail_assignment(plan));
692 cb.add_statement(
693 &format!(
694 "raise {}(response.status_code, {reason_expr}, response.content, detail, headers=response.headers, response=response)%<",
695 plan.error_type
696 ),
697 (),
698 );
699 cb.build().expect("Python error raise builds")
700}
701
702fn error_detail_assignment(plan: &OpPlan<'_>) -> CodeBlock {
703 let mut cb = CodeBlock::builder();
704 let mut exact: Vec<&ErrorResponse> = plan
705 .error_responses
706 .iter()
707 .filter(|r| r.status.parse::<u16>().is_ok())
708 .collect();
709 exact.sort_by_key(|r| r.status.parse::<u16>().unwrap());
710 let wildcards: Vec<&ErrorResponse> = plan
711 .error_responses
712 .iter()
713 .filter(|r| r.status.ends_with("XX"))
714 .collect();
715 let default = plan
716 .error_responses
717 .iter()
718 .find(|r| r.status.eq_ignore_ascii_case("default"));
719
720 let mut emitted = false;
721 for response in exact {
722 let keyword = if emitted { "elif" } else { "if" };
723 cb.add_statement(
724 &format!("{keyword} response.status_code == {}:%>", response.status),
725 (),
726 );
727 cb.add_statement(&format!("{}%<", detail_ctor_statement(response)), ());
728 emitted = true;
729 }
730 for response in wildcards {
731 let (low, high) = wildcard_status_range(&response.status);
732 let keyword = if emitted { "elif" } else { "if" };
733 cb.add_statement(
734 &format!("{keyword} {low} <= response.status_code < {high}:%>"),
735 (),
736 );
737 cb.add_statement(&format!("{}%<", detail_ctor_statement(response)), ());
738 emitted = true;
739 }
740 let unexpected = format!("{}Unexpected", plan.error_type.trim_end_matches("Error"));
741 if let Some(default) = default {
742 if emitted {
743 cb.add_statement("else:%>", ());
744 cb.add_statement(&format!("{}%<", detail_ctor_statement(default)), ());
745 } else {
746 cb.add_statement(&detail_ctor_statement(default), ());
747 }
748 } else if emitted {
749 cb.add_statement("else:%>", ());
750 cb.add_statement(
751 &format!(
752 "detail = {unexpected}(response.status_code, response.headers, response.content)%<"
753 ),
754 (),
755 );
756 } else {
757 cb.add_statement(
758 &format!(
759 "detail = {unexpected}(response.status_code, response.headers, response.content)"
760 ),
761 (),
762 );
763 }
764 cb.build().expect("Python error detail assignment builds")
765}
766
767fn detail_ctor_statement(response: &ErrorResponse) -> String {
768 format!(
769 "detail = {}(response.status_code, response.headers, response.content)",
770 response.class_name
771 )
772}
773
774fn wildcard_status_range(status: &str) -> (u16, u16) {
775 match status.to_uppercase().as_str() {
776 "1XX" => (100, 200),
777 "2XX" => (200, 300),
778 "3XX" => (300, 400),
779 "4XX" => (400, 500),
780 "5XX" => (500, 600),
781 _ => (0, 1000),
782 }
783}
784
785fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
786 if let IrTypeExpr::Named(name) = type_expr {
787 return is_object_schema(name, ir);
788 }
789 false
790}
791
792fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
793 if let IrTypeExpr::Array(inner) = type_expr
794 && let IrTypeExpr::Named(name) = inner.as_ref()
795 {
796 return is_object_schema(name, ir);
797 }
798 false
799}
800
801struct OpPlan<'a> {
806 op: &'a IrOperation,
807 method_name: String,
808 error_type: String,
809 path_params: Vec<ParamBinding<'a>>,
810 query_params: Vec<ParamBinding<'a>>,
811 header_params: Vec<ParamBinding<'a>>,
812 body: Option<BodyBinding>,
813 typed_responses: Vec<TypedResponse>,
814 error_responses: Vec<ErrorResponse>,
815}
816
817struct ParamBinding<'a> {
818 param: &'a IrParameter,
819 var_name: String,
820}
821
822struct BodyBinding {
823 var_name: String,
824 type_expr: IrTypeExpr,
825 required: bool,
826 media_type: String,
827 encoding: BodyEncoding,
828 multipart_parts: Option<Vec<MultipartPart>>,
829}
830
831struct MultipartPart {
832 wire_name: String,
833 field_name: String,
834 type_expr: IrTypeExpr,
835 is_binary: bool,
836 required: bool,
837 content_type: String,
838 value_encoding: MultipartValueEncoding,
839}
840
841#[derive(Clone, Copy, PartialEq, Eq)]
842enum BodyEncoding {
843 Json,
844 Multipart,
845 FormUrlEncoded,
846 Xml,
847 TextPlain,
848 OctetStream,
849 Other,
850}
851
852#[derive(Clone, Copy, PartialEq, Eq)]
853enum ResponseDecoding {
854 Json,
855 Text,
856 Bytes,
857}
858
859struct TypedResponse {
860 type_expr: IrTypeExpr,
861 decoding: ResponseDecoding,
862}
863
864#[derive(Clone)]
865struct ErrorResponse {
866 status: String,
867 class_name: String,
868 type_expr: Option<IrTypeExpr>,
869 decoding: Option<ResponseDecoding>,
870}
871
872fn plan_operation<'a>(
873 op: &'a IrOperation,
874 ir: &IrSpec,
875 request_inputs: &RequestInputPlan,
876) -> OpPlan<'a> {
877 let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
878 let method_name = op_id.to_snake_case();
879 let error_type = format!("{}Error", op_id.to_pascal_case());
880
881 let mut used_names: HashSet<String> = HashSet::new();
882 used_names.insert("self".to_string());
883
884 let mut path_params = Vec::new();
885 let mut query_params = Vec::new();
886 let mut header_params = Vec::new();
887
888 for p in &op.parameters {
889 let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
890 let binding = ParamBinding { param: p, var_name };
891 match p.location {
892 ParameterLocation::Path => path_params.push(binding),
893 ParameterLocation::Query => query_params.push(binding),
894 ParameterLocation::Header => header_params.push(binding),
895 ParameterLocation::Cookie => header_params.push(binding),
896 }
897 }
898
899 let body = op
900 .request_body
901 .as_ref()
902 .and_then(|b| plan_body(op, b, ir, request_inputs, &mut used_names));
903
904 let typed_responses = op
905 .responses
906 .iter()
907 .filter(|r| is_success_status(&r.status))
908 .filter_map(plan_response)
909 .collect();
910 let error_responses = op
911 .responses
912 .iter()
913 .filter(|r| !is_success_status(&r.status))
914 .map(|r| plan_error_response(&op_id, r))
915 .collect();
916
917 OpPlan {
918 op,
919 method_name,
920 error_type,
921 path_params,
922 query_params,
923 header_params,
924 body,
925 typed_responses,
926 error_responses,
927 }
928}
929
930fn plan_body(
931 op: &IrOperation,
932 b: &IrRequestBody,
933 ir: &IrSpec,
934 request_inputs: &RequestInputPlan,
935 used_names: &mut HashSet<String>,
936) -> Option<BodyBinding> {
937 let (media_type, t) = pick_body_content(b)?;
938 let encoding = body_encoding(&media_type);
939 let var_name = unique_name("body", used_names);
940 let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
941 multipart_parts_for(b, &media_type, ir)
942 } else {
943 None
944 };
945 Some(BodyBinding {
946 var_name,
947 type_expr: if encoding == BodyEncoding::Multipart {
948 request_input_for_operation(request_inputs, op, &media_type)
949 .map(|input| IrTypeExpr::Named(input.name.clone()))
950 .unwrap_or(t)
951 } else {
952 t
953 },
954 required: b.required,
955 media_type,
956 encoding,
957 multipart_parts,
958 })
959}
960
961fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
962 let (media_type, t) = pick_response_content(r)?;
963 Some(TypedResponse {
964 type_expr: t,
965 decoding: response_decoding(&media_type),
966 })
967}
968
969fn plan_error_response(op_id: &str, r: &IrResponse) -> ErrorResponse {
970 match pick_response_content(r) {
971 Some((media_type, t)) => ErrorResponse {
972 status: r.status.clone(),
973 class_name: format!(
974 "{}{}",
975 op_id.to_pascal_case(),
976 response_variant_name(&r.status)
977 ),
978 type_expr: Some(t),
979 decoding: Some(response_decoding(&media_type)),
980 },
981 None => ErrorResponse {
982 status: r.status.clone(),
983 class_name: format!(
984 "{}{}",
985 op_id.to_pascal_case(),
986 response_variant_name(&r.status)
987 ),
988 type_expr: None,
989 decoding: None,
990 },
991 }
992}
993
994fn is_success_status(status: &str) -> bool {
995 status
996 .parse::<u16>()
997 .is_ok_and(|code| (200..300).contains(&code))
998 || status.eq_ignore_ascii_case("2XX")
999}
1000
1001fn body_encoding(media_type: &str) -> BodyEncoding {
1002 let base = media_type_base(media_type);
1003 if base == "multipart/form-data" {
1004 BodyEncoding::Multipart
1005 } else if is_json_media_type(media_type) {
1006 BodyEncoding::Json
1007 } else if base == "application/x-www-form-urlencoded" {
1008 BodyEncoding::FormUrlEncoded
1009 } else if is_xml_media_type(media_type) {
1010 BodyEncoding::Xml
1011 } else if base == "text/plain" {
1012 BodyEncoding::TextPlain
1013 } else if base == "application/octet-stream" {
1014 BodyEncoding::OctetStream
1015 } else {
1016 BodyEncoding::Other
1017 }
1018}
1019
1020fn response_decoding(media_type: &str) -> ResponseDecoding {
1021 let base = media_type_base(media_type);
1022 if is_json_media_type(media_type) {
1023 ResponseDecoding::Json
1024 } else if base == "text/plain" || is_xml_media_type(media_type) {
1025 ResponseDecoding::Text
1026 } else {
1027 ResponseDecoding::Bytes
1028 }
1029}
1030
1031fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
1032 pick_media_type(&body.content, |media_type| {
1033 media_type_base(media_type) == "application/json"
1034 })
1035 .or_else(|| pick_media_type(&body.content, is_json_media_type))
1036 .or_else(|| {
1037 pick_media_type(&body.content, |media_type| {
1038 media_type_base(media_type) == "multipart/form-data"
1039 })
1040 })
1041 .or_else(|| {
1042 pick_media_type(&body.content, |media_type| {
1043 media_type_base(media_type) == "application/x-www-form-urlencoded"
1044 })
1045 })
1046 .or_else(|| pick_media_type(&body.content, is_xml_media_type))
1047 .or_else(|| {
1048 pick_media_type(&body.content, |media_type| {
1049 media_type_base(media_type) == "text/plain"
1050 })
1051 })
1052 .or_else(|| {
1053 pick_media_type(&body.content, |media_type| {
1054 media_type_base(media_type) == "application/octet-stream"
1055 })
1056 })
1057 .or_else(|| pick_first_content(&body.content))
1058}
1059
1060fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
1061 pick_media_type(&r.content, |media_type| {
1062 media_type_base(media_type) == "application/json"
1063 })
1064 .or_else(|| pick_media_type(&r.content, is_json_media_type))
1065 .or_else(|| {
1066 pick_media_type(&r.content, |media_type| {
1067 media_type_base(media_type) == "application/octet-stream"
1068 })
1069 })
1070 .or_else(|| {
1071 pick_media_type(&r.content, |media_type| {
1072 media_type_base(media_type) == "text/plain"
1073 })
1074 })
1075 .or_else(|| pick_media_type(&r.content, is_xml_media_type))
1076 .or_else(|| pick_first_content(&r.content))
1077}
1078
1079fn pick_media_type(
1080 content: &indexmap::IndexMap<String, IrTypeExpr>,
1081 predicate: impl Fn(&str) -> bool,
1082) -> Option<(String, IrTypeExpr)> {
1083 content
1084 .iter()
1085 .find(|(media_type, _)| predicate(media_type))
1086 .map(|(media_type, t)| (media_type.clone(), t.clone()))
1087}
1088
1089fn pick_first_content(
1090 content: &indexmap::IndexMap<String, IrTypeExpr>,
1091) -> Option<(String, IrTypeExpr)> {
1092 content
1093 .iter()
1094 .next()
1095 .map(|(media_type, t)| (media_type.clone(), t.clone()))
1096}
1097
1098fn media_type_base(media_type: &str) -> String {
1099 media_type
1100 .split(';')
1101 .next()
1102 .unwrap_or(media_type)
1103 .trim()
1104 .to_ascii_lowercase()
1105}
1106
1107fn is_json_media_type(media_type: &str) -> bool {
1108 let base = media_type_base(media_type);
1109 base == "application/json" || base.ends_with("+json")
1110}
1111
1112fn is_xml_media_type(media_type: &str) -> bool {
1113 let base = media_type_base(media_type);
1114 base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
1115}
1116
1117fn multipart_parts_for(
1118 body: &IrRequestBody,
1119 media_type: &str,
1120 ir: &IrSpec,
1121) -> Option<Vec<MultipartPart>> {
1122 multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
1123 parts
1124 .into_iter()
1125 .map(|part| MultipartPart {
1126 field_name: python_field_name(&part.wire_name),
1127 wire_name: part.wire_name,
1128 type_expr: part.type_expr,
1129 is_binary: part.is_binary,
1130 required: part.required,
1131 content_type: part.content_type,
1132 value_encoding: part.value_encoding,
1133 })
1134 .collect()
1135 })
1136}
1137
1138fn python_param_name(name: &str) -> String {
1139 let snake = name.to_snake_case();
1140 if snake.is_empty() {
1141 return "param".to_string();
1142 }
1143 match snake.as_str() {
1144 "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
1145 | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
1146 | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
1147 | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
1148 format!("{snake}_")
1149 }
1150 _ => snake,
1151 }
1152}
1153
1154fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
1155 if used.insert(desired.to_string()) {
1156 return desired.to_string();
1157 }
1158 for i in 2..=u32::MAX {
1159 let candidate = format!("{desired}{i}");
1160 if used.insert(candidate.clone()) {
1161 return candidate;
1162 }
1163 }
1164 unreachable!("name collision space exhausted")
1165}
1166
1167fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
1168 if !op_id.is_empty() {
1169 return op_id.to_string();
1170 }
1171 let path_part: String = path
1172 .chars()
1173 .map(|c| if c.is_alphanumeric() { c } else { '_' })
1174 .collect();
1175 format!("{method}_{path_part}")
1176}
1177
1178fn is_already_optional(expr: &IrTypeExpr) -> bool {
1181 matches!(expr, IrTypeExpr::Nullable(_))
1182}