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
305 | BodyEncoding::TextPlain
306 | BodyEncoding::OctetStream => request_args.push(format!("data={body_expr}")),
307 BodyEncoding::Xml | BodyEncoding::Other => {
308 if body.required {
309 cb.add_statement(
310 &format!(
311 "raise ValueError(\"unsupported request body media type: {}\")",
312 body.media_type
313 ),
314 (),
315 );
316 } else {
317 cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
318 cb.add_statement(
319 &format!(
320 "raise ValueError(\"unsupported request body media type: {}\")%<",
321 body.media_type
322 ),
323 (),
324 );
325 }
326 }
327 BodyEncoding::Multipart => unreachable!("multipart handled separately"),
328 }
329 }
330 }
331 if has_headers {
332 request_args.push("headers=headers".to_string());
333 }
334
335 cb.add_code(
336 sigil_quote!(Python {
337 response = self._client.request($for(arg in &request_args; separator = ", ") { $L(arg.as_str()) })
338 })
339 .expect("request call"),
340 );
341
342 cb.add_statement("if response.status_code >= 400:%>", ());
344 cb.add_statement(
345 "raise %T(response.status_code, response.reason, response.content)%<",
346 (error_type.clone(),),
347 );
348
349 if !plan.typed_responses.is_empty() {
351 let tr = &plan.typed_responses[0];
352 let parse_expr = render_response_parse(tr, ir);
353 cb.add_statement(&format!("return {parse_expr}"), ());
354 } else {
355 cb.add_statement("return None", ());
356 }
357
358 cb.build().expect("API method body builds")
359}
360
361fn emit_multipart_data(
362 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
363 body: &BodyBinding,
364 parts: &[MultipartPart],
365 ir: &IrSpec,
366) {
367 cb.add_statement("files: dict[str, object] = {}", ());
368 if !body.required {
369 cb.add_statement(&format!("if {} is not None:%>", body.var_name), ());
370 }
371 for part in parts {
372 let access = format!("{}.{}", body.var_name, part.field_name);
373 if part.required {
374 emit_required_multipart_part(cb, part, &access, ir);
375 } else {
376 cb.add_statement(&format!("if {access} is not None:%>"), ());
377 emit_required_multipart_part(cb, part, &access, ir);
378 cb.add_statement("%<", ());
379 }
380 }
381 if !body.required {
382 cb.add_statement("%<", ());
383 }
384}
385
386fn emit_required_multipart_part(
387 cb: &mut sigil_stitch::code_block::CodeBlockBuilder,
388 part: &MultipartPart,
389 access: &str,
390 ir: &IrSpec,
391) {
392 cb.add_code(multipart_part_assignment(part, access, ir));
393}
394
395fn multipart_part_assignment(part: &MultipartPart, access: &str, ir: &IrSpec) -> CodeBlock {
396 let binary_stmt = format!(
397 "files[\"{}\"] = ({}.filename_or_default(\"{}\"), {}.data, \"{}\")",
398 part.wire_name, access, part.wire_name, access, part.content_type
399 );
400 let json_value = render_multipart_json_value(access, &part.type_expr, ir);
401 let json_stmt = format!(
402 "files[\"{}\"] = (None, json.dumps({json_value}), \"{}\")",
403 part.wire_name, part.content_type
404 );
405 let unsupported_stmt = "raise ValueError(\"unsupported multipart part content type\")";
406 let scalar_stmt = format!(
407 "files[\"{}\"] = (None, str({access}), \"{}\")",
408 part.wire_name, part.content_type
409 );
410
411 sigil_quote!(Python {
412 $if(part.is_binary) {
413 $L(binary_stmt.as_str())
414 } $else_if(part.value_encoding == MultipartValueEncoding::Json) {
415 $L(json_stmt.as_str())
416 } $else_if(part.value_encoding == MultipartValueEncoding::Unsupported) {
417 $L(unsupported_stmt)
418 } $else {
419 $L(scalar_stmt.as_str())
420 }
421 })
422 .expect("multipart part assignment builds")
423}
424
425fn render_multipart_json_value(access: &str, expr: &IrTypeExpr, ir: &IrSpec) -> String {
426 match expr {
427 IrTypeExpr::Named(name) if is_object_schema(name, ir) => format!("{access}.to_dict()"),
428 IrTypeExpr::Nullable(inner) => render_multipart_json_value(access, inner, ir),
429 IrTypeExpr::Array(inner) => {
430 if let IrTypeExpr::Named(name) = inner.as_ref()
431 && is_object_schema(name, ir)
432 {
433 format!("[item.to_dict() for item in {access}]")
434 } else {
435 access.to_string()
436 }
437 }
438 _ => access.to_string(),
439 }
440}
441
442fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
443 match type_expr {
444 IrTypeExpr::Primitive(
445 IrPrimitive::String
446 | IrPrimitive::Date
447 | IrPrimitive::DateTime
448 | IrPrimitive::Uuid
449 | IrPrimitive::StringWithFormat(_),
450 )
451 | IrTypeExpr::StringLiteral(_)
452 | IrTypeExpr::StringEnum(_)
453 | IrTypeExpr::Named(_) => format!("str({var})"),
454 IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
455 IrTypeExpr::Primitive(
456 IrPrimitive::Integer
457 | IrPrimitive::IntegerWithFormat(_)
458 | IrPrimitive::Number
459 | IrPrimitive::NumberWithFormat(_),
460 ) => format!("str({var})"),
461 IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
462 IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
463 _ => format!("str({var})"),
464 }
465}
466
467fn response_type_name(response: &TypedResponse) -> TypeName {
468 match response.decoding {
469 ResponseDecoding::Json => api_type_name(&response.type_expr),
470 ResponseDecoding::Text => TypeName::primitive("str"),
471 ResponseDecoding::Bytes => TypeName::primitive("bytes"),
472 }
473}
474
475fn render_response_parse(response: &TypedResponse, ir: &IrSpec) -> String {
476 match response.decoding {
477 ResponseDecoding::Json => render_json_response_parse(&response.type_expr, ir),
478 ResponseDecoding::Text => "response.text".to_string(),
479 ResponseDecoding::Bytes => "response.content".to_string(),
480 }
481}
482
483fn render_json_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
484 match type_expr {
485 IrTypeExpr::Named(name) => {
486 let py_name = name.to_pascal_case();
487 if is_object_schema(name, ir) {
488 format!("{py_name}.from_dict(response.json())")
489 } else {
490 "response.json() # type: ignore[return-value]".to_string()
491 }
492 }
493 IrTypeExpr::Array(inner) => {
494 if let IrTypeExpr::Named(name) = inner.as_ref()
495 && is_object_schema(name, ir)
496 {
497 let py_name = name.to_pascal_case();
498 return format!("[{py_name}.from_dict(item) for item in response.json()]");
499 }
500 "response.json() # type: ignore[return-value]".to_string()
501 }
502 IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
503 "response.text".to_string()
504 }
505 _ => "response.json() # type: ignore[return-value]".to_string(),
506 }
507}
508
509fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
510 if let IrTypeExpr::Named(name) = type_expr {
511 return is_object_schema(name, ir);
512 }
513 false
514}
515
516fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
517 if let IrTypeExpr::Array(inner) = type_expr
518 && let IrTypeExpr::Named(name) = inner.as_ref()
519 {
520 return is_object_schema(name, ir);
521 }
522 false
523}
524
525struct OpPlan<'a> {
530 op: &'a IrOperation,
531 method_name: String,
532 path_params: Vec<ParamBinding<'a>>,
533 query_params: Vec<ParamBinding<'a>>,
534 header_params: Vec<ParamBinding<'a>>,
535 body: Option<BodyBinding>,
536 typed_responses: Vec<TypedResponse>,
537}
538
539struct ParamBinding<'a> {
540 param: &'a IrParameter,
541 var_name: String,
542}
543
544struct BodyBinding {
545 var_name: String,
546 type_expr: IrTypeExpr,
547 required: bool,
548 media_type: String,
549 encoding: BodyEncoding,
550 multipart_parts: Option<Vec<MultipartPart>>,
551}
552
553struct MultipartPart {
554 wire_name: String,
555 field_name: String,
556 type_expr: IrTypeExpr,
557 is_binary: bool,
558 required: bool,
559 content_type: String,
560 value_encoding: MultipartValueEncoding,
561}
562
563#[derive(Clone, Copy, PartialEq, Eq)]
564enum BodyEncoding {
565 Json,
566 Multipart,
567 FormUrlEncoded,
568 Xml,
569 TextPlain,
570 OctetStream,
571 Other,
572}
573
574#[derive(Clone, Copy, PartialEq, Eq)]
575enum ResponseDecoding {
576 Json,
577 Text,
578 Bytes,
579}
580
581struct TypedResponse {
582 type_expr: IrTypeExpr,
583 decoding: ResponseDecoding,
584}
585
586fn plan_operation<'a>(
587 op: &'a IrOperation,
588 ir: &IrSpec,
589 request_inputs: &RequestInputPlan,
590) -> OpPlan<'a> {
591 let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
592 let method_name = op_id.to_snake_case();
593
594 let mut used_names: HashSet<String> = HashSet::new();
595 used_names.insert("self".to_string());
596
597 let mut path_params = Vec::new();
598 let mut query_params = Vec::new();
599 let mut header_params = Vec::new();
600
601 for p in &op.parameters {
602 let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
603 let binding = ParamBinding { param: p, var_name };
604 match p.location {
605 ParameterLocation::Path => path_params.push(binding),
606 ParameterLocation::Query => query_params.push(binding),
607 ParameterLocation::Header => header_params.push(binding),
608 ParameterLocation::Cookie => header_params.push(binding),
609 }
610 }
611
612 let body = op
613 .request_body
614 .as_ref()
615 .and_then(|b| plan_body(op, b, ir, request_inputs, &mut used_names));
616
617 let typed_responses = op.responses.iter().filter_map(plan_response).collect();
618
619 OpPlan {
620 op,
621 method_name,
622 path_params,
623 query_params,
624 header_params,
625 body,
626 typed_responses,
627 }
628}
629
630fn plan_body(
631 op: &IrOperation,
632 b: &IrRequestBody,
633 ir: &IrSpec,
634 request_inputs: &RequestInputPlan,
635 used_names: &mut HashSet<String>,
636) -> Option<BodyBinding> {
637 let (media_type, t) = pick_body_content(b)?;
638 let encoding = body_encoding(&media_type);
639 let var_name = unique_name("body", used_names);
640 let multipart_parts = if media_type_base(&media_type) == "multipart/form-data" {
641 multipart_parts_for(b, &media_type, ir)
642 } else {
643 None
644 };
645 Some(BodyBinding {
646 var_name,
647 type_expr: if encoding == BodyEncoding::Multipart {
648 request_input_for_operation(request_inputs, op, &media_type)
649 .map(|input| IrTypeExpr::Named(input.name.clone()))
650 .unwrap_or(t)
651 } else {
652 t
653 },
654 required: b.required,
655 media_type,
656 encoding,
657 multipart_parts,
658 })
659}
660
661fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
662 let (media_type, t) = pick_response_content(r)?;
663 Some(TypedResponse {
664 type_expr: t,
665 decoding: response_decoding(&media_type),
666 })
667}
668
669fn body_encoding(media_type: &str) -> BodyEncoding {
670 let base = media_type_base(media_type);
671 if base == "multipart/form-data" {
672 BodyEncoding::Multipart
673 } else if is_json_media_type(media_type) {
674 BodyEncoding::Json
675 } else if base == "application/x-www-form-urlencoded" {
676 BodyEncoding::FormUrlEncoded
677 } else if is_xml_media_type(media_type) {
678 BodyEncoding::Xml
679 } else if base == "text/plain" {
680 BodyEncoding::TextPlain
681 } else if base == "application/octet-stream" {
682 BodyEncoding::OctetStream
683 } else {
684 BodyEncoding::Other
685 }
686}
687
688fn response_decoding(media_type: &str) -> ResponseDecoding {
689 let base = media_type_base(media_type);
690 if is_json_media_type(media_type) {
691 ResponseDecoding::Json
692 } else if base == "text/plain" || is_xml_media_type(media_type) {
693 ResponseDecoding::Text
694 } else {
695 ResponseDecoding::Bytes
696 }
697}
698
699fn pick_body_content(body: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
700 pick_media_type(&body.content, |media_type| {
701 media_type_base(media_type) == "application/json"
702 })
703 .or_else(|| pick_media_type(&body.content, is_json_media_type))
704 .or_else(|| {
705 pick_media_type(&body.content, |media_type| {
706 media_type_base(media_type) == "multipart/form-data"
707 })
708 })
709 .or_else(|| {
710 pick_media_type(&body.content, |media_type| {
711 media_type_base(media_type) == "application/x-www-form-urlencoded"
712 })
713 })
714 .or_else(|| pick_media_type(&body.content, is_xml_media_type))
715 .or_else(|| {
716 pick_media_type(&body.content, |media_type| {
717 media_type_base(media_type) == "text/plain"
718 })
719 })
720 .or_else(|| {
721 pick_media_type(&body.content, |media_type| {
722 media_type_base(media_type) == "application/octet-stream"
723 })
724 })
725 .or_else(|| pick_first_content(&body.content))
726}
727
728fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
729 pick_media_type(&r.content, |media_type| {
730 media_type_base(media_type) == "application/json"
731 })
732 .or_else(|| pick_media_type(&r.content, is_json_media_type))
733 .or_else(|| {
734 pick_media_type(&r.content, |media_type| {
735 media_type_base(media_type) == "application/octet-stream"
736 })
737 })
738 .or_else(|| {
739 pick_media_type(&r.content, |media_type| {
740 media_type_base(media_type) == "text/plain"
741 })
742 })
743 .or_else(|| pick_media_type(&r.content, is_xml_media_type))
744 .or_else(|| pick_first_content(&r.content))
745}
746
747fn pick_media_type(
748 content: &indexmap::IndexMap<String, IrTypeExpr>,
749 predicate: impl Fn(&str) -> bool,
750) -> Option<(String, IrTypeExpr)> {
751 content
752 .iter()
753 .find(|(media_type, _)| predicate(media_type))
754 .map(|(media_type, t)| (media_type.clone(), t.clone()))
755}
756
757fn pick_first_content(
758 content: &indexmap::IndexMap<String, IrTypeExpr>,
759) -> Option<(String, IrTypeExpr)> {
760 content
761 .iter()
762 .next()
763 .map(|(media_type, t)| (media_type.clone(), t.clone()))
764}
765
766fn media_type_base(media_type: &str) -> String {
767 media_type
768 .split(';')
769 .next()
770 .unwrap_or(media_type)
771 .trim()
772 .to_ascii_lowercase()
773}
774
775fn is_json_media_type(media_type: &str) -> bool {
776 let base = media_type_base(media_type);
777 base == "application/json" || base.ends_with("+json")
778}
779
780fn is_xml_media_type(media_type: &str) -> bool {
781 let base = media_type_base(media_type);
782 base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
783}
784
785fn multipart_parts_for(
786 body: &IrRequestBody,
787 media_type: &str,
788 ir: &IrSpec,
789) -> Option<Vec<MultipartPart>> {
790 multipart_parts_for_request_body(body, media_type, ir).map(|parts| {
791 parts
792 .into_iter()
793 .map(|part| MultipartPart {
794 field_name: python_field_name(&part.wire_name),
795 wire_name: part.wire_name,
796 type_expr: part.type_expr,
797 is_binary: part.is_binary,
798 required: part.required,
799 content_type: part.content_type,
800 value_encoding: part.value_encoding,
801 })
802 .collect()
803 })
804}
805
806fn python_param_name(name: &str) -> String {
807 let snake = name.to_snake_case();
808 if snake.is_empty() {
809 return "param".to_string();
810 }
811 match snake.as_str() {
812 "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
813 | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
814 | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
815 | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
816 format!("{snake}_")
817 }
818 _ => snake,
819 }
820}
821
822fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
823 if used.insert(desired.to_string()) {
824 return desired.to_string();
825 }
826 for i in 2..=u32::MAX {
827 let candidate = format!("{desired}{i}");
828 if used.insert(candidate.clone()) {
829 return candidate;
830 }
831 }
832 unreachable!("name collision space exhausted")
833}
834
835fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
836 if !op_id.is_empty() {
837 return op_id.to_string();
838 }
839 let path_part: String = path
840 .chars()
841 .map(|c| if c.is_alphanumeric() { c } else { '_' })
842 .collect();
843 format!("{method}_{path_part}")
844}
845
846fn is_already_optional(expr: &IrTypeExpr) -> bool {
849 matches!(expr, IrTypeExpr::Nullable(_))
850}