Skip to main content

openapi_nexus/generators/
request_inputs.rs

1//! Synthetic request-body input models for transport-specific request shapes.
2//!
3//! Canonical schema models stay schema-faithful. These models exist only for
4//! operation request bodies whose selected media type needs a friendlier caller
5//! shape than the schema itself, currently object-shaped multipart/form-data
6//! with binary upload parts.
7
8use std::collections::{HashMap, HashSet};
9
10use heck::ToPascalCase as _;
11
12use crate::generators::multipart::{
13    MultipartValueEncoding, media_type_base, multipart_parts_for_request_body,
14};
15use crate::ir::types::{IrOperation, IrRequestBody, IrSpec, IrTypeExpr};
16
17#[derive(Debug, Clone)]
18pub struct RequestInputModel {
19    pub name: String,
20    pub operation_id: String,
21    pub media_type: String,
22    pub body_required: bool,
23    pub fields: Vec<RequestInputField>,
24}
25
26#[derive(Debug, Clone)]
27pub struct RequestInputField {
28    pub wire_name: String,
29    pub type_expr: IrTypeExpr,
30    pub required: bool,
31    pub content_type: String,
32    pub value_encoding: MultipartValueEncoding,
33    pub kind: RequestInputFieldKind,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum RequestInputFieldKind {
38    SchemaValue,
39    UploadFile { default_filename: String },
40}
41
42impl RequestInputField {
43    pub fn is_upload(&self) -> bool {
44        matches!(self.kind, RequestInputFieldKind::UploadFile { .. })
45    }
46
47    pub fn default_filename(&self) -> &str {
48        match &self.kind {
49            RequestInputFieldKind::UploadFile { default_filename } => default_filename,
50            RequestInputFieldKind::SchemaValue => &self.wire_name,
51        }
52    }
53}
54
55#[derive(Debug, Clone)]
56pub struct RequestInputPlan {
57    models: Vec<RequestInputModel>,
58    by_operation_media: HashMap<(String, String), usize>,
59}
60
61impl RequestInputPlan {
62    pub fn empty() -> Self {
63        Self {
64            models: Vec::new(),
65            by_operation_media: HashMap::new(),
66        }
67    }
68
69    pub fn models(&self) -> &[RequestInputModel] {
70        &self.models
71    }
72
73    pub fn get(&self, operation_id: &str, media_type: &str) -> Option<&RequestInputModel> {
74        let key = (operation_id.to_string(), media_type.to_string());
75        self.by_operation_media
76            .get(&key)
77            .and_then(|idx| self.models.get(*idx))
78    }
79
80    pub fn has_uploads(&self) -> bool {
81        self.models
82            .iter()
83            .any(|model| model.fields.iter().any(RequestInputField::is_upload))
84    }
85}
86
87pub fn plan_multipart_request_inputs(ir: &IrSpec) -> RequestInputPlan {
88    let mut plan = RequestInputPlan::empty();
89    let mut used_names: HashSet<String> = ir
90        .schemas
91        .keys()
92        .map(|name| name.to_pascal_case())
93        .collect();
94
95    for op in &ir.operations {
96        let Some(body) = &op.request_body else {
97            continue;
98        };
99        let Some(media_type) = preferred_request_media_type(body) else {
100            continue;
101        };
102        if media_type_base(&media_type) != "multipart/form-data" {
103            continue;
104        }
105        let Some(parts) = multipart_parts_for_request_body(body, &media_type, ir) else {
106            continue;
107        };
108
109        let base_name = format!(
110            "{}MultipartRequestBody",
111            sanitize_operation_id(&op.operation_id, &op.method, &op.path).to_pascal_case()
112        );
113        let name = unique_type_name(&base_name, &mut used_names);
114        let fields = parts
115            .into_iter()
116            .map(|part| {
117                let kind = if part.is_binary {
118                    RequestInputFieldKind::UploadFile {
119                        default_filename: part.default_filename.clone(),
120                    }
121                } else {
122                    RequestInputFieldKind::SchemaValue
123                };
124                RequestInputField {
125                    wire_name: part.wire_name,
126                    type_expr: part.type_expr,
127                    required: part.required,
128                    content_type: part.content_type,
129                    value_encoding: part.value_encoding,
130                    kind,
131                }
132            })
133            .collect();
134        let index = plan.models.len();
135        plan.by_operation_media
136            .insert((op.operation_id.clone(), media_type.clone()), index);
137        plan.models.push(RequestInputModel {
138            name,
139            operation_id: op.operation_id.clone(),
140            media_type,
141            body_required: body.required,
142            fields,
143        });
144    }
145
146    plan
147}
148
149pub fn request_input_for_operation<'a>(
150    plan: &'a RequestInputPlan,
151    op: &IrOperation,
152    media_type: &str,
153) -> Option<&'a RequestInputModel> {
154    plan.get(&op.operation_id, media_type)
155}
156
157pub fn preferred_request_media_type(body: &IrRequestBody) -> Option<String> {
158    pick_media_type(&body.content, |media_type| {
159        media_type_base(media_type) == "application/json"
160    })
161    .or_else(|| pick_media_type(&body.content, is_json_media_type))
162    .or_else(|| {
163        pick_media_type(&body.content, |media_type| {
164            media_type_base(media_type) == "multipart/form-data"
165        })
166    })
167    .or_else(|| {
168        pick_media_type(&body.content, |media_type| {
169            media_type_base(media_type) == "application/x-www-form-urlencoded"
170        })
171    })
172    .or_else(|| pick_media_type(&body.content, is_xml_media_type))
173    .or_else(|| {
174        pick_media_type(&body.content, |media_type| {
175            media_type_base(media_type) == "text/plain"
176        })
177    })
178    .or_else(|| {
179        pick_media_type(&body.content, |media_type| {
180            media_type_base(media_type) == "application/octet-stream"
181        })
182    })
183    .or_else(|| body.content.keys().next().cloned())
184}
185
186fn unique_type_name(base: &str, used: &mut HashSet<String>) -> String {
187    if used.insert(base.to_string()) {
188        return base.to_string();
189    }
190    for i in 2..=u32::MAX {
191        let candidate = format!("{base}{i}");
192        if used.insert(candidate.clone()) {
193            return candidate;
194        }
195    }
196    unreachable!("request input model name collision space exhausted")
197}
198
199fn sanitize_operation_id(op_id: &str, method: &str, path: &str) -> String {
200    if !op_id.is_empty() {
201        return op_id.to_string();
202    }
203    let path_part: String = path
204        .chars()
205        .map(|c| if c.is_alphanumeric() { c } else { '_' })
206        .collect();
207    format!("{method}_{path_part}")
208}
209
210fn pick_media_type(
211    content: &indexmap::IndexMap<String, IrTypeExpr>,
212    predicate: impl Fn(&str) -> bool,
213) -> Option<String> {
214    content
215        .keys()
216        .find(|media_type| predicate(media_type))
217        .cloned()
218}
219
220fn is_json_media_type(media_type: &str) -> bool {
221    let base = media_type_base(media_type);
222    base == "application/json" || base.ends_with("+json")
223}
224
225fn is_xml_media_type(media_type: &str) -> bool {
226    let base = media_type_base(media_type);
227    base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
228}