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