openapi_nexus/generators/
request_inputs.rs1use 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}