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