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::ir::types::{
13 IrOperation, IrParameter, IrPrimitive, IrRequestBody, IrResponse, IrSpec, IrTypeExpr,
14 ParameterLocation,
15};
16use heck::{ToPascalCase, ToSnakeCase};
17use sigil_stitch::code_block::CodeBlock;
18use sigil_stitch::lang::python::Python;
19use sigil_stitch::prelude::*;
20
21use super::emit_models::{
22 api_type_name, future_annotations_header, is_object_schema, python_field_name,
23};
24
25pub fn generate_api_files(
27 ir: &IrSpec,
28 header: &str,
29 request_inputs: &RequestInputPlan,
30) -> Result<Vec<FileInfo>, String> {
31 let by_tag = group_by_tag(&ir.operations);
32 let mut files = Vec::with_capacity(by_tag.len());
33 for (tag, ops) in &by_tag {
34 let stem = tag.to_snake_case();
35 let filename = format!("{stem}_api.py");
36 let body = emit_api_file(tag, ops, ir, header, request_inputs);
37 files.push(FileInfo::api(filename, body));
38 }
39 Ok(files)
40}
41
42fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
43 let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
44 for op in operations {
45 let tags: Vec<String> = if op.tags.is_empty() {
46 vec!["default".to_string()]
47 } else {
48 op.tags.clone()
49 };
50 for tag in tags {
51 out.entry(tag).or_default().push(op);
52 }
53 }
54 out
55}
56
57fn emit_api_file(
58 tag: &str,
59 ops: &[&IrOperation],
60 ir: &IrSpec,
61 header: &str,
62 request_inputs: &RequestInputPlan,
63) -> String {
64 let class_name = format!("{}Api", tag.to_pascal_case());
65 let plans: Vec<OpPlan> = ops
66 .iter()
67 .map(|op| plan_operation(op, ir, request_inputs))
68 .collect();
69
70 let client_type = TypeName::importable("..runtime.client", "Client");
71 let error_type = TypeName::importable("..runtime.errors", "ApiError");
72
73 let init_body = CodeBlock::of("self._client = client", ()).expect("static body");
75 let init = FunSpec::builder("__init__")
76 .add_param(ParameterSpec::of("self", TypeName::primitive("")))
77 .add_param(ParameterSpec::of("client", client_type))
78 .returns(TypeName::primitive("None"))
79 .body(init_body)
80 .build()
81 .expect("__init__ FunSpec builds");
82
83 let mut cls = TypeSpec::builder(&class_name, TypeKind::Class).add_method(init);
84
85 for plan in &plans {
86 cls = cls.add_method(build_api_method(plan, ir, &error_type));
87 }
88
89 let mut fb = FileSpec::builder_with(&format!("{}_api.py", tag.to_snake_case()), Python::new())
90 .header(future_annotations_header())
91 .add_type(cls.build().expect("API TypeSpec builds"));
92 if plans.iter().any(|plan| {
93 plan.body.as_ref().is_some_and(|body| {
94 body.multipart_parts.as_ref().is_some_and(|parts| {
95 parts
96 .iter()
97 .any(|part| part.value_encoding == MultipartValueEncoding::Json)
98 })
99 })
100 }) {
101 fb = fb.add_import(ImportSpec::side_effect("json"));
102 }
103 let file = fb.build().expect("API FileSpec builds");
104
105 let body = file.render(120).unwrap_or_default();
106 let mut content = String::with_capacity(header.len() + body.len());
107 content.push_str(header);
108 content.push_str(&body);
109 content
110}
111
112fn build_api_method(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> FunSpec {
113 let mut fun = FunSpec::builder(&plan.method_name);
114
115 fun = fun.add_param(ParameterSpec::of("self", TypeName::primitive("")));
117
118 for p in &plan.path_params {
120 fun = fun.add_param(ParameterSpec::of(
121 &p.var_name,
122 api_type_name(&p.param.type_expr),
123 ));
124 }
125
126 let has_keyword_params =
128 !plan.query_params.is_empty() || !plan.header_params.is_empty() || plan.body.is_some();
129 if has_keyword_params {
130 fun = fun.add_param(ParameterSpec::of("*", TypeName::primitive("")));
131 }
132
133 for p in plan.query_params.iter().chain(&plan.header_params) {
135 if p.param.required {
136 fun = fun.add_param(ParameterSpec::of(
137 &p.var_name,
138 api_type_name(&p.param.type_expr),
139 ));
140 }
141 }
142
143 if let Some(b) = &plan.body {
145 let ty = api_type_name(&b.type_expr);
146 if b.required {
147 fun = fun.add_param(ParameterSpec::of(&b.var_name, ty));
148 } else {
149 fun = fun.add_param(
150 ParameterSpec::builder(&b.var_name, TypeName::optional(ty))
151 .default_value(CodeBlock::of("None", ()).expect("None"))
152 .build()
153 .expect("optional body param"),
154 );
155 }
156 }
157
158 for p in plan.query_params.iter().chain(&plan.header_params) {
160 if !p.param.required {
161 let param_ty = api_type_name(&p.param.type_expr);
162 let param_ty = if is_already_optional(&p.param.type_expr) {
163 param_ty
164 } else {
165 TypeName::optional(param_ty)
166 };
167 fun = fun.add_param(
168 ParameterSpec::builder(&p.var_name, param_ty)
169 .default_value(CodeBlock::of("None", ()).expect("None"))
170 .build()
171 .expect("optional param"),
172 );
173 }
174 }
175
176 let return_type = if plan.typed_responses.is_empty() {
178 TypeName::primitive("None")
179 } else {
180 response_type_name(&plan.typed_responses[0])
181 };
182 fun = fun.returns(return_type);
183
184 if let Some(summary) = &plan.op.summary {
186 fun = fun.doc(&format!("{summary}."));
187 }
188
189 fun = fun.body(build_method_body(plan, ir, error_type));
191
192 fun.build().expect("API method FunSpec builds")
193}
194
195fn build_method_body(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> CodeBlock {
196 let mut cb = CodeBlock::builder();
197
198 if plan.path_params.is_empty() {
200 cb.add_statement(&format!("path = \"{}\"", plan.op.path), ());
201 } else {
202 let mut path_template = plan.op.path.clone();
203 for p in &plan.path_params {
204 let placeholder = format!("{{{}}}", p.param.name);
205 let replacement = format!("{{{}}}", p.var_name);
206 path_template = path_template.replace(&placeholder, &replacement);
207 }
208 cb.add_statement("path = %V", VerbatimStrArg(path_template));
209 }
210
211 let has_query = !plan.query_params.is_empty();
213 if has_query {
214 cb.add_statement("params: dict[str, str] = {}", ());
215 for p in &plan.query_params {
216 let stringify = render_stringify(&p.var_name, &p.param.type_expr);
217 if p.param.required {
218 cb.add_statement(&format!("params[\"{}\"] = {stringify}", p.param.name), ());
219 } else {
220 cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
221 cb.add_statement(&format!("params[\"{}\"] = {stringify}%<", p.param.name), ());
222 }
223 }
224 }
225
226 let body_content_type = plan.body.as_ref().and_then(|body| {
228 let base = media_type_base(&body.media_type);
229 if base != "multipart/form-data" {
230 Some(body.media_type.as_str())
231 } else {
232 None
233 }
234 });
235 let has_headers = !plan.header_params.is_empty() || body_content_type.is_some();
236 if has_headers {
237 cb.add_statement("headers: dict[str, str] = {}", ());
238 if let Some(media_type) = body_content_type {
239 cb.add_statement(&format!("headers[\"Content-Type\"] = \"{media_type}\""), ());
240 }
241 for p in &plan.header_params {
242 let stringify = render_stringify(&p.var_name, &p.param.type_expr);
243 if p.param.required {
244 cb.add_statement(&format!("headers[\"{}\"] = {stringify}", p.param.name), ());
245 } else {
246 cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
247 cb.add_statement(
248 &format!("headers[\"{}\"] = {stringify}%<", p.param.name),
249 (),
250 );
251 }
252 }
253 }
254
255 let body_expr = if let Some(b) = &plan.body {
257 if is_object_type(&b.type_expr, ir) {
258 if b.required {
259 format!("{}.to_dict()", b.var_name)
260 } else {
261 format!(
262 "{}.to_dict() if {} is not None else None",
263 b.var_name, b.var_name
264 )
265 }
266 } else if is_array_of_objects(&b.type_expr, ir) {
267 if b.required {
268 format!("[item.to_dict() for item in {}]", b.var_name)
269 } else {
270 format!(
271 "[item.to_dict() for item in {}] if {} is not None else None",
272 b.var_name, b.var_name
273 )
274 }
275 } else {
276 b.var_name.clone()
277 }
278 } else {
279 String::new()
280 };
281
282 let mut request_args = vec![
284 format!("\"{}\"", plan.op.method.to_uppercase()),
285 "path".to_string(),
286 ];
287 if has_query {
288 request_args.push("params=params".to_string());
289 }
290 if let Some(body) = &plan.body {
291 if media_type_base(&body.media_type) == "multipart/form-data" {
292 if let Some(parts) = &body.multipart_parts {
293 emit_multipart_data(&mut cb, body, parts, ir);
294 request_args.push("files=files if files else None".to_string());
295 } else {
296 cb.add_statement(
297 "raise ValueError(\"unsupported multipart request body: schema must be object-shaped\")",
298 (),
299 );
300 }
301 } else {
302 match body.encoding {
303 BodyEncoding::Json => request_args.push(format!("json={body_expr}")),
304 BodyEncoding::FormUrlEncoded => request_args.push(format!("data={body_expr}")),
305 BodyEncoding::TextPlain | BodyEncoding::OctetStream => {
306 request_args.push(format!("content={body_expr}"));
307 }
308 BodyEncoding::Xml | BodyEncoding::Other => {
309 if body.required {
310 cb.add_statement(
311 &format!(
312 "raise ValueError(\"unsupported request body media type: {}\")",
313 body.media_type
314 ),
315 (),
316 );
317 } else {
318 cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
319 cb.add_statement(
320 &format!(
321 "raise ValueError(\"unsupported request body media type: {}\")%<",
322 body.media_type
323 ),
324 (),
325 );
326 }
327 }
328 BodyEncoding::Multipart => unreachable!("multipart handled separately"),
329 }
330 }
331 }
332 if has_headers {
333 request_args.push("headers=headers".to_string());
334 }
335
336 cb.add_code(
337 sigil_quote!(Python {
338 response = self._client.request($for(arg in &request_args; separator = ", ") { $L(arg.as_str()) })
339 })
340 .expect("request call"),
341 );
342
343 cb.add_statement("if response.status_code >= 400:%>", ());
345 cb.add_statement(
346 "raise %T(response.status_code, response.reason_phrase, response.content)%<",
347 (error_type.clone(),),
348 );
349
350 if !plan.typed_responses.is_empty() {
352 let tr = &plan.typed_responses[0];
353 let parse_expr = render_response_parse(tr, ir);
354 cb.add_statement(&format!("return {parse_expr}"), ());
355 } else {
356 cb.add_statement("return None", ());
357 }
358
359 cb.build().expect("API method body builds")
360}
361
362fn emit_multipart_data(
363 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
364 body: &BodyBinding,
365 parts: &[MultipartPart],
366 ir: &IrSpec,
367) {
368 cb.add_statement("files: dict[str, object] = {}", ());
369 if !body.required {
370 cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
371 }
372 for part in parts {
373 let access = format!("{}.{}", body.var_name, part.field_name);
374 if part.required {
375 emit_required_multipart_part(cb, part, &access, ir);
376 } else {
377 cb.add_statement(&format!("if {access} is not None:%>"), ());
378 emit_required_multipart_part(cb, part, &access, ir);
379 cb.add_statement("%<", ());
380 }
381 }
382 if !body.required {
383 cb.add_statement("%<", ());
384 }
385}
386
387fn emit_required_multipart_part(
388 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
389 part: &MultipartPart,
390 access: &str,
391 ir: &IrSpec,
392) {
393 cb.add_code(multipart_part_assignment(part, access, ir));
394}
395
396fn multipart_part_assignment(part: &MultipartPart, access: &str, ir: &IrSpec) -> CodeBlock {
397 let binary_stmt = format!(
398 "files[\"{}\"] = ({}.filename_or_default(\"{}\"), {}.data, \"{}\")",
399 part.wire_name, access, part.wire_name, access, part.content_type
400 );
401 let json_value = render_multipart_json_value(access, &part.type_expr, ir);
402 let json_stmt = format!(
403 "files[\"{}\"] = (None, json.dumps({json_value}), \"{}\")",
404 part.wire_name, part.content_type
405 );
406 let unsupported_stmt = "raise ValueError(\"unsupported multipart part content type\")";
407 let scalar_stmt = format!(
408 "files[\"{}\"] = (None, str({access}), \"{}\")",
409 part.wire_name, part.content_type
410 );
411
412 sigil_quote!(Python {
413 $if(part.is_binary) {
414 $L(binary_stmt.as_str())
415 } $else_if(part.value_encoding == MultipartValueEncoding::Json) {
416 $L(json_stmt.as_str())
417 } $else_if(part.value_encoding == MultipartValueEncoding::Unsupported) {
418 $L(unsupported_stmt)
419 } $else {
420 $L(scalar_stmt.as_str())
421 }
422 })
423 .expect("multipart part assignment builds")
424}
425
426fn render_multipart_json_value(access: &str, expr: &IrTypeExpr, ir: &IrSpec) -> String {
427 match expr {
428 IrTypeExpr::Named(name) if is_object_schema(name, ir) => format!("{access}.to_dict()"),
429 IrTypeExpr::Nullable(inner) => render_multipart_json_value(access, inner, ir),
430 IrTypeExpr::Array(inner) => {
431 if let IrTypeExpr::Named(name) = inner.as_ref()
432 && is_object_schema(name, ir)
433 {
434 format!("[item.to_dict() for item in {access}]")
435 } else {
436 access.to_string()
437 }
438 }
439 _ => access.to_string(),
440 }
441}
442
443fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
444 match type_expr {
445 IrTypeExpr::Primitive(
446 IrPrimitive::String
447 | IrPrimitive::Date
448 | IrPrimitive::DateTime
449 | IrPrimitive::Uuid
450 | IrPrimitive::StringWithFormat(_),
451 )
452 | IrTypeExpr::StringLiteral(_)
453 | IrTypeExpr::StringEnum(_)
454 | IrTypeExpr::Named(_) => format!("str({var})"),
455 IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
456 IrTypeExpr::Primitive(
457 IrPrimitive::Integer
458 | IrPrimitive::IntegerWithFormat(_)
459 | IrPrimitive::Number
460 | IrPrimitive::NumberWithFormat(_),
461 ) => format!("str({var})"),
462 IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
463 IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
464 _ => format!("str({var})"),
465 }
466}
467
468fn response_type_name(response: &TypedResponse) -> TypeName {
469 match response.decoding {
470 ResponseDecoding::Json => api_type_name(&response.type_expr),
471 ResponseDecoding::Text => TypeName::primitive("str"),
472 ResponseDecoding::Bytes => TypeName::primitive("bytes"),
473 }
474}
475
476fn render_response_parse(response: &TypedResponse, ir: &IrSpec) -> String {
477 match response.decoding {
478 ResponseDecoding::Json => render_json_response_parse(&response.type_expr, ir),
479 ResponseDecoding::Text => "response.text".to_string(),
480 ResponseDecoding::Bytes => "response.content".to_string(),
481 }
482}
483
484fn render_json_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
485 match type_expr {
486 IrTypeExpr::Named(name) => {
487 let py_name = name.to_pascal_case();
488 if is_object_schema(name, ir) {
489 format!("{py_name}.from_dict(response.json())")
490 } else {
491 "response.json() # type: ignore[return-value]".to_string()
492 }
493 }
494 IrTypeExpr::Array(inner) => {
495 if let IrTypeExpr::Named(name) = inner.as_ref()
496 && is_object_schema(name, ir)
497 {
498 let py_name = name.to_pascal_case();
499 return format!("[{py_name}.from_dict(item) for item in response.json()]");
500 }
501 "response.json() # type: ignore[return-value]".to_string()
502 }
503 IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
504 "response.text".to_string()
505 }
506 _ => "response.json() # type: ignore[return-value]".to_string(),
507 }
508}
509
510fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
511 if let IrTypeExpr::Named(name) = type_expr {
512 return is_object_schema(name, ir);
513 }
514 false
515}
516
517fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
518 if let IrTypeExpr::Array(inner) = type_expr
519 && let IrTypeExpr::Named(name) = inner.as_ref()
520 {
521 return is_object_schema(name, ir);
522 }
523 false
524}
525
526struct OpPlan<'a> {
531 op: &'a IrOperation,
532 method_name: String,
533 path_params: Vec<ParamBinding<'a>>,
534 query_params: Vec<ParamBinding<'a>>,
535 header_params: Vec<ParamBinding<'a>>,
536 body: Option<BodyBinding>,
537 typed_responses: Vec<TypedResponse>,
538}
539
540struct ParamBinding<'a> {
541 param: &'a IrParameter,
542 var_name: String,
543}
544
545struct BodyBinding {
546 var_name: String,
547 type_expr: IrTypeExpr,
548 required: bool,
549 media_type: String,
550 encoding: BodyEncoding,
551 multipart_parts: Option<Vec<MultipartPart>>,
552}
553
554struct MultipartPart {
555 wire_name: String,
556 field_name: String,
557 type_expr: IrTypeExpr,
558 is_binary: bool,
559 required: bool,
560 content_type: String,
561 value_encoding: MultipartValueEncoding,
562}
563
564#[derive(Clone, Copy, PartialEq, Eq)]
565enum BodyEncoding {
566 Json,
567 Multipart,
568 FormUrlEncoded,
569 Xml,
570 TextPlain,
571 OctetStream,
572 Other,
573}
574
575#[derive(Clone, Copy, PartialEq, Eq)]
576enum ResponseDecoding {
577 Json,
578 Text,
579 Bytes,
580}
581
582struct TypedResponse {
583 type_expr: IrTypeExpr,
584 decoding: ResponseDecoding,
585}
586
587fn plan_operation<'a>(
588 op: &'a IrOperation,
589 ir: &IrSpec,
590 request_inputs: &RequestInputPlan,
591) -> OpPlan<'a> {
592 let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
593 let method_name = op_id.to_snake_case();
594
595 let mut used_names: HashSet<String> = HashSet::new();
596 used_names.insert("self".to_string());
597
598 let mut path_params = Vec::new();
599 let mut query_params = Vec::new();
600 let mut header_params = Vec::new();
601
602 for p in &op.parameters {
603 let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
604 let binding = ParamBinding { param: p, var_name };
605 match p.location {
606 ParameterLocation::Path => path_params.push(binding),
607 ParameterLocation::Query => query_params.push(binding),
608 ParameterLocation::Header => header_params.push(binding),
609 ParameterLocation::Cookie => header_params.push(binding),
610 }
611 }
612
613 let body = op
614 .request_body
615 .as_ref()
616 .and_then(|b| plan_body(op, b, ir, request_inputs, &mut used_names));
617
618 let typed_responses = op.responses.iter().filter_map(plan_response).collect();
619
620 OpPlan {
621 op,
622 method_name,
623 path_params,
624 query_params,
625 header_params,
626 body,
627 typed_responses,
628 }
629}
630
631fn plan_body(
632 op: &IrOperation,
633 b: &IrRequestBody,
634 ir: &IrSpec,
635 request_inputs: &RequestInputPlan,
636 used_names: &mut HashSet<String>,
637) -> Option<BodyBinding> {
638 let (media_type, t) = pick_body_content(b)?;
639 let encoding = body_encoding(&media_type);
640 let var_name = unique_name("body", used_names);
641 let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
642 multipart_parts_for(b, &media_type, ir)
643 } else {
644 None
645 };
646 Some(BodyBinding {
647 var_name,
648 type_expr: if encoding == BodyEncoding::Multipart {
649 request_input_for_operation(request_inputs, op, &media_type)
650 .map(|input| IrTypeExpr::Named(input.name.clone()))
651 .unwrap_or(t)
652 } else {
653 t
654 },
655 required: b.required,
656 media_type,
657 encoding,
658 multipart_parts,
659 })
660}
661
662fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
663 let (media_type, t) = pick_response_content(r)?;
664 Some(TypedResponse {
665 type_expr: t,
666 decoding: response_decoding(&media_type),
667 })
668}
669
670fn body_encoding(media_type: &str) -> BodyEncoding {
671 let base = media_type_base(media_type);
672 if base == "multipart/form-data" {
673 BodyEncoding::Multipart
674 } else if is_json_media_type(media_type) {
675 BodyEncoding::Json
676 } else if base == "application/x-www-form-urlencoded" {
677 BodyEncoding::FormUrlEncoded
678 } else if is_xml_media_type(media_type) {
679 BodyEncoding::Xml
680 } else if base == "text/plain" {
681 BodyEncoding::TextPlain
682 } else if base == "application/octet-stream" {
683 BodyEncoding::OctetStream
684 } else {
685 BodyEncoding::Other
686 }
687}
688
689fn response_decoding(media_type: &str) -> ResponseDecoding {
690 let base = media_type_base(media_type);
691 if is_json_media_type(media_type) {
692 ResponseDecoding::Json
693 } else if base == "text/plain" || is_xml_media_type(media_type) {
694 ResponseDecoding::Text
695 } else {
696 ResponseDecoding::Bytes
697 }
698}
699
700fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
701 pick_media_type(&body.content, |media_type| {
702 media_type_base(media_type) == "application/json"
703 })
704 .or_else(|| pick_media_type(&body.content, is_json_media_type))
705 .or_else(|| {
706 pick_media_type(&body.content, |media_type| {
707 media_type_base(media_type) == "multipart/form-data"
708 })
709 })
710 .or_else(|| {
711 pick_media_type(&body.content, |media_type| {
712 media_type_base(media_type) == "application/x-www-form-urlencoded"
713 })
714 })
715 .or_else(|| pick_media_type(&body.content, is_xml_media_type))
716 .or_else(|| {
717 pick_media_type(&body.content, |media_type| {
718 media_type_base(media_type) == "text/plain"
719 })
720 })
721 .or_else(|| {
722 pick_media_type(&body.content, |media_type| {
723 media_type_base(media_type) == "application/octet-stream"
724 })
725 })
726 .or_else(|| pick_first_content(&body.content))
727}
728
729fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
730 pick_media_type(&r.content, |media_type| {
731 media_type_base(media_type) == "application/json"
732 })
733 .or_else(|| pick_media_type(&r.content, is_json_media_type))
734 .or_else(|| {
735 pick_media_type(&r.content, |media_type| {
736 media_type_base(media_type) == "application/octet-stream"
737 })
738 })
739 .or_else(|| {
740 pick_media_type(&r.content, |media_type| {
741 media_type_base(media_type) == "text/plain"
742 })
743 })
744 .or_else(|| pick_media_type(&r.content, is_xml_media_type))
745 .or_else(|| pick_first_content(&r.content))
746}
747
748fn pick_media_type(
749 content: &indexmap::IndexMap<String, IrTypeExpr>,
750 predicate: impl Fn(&str) -> bool,
751) -> Option<(String, IrTypeExpr)> {
752 content
753 .iter()
754 .find(|(media_type, _)| predicate(media_type))
755 .map(|(media_type, t)| (media_type.clone(), t.clone()))
756}
757
758fn pick_first_content(
759 content: &indexmap::IndexMap<String, IrTypeExpr>,
760) -> Option<(String, IrTypeExpr)> {
761 content
762 .iter()
763 .next()
764 .map(|(media_type, t)| (media_type.clone(), t.clone()))
765}
766
767fn media_type_base(media_type: &str) -> String {
768 media_type
769 .split(';')
770 .next()
771 .unwrap_or(media_type)
772 .trim()
773 .to_ascii_lowercase()
774}
775
776fn is_json_media_type(media_type: &str) -> bool {
777 let base = media_type_base(media_type);
778 base == "application/json" || base.ends_with("+json")
779}
780
781fn is_xml_media_type(media_type: &str) -> bool {
782 let base = media_type_base(media_type);
783 base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
784}
785
786fn multipart_parts_for(
787 body: &IrRequestBody,
788 media_type: &str,
789 ir: &IrSpec,
790) -> Option<Vec<MultipartPart>> {
791 multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
792 parts
793 .into_iter()
794 .map(|part| MultipartPart {
795 field_name: python_field_name(&part.wire_name),
796 wire_name: part.wire_name,
797 type_expr: part.type_expr,
798 is_binary: part.is_binary,
799 required: part.required,
800 content_type: part.content_type,
801 value_encoding: part.value_encoding,
802 })
803 .collect()
804 })
805}
806
807fn python_param_name(name: &str) -> String {
808 let snake = name.to_snake_case();
809 if snake.is_empty() {
810 return "param".to_string();
811 }
812 match snake.as_str() {
813 "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
814 | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
815 | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
816 | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
817 format!("{snake}_")
818 }
819 _ => snake,
820 }
821}
822
823fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
824 if used.insert(desired.to_string()) {
825 return desired.to_string();
826 }
827 for i in 2..=u32::MAX {
828 let candidate = format!("{desired}{i}");
829 if used.insert(candidate.clone()) {
830 return candidate;
831 }
832 }
833 unreachable!("name collision space exhausted")
834}
835
836fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
837 if !op_id.is_empty() {
838 return op_id.to_string();
839 }
840 let path_part: String = path
841 .chars()
842 .map(|c| if c.is_alphanumeric() { c } else { '_' })
843 .collect();
844 format!("{method}_{path_part}")
845}
846
847fn is_already_optional(expr: &IrTypeExpr) -> bool {
850 matches!(expr, IrTypeExpr::Nullable(_))
851}