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