openapi_trait_shared/codegen/
operations.rs1use heck::{ToPascalCase, ToSnakeCase};
2use openapiv3::{
3 MediaType, OpenAPI, Operation, Parameter, PathItem, ReferenceOr, RequestBody, Response,
4 Responses, Schema, StatusCode,
5};
6use proc_macro2::TokenStream;
7use quote::{format_ident, quote};
8
9use super::schemas::doc_attr;
10use super::types::{is_string_enum, schema_to_rust_type, string_enum_values};
11
12#[derive(Debug)]
14pub struct OperationInfo {
15 pub operation_id: String,
16 pub method: String,
17 pub path: String,
18 pub summary: Option<String>,
19 pub description: Option<String>,
20 pub path_params: Vec<ParamInfo>,
21 pub query_params: Vec<ParamInfo>,
22 pub header_params: Vec<ParamInfo>,
23 pub body: Option<BodyInfo>,
24 pub responses: Vec<ResponseInfo>,
25}
26
27#[derive(Debug)]
28pub struct ParamInfo {
29 pub name: String,
30 pub description: Option<String>,
31 pub required: bool,
32 pub rust_type: TokenStream,
34 pub is_enum: bool,
36 pub enum_ident: Option<syn::Ident>,
38 pub enum_values: Vec<String>,
40}
41
42#[derive(Debug)]
43pub struct BodyInfo {
44 pub description: Option<String>,
45 pub required: bool,
46 pub rust_type: TokenStream,
47}
48
49#[derive(Debug)]
50pub struct ResponseInfo {
51 pub status: ResponseStatus,
52 pub description: String,
53 pub rust_type: Option<TokenStream>,
54}
55
56#[derive(Debug)]
57pub enum ResponseStatus {
58 Code(u16),
59 Default,
60}
61
62#[must_use]
64pub fn collect_operations(openapi: &OpenAPI) -> Vec<OperationInfo> {
65 let mut ops = Vec::new();
66 for (path, ref_or_item) in &openapi.paths.paths {
67 let item = match ref_or_item {
68 ReferenceOr::Item(i) => i,
69 ReferenceOr::Reference { .. } => continue,
70 };
71 for (method, operation) in path_item_operations(item) {
72 if let Some(info) = build_operation_info(path, &method, operation, item, openapi) {
73 ops.push(info);
74 }
75 }
76 }
77 ops
78}
79
80fn path_item_operations(item: &PathItem) -> Vec<(String, &Operation)> {
82 let mut out = Vec::new();
83 if let Some(op) = &item.get {
84 out.push(("get".into(), op));
85 }
86 if let Some(op) = &item.post {
87 out.push(("post".into(), op));
88 }
89 if let Some(op) = &item.put {
90 out.push(("put".into(), op));
91 }
92 if let Some(op) = &item.delete {
93 out.push(("delete".into(), op));
94 }
95 if let Some(op) = &item.patch {
96 out.push(("patch".into(), op));
97 }
98 if let Some(op) = &item.head {
99 out.push(("head".into(), op));
100 }
101 if let Some(op) = &item.options {
102 out.push(("options".into(), op));
103 }
104 if let Some(op) = &item.trace {
105 out.push(("trace".into(), op));
106 }
107 out
108}
109
110fn build_operation_info(
112 path: &str,
113 method: &str,
114 operation: &Operation,
115 path_item: &PathItem,
116 openapi: &OpenAPI,
117) -> Option<OperationInfo> {
118 let operation_id = operation.operation_id.clone()?;
119
120 let mut all_params: Vec<&ReferenceOr<Parameter>> = Vec::new();
122 all_params.extend(path_item.parameters.iter());
123 all_params.extend(operation.parameters.iter());
124
125 let mut path_params = Vec::new();
126 let mut query_params = Vec::new();
127 let mut header_params = Vec::new();
128
129 for ref_or_param in &all_params {
130 let param = match ref_or_param {
131 ReferenceOr::Item(p) => p,
132 ReferenceOr::Reference { reference } => {
133 if let Some(resolved) = resolve_param_ref(reference, openapi) {
135 resolved
136 } else {
137 continue;
138 }
139 }
140 };
141
142 let data = param.parameter_data_ref();
143 let param_schema = param_schema(param, openapi);
144
145 let (is_enum, enum_ident, enum_values) = param_schema.as_ref().map_or_else(
146 || (false, None, vec![]),
147 |schema| {
148 if is_string_enum(schema) {
149 let ident = format_ident!(
150 "{}{}Query",
151 operation_id.to_pascal_case(),
152 data.name.to_pascal_case()
153 );
154 let vals = string_enum_values(schema);
155 (true, Some(ident), vals)
156 } else {
157 (false, None, vec![])
158 }
159 },
160 );
161
162 let rust_type = if is_enum {
163 let ei = enum_ident.as_ref().unwrap();
164 quote! { #ei }
165 } else if let Some(schema) = ¶m_schema {
166 let ref_or = ReferenceOr::Item(schema.clone());
167 schema_to_rust_type(&ref_or, true)
168 } else {
169 quote! { ::std::string::String }
170 };
171
172 let info = ParamInfo {
173 name: data.name.clone(),
174 description: data.description.clone(),
175 required: data.required,
176 rust_type,
177 is_enum,
178 enum_ident,
179 enum_values,
180 };
181
182 match param {
183 Parameter::Path { .. } => path_params.push(info),
184 Parameter::Query { .. } => query_params.push(info),
185 Parameter::Header { .. } => header_params.push(info),
186 Parameter::Cookie { .. } => {}
187 }
188 }
189
190 let body = operation
191 .request_body
192 .as_ref()
193 .and_then(|rb| build_body_info(rb, openapi));
194
195 let responses = build_responses(&operation.responses, openapi);
196
197 Some(OperationInfo {
198 operation_id,
199 method: method.to_owned(),
200 path: path.to_owned(),
201 summary: operation.summary.clone(),
202 description: operation.description.clone(),
203 path_params,
204 query_params,
205 header_params,
206 body,
207 responses,
208 })
209}
210
211fn resolve_param_ref<'a>(reference: &str, openapi: &'a OpenAPI) -> Option<&'a Parameter> {
213 let name = reference.strip_prefix("#/components/parameters/")?;
214 openapi.components.as_ref()?.parameters.get(name)?.as_item()
215}
216
217fn param_schema(param: &Parameter, openapi: &OpenAPI) -> Option<Schema> {
219 use openapiv3::ParameterSchemaOrContent;
220 let data = param.parameter_data_ref();
221 match &data.format {
222 ParameterSchemaOrContent::Schema(ref_or) => match ref_or {
223 ReferenceOr::Item(s) => Some(s.clone()),
224 ReferenceOr::Reference { reference } => {
225 let name = reference.strip_prefix("#/components/schemas/")?;
226 openapi
227 .components
228 .as_ref()?
229 .schemas
230 .get(name)?
231 .as_item()
232 .cloned()
233 }
234 },
235 ParameterSchemaOrContent::Content(_) => None,
236 }
237}
238
239fn build_body_info(ref_or_rb: &ReferenceOr<RequestBody>, openapi: &OpenAPI) -> Option<BodyInfo> {
241 let rb = match ref_or_rb {
242 ReferenceOr::Item(r) => r,
243 ReferenceOr::Reference { reference } => {
244 let name = reference.strip_prefix("#/components/requestBodies/")?;
245 openapi
246 .components
247 .as_ref()?
248 .request_bodies
249 .get(name)?
250 .as_item()?
251 }
252 };
253
254 let rust_type = json_media_type_to_rust(&rb.content, openapi)?;
255
256 Some(BodyInfo {
257 description: rb.description.clone(),
258 required: rb.required,
259 rust_type,
260 })
261}
262
263fn json_media_type_to_rust(
265 content: &indexmap::IndexMap<String, MediaType>,
266 _openapi: &OpenAPI,
267) -> Option<TokenStream> {
268 let media = content
269 .get("application/json")
270 .or_else(|| content.values().next())?;
271 let ref_or_schema = media.schema.as_ref()?;
272 Some(schema_to_rust_type(ref_or_schema, true))
273}
274
275fn build_responses(responses: &Responses, openapi: &OpenAPI) -> Vec<ResponseInfo> {
277 let mut out = Vec::new();
278
279 for (status_code, ref_or_resp) in &responses.responses {
280 let resp = match ref_or_resp {
281 ReferenceOr::Item(r) => r,
282 ReferenceOr::Reference { reference } => {
283 if let Some(r) = resolve_response_ref(reference, openapi) {
284 r
285 } else {
286 continue;
287 }
288 }
289 };
290
291 let rust_type = json_media_type_to_rust(&resp.content, openapi);
292
293 let status = match status_code {
294 StatusCode::Code(n) => ResponseStatus::Code(*n),
295 StatusCode::Range(_) => continue, };
297
298 out.push(ResponseInfo {
299 status,
300 description: resp.description.clone(),
301 rust_type,
302 });
303 }
304
305 if let Some(ref_or_default) = &responses.default {
307 let resp = match ref_or_default {
308 ReferenceOr::Item(r) => r,
309 ReferenceOr::Reference { reference } => {
310 if let Some(r) = resolve_response_ref(reference, openapi) {
311 r
312 } else {
313 return out;
314 }
315 }
316 };
317 out.push(ResponseInfo {
318 status: ResponseStatus::Default,
319 description: resp.description.clone(),
320 rust_type: None, });
322 }
323
324 out
325}
326
327fn resolve_response_ref<'a>(reference: &str, openapi: &'a OpenAPI) -> Option<&'a Response> {
329 let name = reference.strip_prefix("#/components/responses/")?;
330 openapi.components.as_ref()?.responses.get(name)?.as_item()
331}
332
333pub fn generate_operation_types(ops: &[OperationInfo]) -> TokenStream {
335 let items: Vec<TokenStream> = ops.iter().map(generate_single_operation_types).collect();
336 quote! { #(#items)* }
337}
338
339fn generate_single_operation_types(op: &OperationInfo) -> TokenStream {
341 let query_enums = generate_query_enums(op);
342 let request_struct = generate_request_struct(op);
343 let response_enum = generate_response_enum(op);
344 quote! {
345 #query_enums
346 #request_struct
347 #response_enum
348 }
349}
350
351fn generate_query_enums(op: &OperationInfo) -> TokenStream {
353 let enums: Vec<TokenStream> = op
354 .query_params
355 .iter()
356 .filter(|p| p.is_enum)
357 .map(|p| {
358 let ident = p.enum_ident.as_ref().unwrap();
359 let doc = doc_attr(&p.description);
360 let variants: Vec<TokenStream> = p
361 .enum_values
362 .iter()
363 .map(|v| {
364 let variant_ident = format_ident!("{}", v.to_pascal_case());
365 if variant_ident == v.as_str() {
366 quote! { #variant_ident }
367 } else {
368 quote! {
369 #[serde(rename = #v)]
370 #variant_ident
371 }
372 }
373 })
374 .collect();
375
376 quote! {
377 #doc
378 #[derive(
379 ::core::fmt::Debug,
380 ::core::clone::Clone,
381 ::serde::Serialize,
382 ::serde::Deserialize,
383 )]
384
385 pub enum #ident {
386 #(#variants,)*
387 }
388 }
389 })
390 .collect();
391
392 quote! { #(#enums)* }
393}
394
395fn generate_request_struct(op: &OperationInfo) -> TokenStream {
397 let ident = format_ident!("{}Request", op.operation_id.to_pascal_case());
398 let doc = combined_doc(op.summary.as_ref(), op.description.as_ref());
399
400 let mut fields: Vec<TokenStream> = Vec::new();
401
402 for p in &op.path_params {
403 let field_ident = format_ident!("{}", p.name.to_snake_case());
404 let ftype = &p.rust_type;
405 let fdoc = doc_attr(&p.description);
406 fields.push(quote! {
407 #fdoc
408 pub #field_ident: #ftype,
409 });
410 }
411
412 for p in &op.query_params {
413 let field_ident = format_ident!("{}", p.name.to_snake_case());
414 let inner = &p.rust_type;
415 let ftype = if p.required {
416 quote! { #inner }
417 } else {
418 quote! { ::core::option::Option<#inner> }
419 };
420 let fdoc = doc_attr(&p.description);
421 fields.push(quote! {
422 #fdoc
423 pub #field_ident: #ftype,
424 });
425 }
426
427 for p in &op.header_params {
428 let field_ident = format_ident!("{}", p.name.to_snake_case());
429 let fdoc = doc_attr(&p.description);
430 fields.push(quote! {
432 #fdoc
433 pub #field_ident: ::core::option::Option<::std::string::String>,
434 });
435 }
436
437 if let Some(body) = &op.body {
438 let inner = &body.rust_type;
439 let ftype = if body.required {
440 quote! { #inner }
441 } else {
442 quote! { ::core::option::Option<#inner> }
443 };
444 let bdoc = doc_attr(&body.description);
445 fields.push(quote! {
446 #bdoc
447 pub body: #ftype,
448 });
449 }
450
451 quote! {
452 #doc
453 #[derive(::core::fmt::Debug, ::core::clone::Clone)]
454 pub struct #ident {
455 #(#fields)*
456 }
457 }
458}
459
460fn generate_response_enum(op: &OperationInfo) -> TokenStream {
462 let ident = format_ident!("{}Response", op.operation_id.to_pascal_case());
463 let doc = combined_doc(op.summary.as_ref(), op.description.as_ref());
464
465 let variants: Vec<TokenStream> = op
466 .responses
467 .iter()
468 .map(|r| {
469 let vdoc = doc_attr(&Some(r.description.clone()));
470 match &r.status {
471 ResponseStatus::Code(n) => {
472 let variant_ident = format_ident!("Status{}", n);
473 r.rust_type.as_ref().map_or_else(
474 || {
475 quote! {
476 #vdoc
477 #variant_ident
478 }
479 },
480 |ty| {
481 quote! {
482 #vdoc
483 #variant_ident(#ty)
484 }
485 },
486 )
487 }
488 ResponseStatus::Default => {
489 quote! {
490 #vdoc
491 Default(::std::string::String)
492 }
493 }
494 }
495 })
496 .collect();
497
498 quote! {
499 #doc
500 #[derive(::core::fmt::Debug, ::core::clone::Clone)]
501 pub enum #ident {
502 #(#variants,)*
503 }
504 }
505}
506
507fn combined_doc(summary: Option<&String>, description: Option<&String>) -> TokenStream {
509 match (summary, description) {
510 (Some(s), Some(d)) if s != d => quote! { #[doc = #s] #[doc = ""] #[doc = #d] },
511 (Some(s), _) => quote! { #[doc = #s] },
512 (None, Some(d)) => quote! { #[doc = #d] },
513 (None, None) => quote! {},
514 }
515}