1use std::collections::{BTreeMap, HashSet};
8
9use crate::codegen::traits::file_writer::FileInfo;
10use crate::ir::types::{
11 IrOperation, IrParameter, IrPrimitive, IrRequestBody, IrResponse, IrSpec, IrTypeExpr,
12 ParameterLocation,
13};
14use heck::{ToPascalCase, ToSnakeCase};
15use sigil_stitch::code_block::CodeBlock;
16use sigil_stitch::lang::python::Python;
17use sigil_stitch::prelude::*;
18
19use super::emit_models::{api_type_name, future_annotations_header, is_object_schema};
20
21pub fn generate_api_files(ir: &IrSpec, header: &str) -> Result<Vec<FileInfo>, String> {
23 let by_tag = group_by_tag(&ir.operations);
24 let mut files = Vec::with_capacity(by_tag.len());
25 for (tag, ops) in &by_tag {
26 let stem = tag.to_snake_case();
27 let filename = format!("{stem}_api.py");
28 let body = emit_api_file(tag, ops, ir, header);
29 files.push(FileInfo::api(filename, body));
30 }
31 Ok(files)
32}
33
34fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
35 let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
36 for op in operations {
37 let tags: Vec<String> = if op.tags.is_empty() {
38 vec!["default".to_string()]
39 } else {
40 op.tags.clone()
41 };
42 for tag in tags {
43 out.entry(tag).or_default().push(op);
44 }
45 }
46 out
47}
48
49fn emit_api_file(tag: &str, ops: &[&IrOperation], ir: &IrSpec, header: &str) -> String {
50 let class_name = format!("{}Api", tag.to_pascal_case());
51 let plans: Vec<OpPlan> = ops.iter().map(|op| plan_operation(op)).collect();
52
53 let client_type = TypeName::importable("..runtime.client", "Client");
54 let error_type = TypeName::importable("..runtime.errors", "ApiError");
55
56 let init_body = CodeBlock::of("self._client = client", ()).expect("static body");
58 let init = FunSpec::builder("__init__")
59 .add_param(ParameterSpec::of("self", TypeName::primitive("")))
60 .add_param(ParameterSpec::of("client", client_type))
61 .returns(TypeName::primitive("None"))
62 .body(init_body)
63 .build()
64 .expect("__init__ FunSpec builds");
65
66 let mut cls = TypeSpec::builder(&class_name, TypeKind::Class).add_method(init);
67
68 for plan in &plans {
69 cls = cls.add_method(build_api_method(plan, ir, &error_type));
70 }
71
72 let file = FileSpec::builder_with(&format!("{}_api.py", tag.to_snake_case()), Python::new())
73 .header(future_annotations_header())
74 .add_type(cls.build().expect("API TypeSpec builds"))
75 .build()
76 .expect("API FileSpec builds");
77
78 let body = file.render(120).unwrap_or_default();
79 let mut content = String::with_capacity(header.len() + body.len());
80 content.push_str(header);
81 content.push_str(&body);
82 content
83}
84
85fn build_api_method(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> FunSpec {
86 let mut fun = FunSpec::builder(&plan.method_name);
87
88 fun = fun.add_param(ParameterSpec::of("self", TypeName::primitive("")));
90
91 for p in &plan.path_params {
93 fun = fun.add_param(ParameterSpec::of(
94 &p.var_name,
95 api_type_name(&p.param.type_expr),
96 ));
97 }
98
99 let has_keyword_params =
101 !plan.query_params.is_empty() || !plan.header_params.is_empty() || plan.body.is_some();
102 if has_keyword_params {
103 fun = fun.add_param(ParameterSpec::of("*", TypeName::primitive("")));
104 }
105
106 for p in plan.query_params.iter().chain(&plan.header_params) {
108 if p.param.required {
109 fun = fun.add_param(ParameterSpec::of(
110 &p.var_name,
111 api_type_name(&p.param.type_expr),
112 ));
113 }
114 }
115
116 if let Some(b) = &plan.body {
118 let ty = api_type_name(&b.type_expr);
119 if b.required {
120 fun = fun.add_param(ParameterSpec::of(&b.var_name, ty));
121 } else {
122 fun = fun.add_param(
123 ParameterSpec::builder(&b.var_name, TypeName::optional(ty))
124 .default_value(CodeBlock::of("None", ()).expect("None"))
125 .build()
126 .expect("optional body param"),
127 );
128 }
129 }
130
131 for p in plan.query_params.iter().chain(&plan.header_params) {
133 if !p.param.required {
134 fun = fun.add_param(
135 ParameterSpec::builder(
136 &p.var_name,
137 TypeName::optional(api_type_name(&p.param.type_expr)),
138 )
139 .default_value(CodeBlock::of("None", ()).expect("None"))
140 .build()
141 .expect("optional param"),
142 );
143 }
144 }
145
146 let return_type = if plan.typed_responses.is_empty() {
148 TypeName::primitive("None")
149 } else {
150 api_type_name(&plan.typed_responses[0].type_expr)
151 };
152 fun = fun.returns(return_type);
153
154 if let Some(summary) = &plan.op.summary {
156 fun = fun.doc(&format!("{summary}."));
157 }
158
159 fun = fun.body(build_method_body(plan, ir, error_type));
161
162 fun.build().expect("API method FunSpec builds")
163}
164
165fn build_method_body(plan: &OpPlan<'_>, ir: &IrSpec, error_type: &TypeName) -> CodeBlock {
166 let mut cb = CodeBlock::builder();
167
168 if plan.path_params.is_empty() {
170 cb.add_statement(&format!("path = \"{}\"", plan.op.path), ());
171 } else {
172 let mut path_template = plan.op.path.clone();
173 for p in &plan.path_params {
174 let placeholder = format!("{{{}}}", p.param.name);
175 let replacement = format!("{{{}}}", p.var_name);
176 path_template = path_template.replace(&placeholder, &replacement);
177 }
178 cb.add_statement("path = %V", VerbatimStrArg(path_template));
179 }
180
181 let has_query = !plan.query_params.is_empty();
183 if has_query {
184 cb.add_statement("params: dict[str, str] = {}", ());
185 for p in &plan.query_params {
186 let stringify = render_stringify(&p.var_name, &p.param.type_expr);
187 if p.param.required {
188 cb.add_statement(&format!("params[\"{}\"] = {stringify}", p.param.name), ());
189 } else {
190 cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
191 cb.add_statement(&format!("params[\"{}\"] = {stringify}%<", p.param.name), ());
192 }
193 }
194 }
195
196 let has_headers = !plan.header_params.is_empty();
198 if has_headers {
199 cb.add_statement("headers: dict[str, str] = {}", ());
200 for p in &plan.header_params {
201 let stringify = render_stringify(&p.var_name, &p.param.type_expr);
202 if p.param.required {
203 cb.add_statement(&format!("headers[\"{}\"] = {stringify}", p.param.name), ());
204 } else {
205 cb.add_statement(&format!("if {} is not None:%>", p.var_name), ());
206 cb.add_statement(
207 &format!("headers[\"{}\"] = {stringify}%<", p.param.name),
208 (),
209 );
210 }
211 }
212 }
213
214 let body_expr = if let Some(b) = &plan.body {
216 if is_object_type(&b.type_expr, ir) {
217 if b.required {
218 format!("{}.to_dict()", b.var_name)
219 } else {
220 format!(
221 "{}.to_dict() if {} is not None else None",
222 b.var_name, b.var_name
223 )
224 }
225 } else if is_array_of_objects(&b.type_expr, ir) {
226 if b.required {
227 format!("[item.to_dict() for item in {}]", b.var_name)
228 } else {
229 format!(
230 "[item.to_dict() for item in {}] if {} is not None else None",
231 b.var_name, b.var_name
232 )
233 }
234 } else {
235 b.var_name.clone()
236 }
237 } else {
238 String::new()
239 };
240
241 let mut request_args = vec![
243 format!("\"{}\"", plan.op.method.to_uppercase()),
244 "path".to_string(),
245 ];
246 if has_query {
247 request_args.push("params=params".to_string());
248 }
249 if plan.body.is_some() {
250 request_args.push(format!("json={body_expr}"));
251 }
252 if has_headers {
253 request_args.push("headers=headers".to_string());
254 }
255
256 cb.add_statement(
257 &format!(
258 "response = self._client.request({})",
259 request_args.join(", "),
260 ),
261 (),
262 );
263
264 cb.add_statement("if response.status_code >= 400:%>", ());
266 cb.add_statement(
267 "raise %T(response.status_code, response.reason, response.content)%<",
268 (error_type.clone(),),
269 );
270
271 if !plan.typed_responses.is_empty() {
273 let tr = &plan.typed_responses[0];
274 let parse_expr = render_response_parse(&tr.type_expr, ir);
275 cb.add_statement(&format!("return {parse_expr}"), ());
276 } else {
277 cb.add_statement("return None", ());
278 }
279
280 cb.build().expect("API method body builds")
281}
282
283fn render_stringify(var: &str, type_expr: &IrTypeExpr) -> String {
284 match type_expr {
285 IrTypeExpr::Primitive(
286 IrPrimitive::String
287 | IrPrimitive::Date
288 | IrPrimitive::DateTime
289 | IrPrimitive::Uuid
290 | IrPrimitive::StringWithFormat(_),
291 )
292 | IrTypeExpr::StringLiteral(_)
293 | IrTypeExpr::StringEnum(_)
294 | IrTypeExpr::Named(_) => format!("str({var})"),
295 IrTypeExpr::Primitive(IrPrimitive::Boolean) => format!("str({var}).lower()"),
296 IrTypeExpr::Primitive(
297 IrPrimitive::Integer
298 | IrPrimitive::IntegerWithFormat(_)
299 | IrPrimitive::Number
300 | IrPrimitive::NumberWithFormat(_),
301 ) => format!("str({var})"),
302 IrTypeExpr::Nullable(inner) => render_stringify(var, inner),
303 IrTypeExpr::Array(_) => format!("\",\".join(str(v) for v in {var})"),
304 _ => format!("str({var})"),
305 }
306}
307
308fn render_response_parse(type_expr: &IrTypeExpr, ir: &IrSpec) -> String {
309 match type_expr {
310 IrTypeExpr::Named(name) => {
311 let py_name = name.to_pascal_case();
312 if is_object_schema(name, ir) {
313 format!("{py_name}.from_dict(response.json())")
314 } else {
315 "response.json() # type: ignore[return-value]".to_string()
316 }
317 }
318 IrTypeExpr::Array(inner) => {
319 if let IrTypeExpr::Named(name) = inner.as_ref()
320 && is_object_schema(name, ir)
321 {
322 let py_name = name.to_pascal_case();
323 return format!("[{py_name}.from_dict(item) for item in response.json()]");
324 }
325 "response.json() # type: ignore[return-value]".to_string()
326 }
327 IrTypeExpr::Primitive(IrPrimitive::String | IrPrimitive::StringWithFormat(_)) => {
328 "response.text".to_string()
329 }
330 _ => "response.json() # type: ignore[return-value]".to_string(),
331 }
332}
333
334fn is_object_type(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
335 if let IrTypeExpr::Named(name) = type_expr {
336 return is_object_schema(name, ir);
337 }
338 false
339}
340
341fn is_array_of_objects(type_expr: &IrTypeExpr, ir: &IrSpec) -> bool {
342 if let IrTypeExpr::Array(inner) = type_expr
343 && let IrTypeExpr::Named(name) = inner.as_ref()
344 {
345 return is_object_schema(name, ir);
346 }
347 false
348}
349
350struct OpPlan<'a> {
355 op: &'a IrOperation,
356 method_name: String,
357 path_params: Vec<ParamBinding<'a>>,
358 query_params: Vec<ParamBinding<'a>>,
359 header_params: Vec<ParamBinding<'a>>,
360 body: Option<BodyBinding>,
361 typed_responses: Vec<TypedResponse>,
362}
363
364struct ParamBinding<'a> {
365 param: &'a IrParameter,
366 var_name: String,
367}
368
369struct BodyBinding {
370 var_name: String,
371 type_expr: IrTypeExpr,
372 required: bool,
373}
374
375struct TypedResponse {
376 type_expr: IrTypeExpr,
377}
378
379fn plan_operation<'a>(op: &'a IrOperation) -> OpPlan<'a> {
380 let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
381 let method_name = op_id.to_snake_case();
382
383 let mut used_names: HashSet<String> = HashSet::new();
384 used_names.insert("self".to_string());
385
386 let mut path_params = Vec::new();
387 let mut query_params = Vec::new();
388 let mut header_params = Vec::new();
389
390 for p in &op.parameters {
391 let var_name = unique_name(&python_param_name(&p.name), &mut used_names);
392 let binding = ParamBinding { param: p, var_name };
393 match p.location {
394 ParameterLocation::Path => path_params.push(binding),
395 ParameterLocation::Query => query_params.push(binding),
396 ParameterLocation::Header => header_params.push(binding),
397 ParameterLocation::Cookie => header_params.push(binding),
398 }
399 }
400
401 let body = op
402 .request_body
403 .as_ref()
404 .and_then(|b| plan_body(b, &mut used_names));
405
406 let typed_responses = op.responses.iter().filter_map(plan_response).collect();
407
408 OpPlan {
409 op,
410 method_name,
411 path_params,
412 query_params,
413 header_params,
414 body,
415 typed_responses,
416 }
417}
418
419fn plan_body(b: &IrRequestBody, used_names: &mut HashSet<String>) -> Option<BodyBinding> {
420 let t = pick_body_type(b)?;
421 let var_name = unique_name("body", used_names);
422 Some(BodyBinding {
423 var_name,
424 type_expr: t,
425 required: b.required,
426 })
427}
428
429fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
430 let t = pick_response_type(r)?;
431 Some(TypedResponse { type_expr: t })
432}
433
434fn pick_body_type(body: &IrRequestBody) -> Option<IrTypeExpr> {
435 body.content
436 .get("application/json")
437 .cloned()
438 .or_else(|| body.content.values().next().cloned())
439}
440
441fn pick_response_type(r: &IrResponse) -> Option<IrTypeExpr> {
442 r.content
443 .get("application/json")
444 .cloned()
445 .or_else(|| r.content.values().next().cloned())
446}
447
448fn python_param_name(name: &str) -> String {
449 let snake = name.to_snake_case();
450 if snake.is_empty() {
451 return "param".to_string();
452 }
453 match snake.as_str() {
454 "and" | "as" | "assert" | "async" | "await" | "break" | "class" | "continue" | "def"
455 | "del" | "elif" | "else" | "except" | "finally" | "for" | "from" | "global" | "if"
456 | "import" | "in" | "is" | "lambda" | "nonlocal" | "not" | "or" | "pass" | "raise"
457 | "return" | "try" | "while" | "with" | "yield" | "type" | "self" => {
458 format!("{snake}_")
459 }
460 _ => snake,
461 }
462}
463
464fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
465 if used.insert(desired.to_string()) {
466 return desired.to_string();
467 }
468 for i in 2..=u32::MAX {
469 let candidate = format!("{desired}{i}");
470 if used.insert(candidate.clone()) {
471 return candidate;
472 }
473 }
474 unreachable!("name collision space exhausted")
475}
476
477fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
478 if !op_id.is_empty() {
479 return op_id.to_string();
480 }
481 let path_part: String = path
482 .chars()
483 .map(|c| if c.is_alphanumeric() { c } else { '_' })
484 .collect();
485 format!("{method}_{path_part}")
486}