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