1use std::collections::BTreeMap;
41
42use http::StatusCode as HttpStatus;
43use openapiv3::ObjectType;
44use openapiv3::Operation as OasOperation;
45use openapiv3::Parameter;
46use openapiv3::ParameterData;
47use openapiv3::ParameterSchemaOrContent;
48use openapiv3::QueryStyle;
49use openapiv3::ReferenceOr;
50use openapiv3::RequestBody;
51use openapiv3::Response as OasResponse;
52use openapiv3::Schema;
53use openapiv3::SchemaKind;
54use openapiv3::StatusCode;
55use openapiv3::Type;
56
57use crate::error::Error;
58use crate::error::Result;
59use crate::ir::Body;
60use crate::ir::BodyKind;
61use crate::ir::BodyVariant;
62use crate::ir::CookieParam;
63use crate::ir::Cookies;
64use crate::ir::Field;
65use crate::ir::HeaderParam;
66use crate::ir::Headers;
67use crate::ir::Multipart;
68use crate::ir::MultipartField;
69use crate::ir::NegotiatedBody;
70use crate::ir::Operation;
71use crate::ir::Param;
72use crate::ir::RequestPayload;
73use crate::ir::ResponseBody;
74use crate::ir::ResponseCase;
75use crate::ir::ResponseStatus;
76use crate::ir::RustType;
77use crate::ir::Service;
78use crate::ir::Struct;
79use crate::loader::Resolved;
80use crate::loader::Spec;
81use crate::loader::ref_component_name;
82use crate::loader::ref_file_part;
83use crate::lower::default::lower_default;
84use crate::lower::schema::integer_type;
85use crate::lower::schema::string_format_type;
86use crate::lower::security;
87use crate::naming::Case;
88use crate::naming::RustIdent;
89use crate::naming::X_RUST_NAME;
90use crate::naming::operations;
91use crate::naming::to_ident;
92
93const IGNORED_HEADER_NAMES: [&str; 3] = ["accept", "content-type", "authorization"];
97
98const REQUEST_BODY_PRIORITY: [BodyKind; 4] = [BodyKind::Json, BodyKind::Form, BodyKind::Multipart, BodyKind::Text];
102
103const RESPONSE_BODY_PRIORITY: [BodyKind; 3] = [BodyKind::Json, BodyKind::Form, BodyKind::Text];
108
109enum LoweredResponseBody {
113 Single(Body),
114 Negotiated(Vec<BodyVariant>),
115}
116
117const RESERVED_RESPONSE_FIELDS: [&str; 2] = ["status", "body"];
122
123fn is_valid_header_name(name: &str) -> bool {
128 if name.is_empty() {
129 return false;
130 }
131 for byte in name.as_bytes() {
132 let valid = matches!(
136 byte,
137 b'!' | b'#'..=b'\'' | b'*'..=b'+' | b'-' | b'.' | b'0'..=b'9' | b'A'..=b'Z' | b'^'..=b'z' | b'|' | b'~'
138 );
139 if !valid {
140 return false;
141 }
142 }
143 return true;
144}
145
146pub fn generate_service(
150 spec: &Spec,
151 import_mapping: &BTreeMap<String, String>,
152 response_type_suffix: &str,
153) -> Result<Service> {
154 let lowerer = Lowerer {
155 spec,
156 import_mapping,
157 response_type_suffix,
158 };
159 return lowerer.lower();
160}
161
162struct Lowerer<'a> {
164 spec: &'a Spec,
165 import_mapping: &'a BTreeMap<String, String>,
166 response_type_suffix: &'a str,
167}
168
169impl Lowerer<'_> {
170 fn lower(&self) -> Result<Service> {
172 let catalogue = security::scheme_catalogue(self.spec);
173 let mut operations = Vec::new();
174 let mut used_schemes: Vec<String> = Vec::new();
175 let mut claimed: BTreeMap<String, String> = BTreeMap::new();
181 let mut collisions = crate::lower::validate::Diagnostics::new();
182 for (path, entry) in self.spec.paths().iter() {
183 let item = match entry {
184 ReferenceOr::Item(item) => item,
185 ReferenceOr::Reference { .. } => {
186 return Err(Error::UnsupportedOperation {
187 method: "*".to_owned(),
188 path: path.clone(),
189 reason: "path-item `$ref`s are not supported".to_owned(),
190 });
191 }
192 };
193 for (method, operation) in item.iter() {
194 let mut lowered = self.lower_operation(path, method, operation, &item.parameters)?;
195 let route = format!("{method} {path}");
196 match claimed.get(lowered.name.logical()) {
197 Some(first) => {
198 collisions.push(Error::OperationNameCollision {
199 ident: lowered.name.logical().to_owned(),
200 first: first.clone(),
201 second: route,
202 hint: operation_collision_hint(operation),
203 });
204 continue;
208 }
209 None => {
210 claimed.insert(lowered.name.logical().to_owned(), route);
211 }
212 }
213 lowered.security = self.operation_security(operation);
214 for key in &lowered.security {
215 if !used_schemes.iter().any(|existing| return existing == key) {
216 used_schemes.push(key.clone());
217 }
218 }
219 operations.push(lowered);
220 }
221 }
222 collisions.into_result()?;
223 let security_schemes = catalogue
224 .into_iter()
225 .filter(|scheme| return used_schemes.iter().any(|key| return *key == scheme.key))
226 .collect();
227 return Ok(Service {
228 operations,
229 security_schemes,
230 });
231 }
232
233 fn operation_security(&self, operation: &OasOperation) -> Vec<String> {
242 let effective = security::effective_requirements(operation.security.as_deref(), self.spec.global_security());
243 let Some(requirements) = effective else {
244 return Vec::new();
245 };
246 return security::required_keys(requirements);
247 }
248
249 fn lower_operation(
251 &self,
252 path: &str,
253 method: &str,
254 operation: &OasOperation,
255 shared_params: &[ReferenceOr<Parameter>],
256 ) -> Result<Operation> {
257 let name = operation_name(path, method, operation)?;
258 let response_enum = operations::response_enum_name(&name, self.response_type_suffix);
259
260 let params = self.resolve_parameters(operation, shared_params)?;
261 let path_params = self.lower_path_params(path, method, ¶ms)?;
262 let query = self.lower_query_params(path, method, ¶ms, &name)?;
263 let headers = self.lower_header_params(path, method, ¶ms, &name)?;
264 let cookies = self.lower_cookie_params(path, method, ¶ms, &name)?;
265 let request = self.lower_request_body(path, method, &name, operation)?;
266 let responses = self.lower_responses(path, method, &response_enum, operation)?;
267
268 return Ok(Operation {
269 name,
270 response_enum,
271 doc: operation_doc(operation),
272 method: method.to_owned(),
273 path: path.to_owned(),
274 path_params,
275 query,
276 headers,
277 cookies,
278 request,
279 responses,
280 security: Vec::new(),
281 });
282 }
283
284 fn resolve_parameters(
290 &self,
291 operation: &OasOperation,
292 shared_params: &[ReferenceOr<Parameter>],
293 ) -> Result<Vec<Resolved<Parameter>>> {
294 let mut resolved = Vec::new();
295 for parameter in operation.parameters.iter().chain(shared_params) {
296 let entry = match parameter {
297 ReferenceOr::Item(param) => Resolved {
298 value: param.clone(),
299 origin: None,
300 },
301 ReferenceOr::Reference { reference } => self.spec.resolve_parameter(reference)?,
302 };
303 resolved.push(entry);
304 }
305 return Ok(resolved);
306 }
307
308 fn lower_path_params(&self, path: &str, method: &str, params: &[Resolved<Parameter>]) -> Result<Vec<Param>> {
311 let placeholders = path_param_names(path);
312 let mut path_params = Vec::new();
313 for name in &placeholders {
314 let declared = path_param_schema(name, params);
315 let ty = match declared {
316 Some((format, origin)) => self.param_type(path, method, name, origin, format)?,
317 None => {
318 return Err(Error::UndeclaredPathParameter {
319 method: method.to_owned(),
320 path: path.to_owned(),
321 name: name.clone(),
322 });
323 }
324 };
325 path_params.push(Param {
326 name: to_ident(name, Case::Snake),
327 ty,
328 });
329 }
330 for parameter in params {
335 let Parameter::Path { parameter_data, .. } = ¶meter.value else {
336 continue;
337 };
338 if !placeholders
339 .iter()
340 .any(|placeholder| return placeholder == ¶meter_data.name)
341 {
342 return Err(Error::InvalidPathParameter {
343 method: method.to_owned(),
344 path: path.to_owned(),
345 name: parameter_data.name.clone(),
346 });
347 }
348 }
349 return Ok(path_params);
350 }
351
352 fn lower_query_params(
358 &self,
359 path: &str,
360 method: &str,
361 params: &[Resolved<Parameter>],
362 operation_name: &RustIdent,
363 ) -> Result<Option<Struct>> {
364 let owner = operations::query_struct_name(operation_name);
365 let mut fields = Vec::new();
366 let mut seen: Vec<&str> = Vec::new();
367 for parameter in params {
368 let Parameter::Query {
369 parameter_data, style, ..
370 } = ¶meter.value
371 else {
372 continue;
373 };
374 if seen.contains(¶meter_data.name.as_str()) {
375 continue;
376 }
377 seen.push(¶meter_data.name);
378 fields.push(self.query_field(path, method, parameter.origin.as_deref(), parameter_data, style, &owner)?);
379 }
380 if fields.is_empty() {
381 return Ok(None);
382 }
383 let name = owner;
384 return Ok(Some(Struct {
385 name,
386 doc: None,
387 deprecated: None,
388 fields,
389 additional_properties: None,
390 deny_unknown_fields: false,
395 }));
396 }
397
398 fn query_field(
401 &self,
402 path: &str,
403 method: &str,
404 origin: Option<&str>,
405 data: &ParameterData,
406 style: &QueryStyle,
407 owner: &RustIdent,
408 ) -> Result<Field> {
409 let schema = self.query_param_schema(path, method, origin, data)?;
410 let mut ty = self.query_param_type(path, method, origin, data, style, &schema)?;
411
412 let declared = match data.required {
414 true => None,
415 false => schema.schema_data.default.clone(),
416 };
417 if !data.required && declared.is_none() {
418 ty = ty.optional();
419 }
420 let default = match &declared {
423 Some(json) => Some(lower_default(json, &ty, &|_| return None, owner.logical(), &data.name)?),
424 None => None,
425 };
426
427 let ident = to_ident(&data.name, Case::Snake);
428 let rename = crate::naming::rename_for(&data.name, &ident);
429 let constraints = crate::lower::constraints::constraints_of(&schema);
432 let field = Field {
433 name: ident,
434 rename,
435 doc: data.description.as_deref().and_then(trimmed),
436 deprecated: None,
437 ty,
438 required: data.required,
439 omit_empty: None,
440 serde_skip: false,
441 default,
442 constraints,
443 };
444 crate::lower::constraints::check_constraints(&field)?;
445 return Ok(field);
446 }
447
448 fn query_param_schema(
454 &self,
455 path: &str,
456 method: &str,
457 origin: Option<&str>,
458 data: &ParameterData,
459 ) -> Result<Schema> {
460 let ParameterSchemaOrContent::Schema(schema) = &data.format else {
461 return Err(Error::UnsupportedOperation {
462 method: method.to_owned(),
463 path: path.to_owned(),
464 reason: format!("query parameter `{}` uses `content`, which is not supported", data.name),
465 });
466 };
467 return self.resolve_param_schema(path, method, origin, &data.name, schema);
468 }
469
470 fn query_param_type(
477 &self,
478 path: &str,
479 method: &str,
480 origin: Option<&str>,
481 data: &ParameterData,
482 style: &QueryStyle,
483 schema: &Schema,
484 ) -> Result<RustType> {
485 let name = data.name.as_str();
486 let explode = data.explode;
487 if let SchemaKind::Type(Type::Array(array)) = &schema.schema_kind {
488 if !matches!(style, QueryStyle::Form) || explode == Some(false) {
489 return Err(Error::UnsupportedOperation {
490 method: method.to_owned(),
491 path: path.to_owned(),
492 reason: format!(
493 "query parameter `{name}` uses a non-default array encoding; only `style: form` with `explode: true` (repeated keys) is supported"
494 ),
495 });
496 }
497 let element = match &array.items {
498 Some(ReferenceOr::Item(item)) => scalar_type(&item.schema_kind),
499 Some(ReferenceOr::Reference { reference }) if ref_file_part(reference).is_some() => {
500 return Err(Error::UnsupportedOperation {
501 method: method.to_owned(),
502 path: path.to_owned(),
503 reason: format!(
504 "query parameter `{name}` uses array items via a cross-file `$ref`, which is not supported"
505 ),
506 });
507 }
508 Some(ReferenceOr::Reference { reference }) => {
509 let item = self.spec.resolve_schema(origin, reference)?;
510 scalar_type(&item.schema_kind)
511 }
512 None => {
513 return Err(Error::UnsupportedOperation {
514 method: method.to_owned(),
515 path: path.to_owned(),
516 reason: format!("query parameter `{name}` is an array without `items`"),
517 });
518 }
519 };
520 let element = element.ok_or_else(|| {
521 return Error::UnsupportedOperation {
522 method: method.to_owned(),
523 path: path.to_owned(),
524 reason: format!("query parameter `{name}` must be an array of scalars"),
525 };
526 })?;
527 return Ok(RustType::Vec(Box::new(element)));
528 }
529 let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
530 return Error::UnsupportedOperation {
531 method: method.to_owned(),
532 path: path.to_owned(),
533 reason: format!("query parameter `{name}` must be a scalar or an array of scalars"),
534 };
535 })?;
536 return Ok(ty);
537 }
538
539 fn resolve_param_schema(
544 &self,
545 path: &str,
546 method: &str,
547 origin: Option<&str>,
548 name: &str,
549 schema: &ReferenceOr<Schema>,
550 ) -> Result<Schema> {
551 match schema {
552 ReferenceOr::Item(schema) => return Ok(schema.clone()),
553 ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
554 return Err(Error::UnsupportedOperation {
555 method: method.to_owned(),
556 path: path.to_owned(),
557 reason: format!("query parameter `{name}` uses a cross-file `$ref`, which is not supported"),
558 });
559 }
560 ReferenceOr::Reference { reference } => return self.spec.resolve_schema(origin, reference),
561 }
562 }
563
564 fn lower_header_params(
570 &self,
571 path: &str,
572 method: &str,
573 params: &[Resolved<Parameter>],
574 operation_name: &RustIdent,
575 ) -> Result<Option<Headers>> {
576 let mut header_params = Vec::new();
577 let mut seen: Vec<&str> = Vec::new();
578 for parameter in params {
579 let Parameter::Header { parameter_data, .. } = ¶meter.value else {
580 continue;
581 };
582 let name = parameter_data.name.as_str();
583 if IGNORED_HEADER_NAMES
584 .iter()
585 .any(|ignored| return ignored.eq_ignore_ascii_case(name))
586 {
587 continue;
588 }
589 if seen.iter().any(|other| return other.eq_ignore_ascii_case(name)) {
590 continue;
591 }
592 seen.push(name);
593 header_params.push(self.header_param(path, method, parameter.origin.as_deref(), parameter_data)?);
594 }
595 if header_params.is_empty() {
596 return Ok(None);
597 }
598 let name = operations::headers_struct_name(operation_name);
599 return Ok(Some(Headers {
600 name,
601 params: header_params,
602 }));
603 }
604
605 fn header_param(
608 &self,
609 path: &str,
610 method: &str,
611 origin: Option<&str>,
612 data: &ParameterData,
613 ) -> Result<HeaderParam> {
614 let ty = self.header_param_type(path, method, origin, &data.name, &data.format)?;
615 return Ok(HeaderParam {
616 name: to_ident(&data.name, Case::Snake),
617 header_name: data.name.clone(),
618 ty,
619 required: data.required,
620 doc: data.description.as_deref().and_then(trimmed),
621 });
622 }
623
624 fn scalar_from_format(
630 &self,
631 path: &str,
632 method: &str,
633 origin: Option<&str>,
634 kind_label: &str,
635 name: &str,
636 format: &ParameterSchemaOrContent,
637 ) -> Result<RustType> {
638 let schema = match format {
639 ParameterSchemaOrContent::Schema(schema) => schema,
640 ParameterSchemaOrContent::Content(_) => {
641 return Err(Error::UnsupportedOperation {
642 method: method.to_owned(),
643 path: path.to_owned(),
644 reason: format!("{kind_label} `{name}` uses `content`, which is not supported"),
645 });
646 }
647 };
648 let schema = match schema {
649 ReferenceOr::Item(schema) => schema.clone(),
650 ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
651 return Err(Error::UnsupportedOperation {
652 method: method.to_owned(),
653 path: path.to_owned(),
654 reason: format!("{kind_label} `{name}` uses a cross-file `$ref`, which is not supported"),
655 });
656 }
657 ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
658 };
659 let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
660 return Error::UnsupportedOperation {
661 method: method.to_owned(),
662 path: path.to_owned(),
663 reason: format!("{kind_label} `{name}` must be a scalar"),
664 };
665 })?;
666 if matches!(ty, RustType::Bytes) {
667 return Err(Error::UnsupportedOperation {
668 method: method.to_owned(),
669 path: path.to_owned(),
670 reason: format!("{kind_label} `{name}` uses a `byte`/`binary` format, which is not supported"),
671 });
672 }
673 return Ok(ty);
674 }
675
676 fn header_param_type(
680 &self,
681 path: &str,
682 method: &str,
683 origin: Option<&str>,
684 name: &str,
685 format: &ParameterSchemaOrContent,
686 ) -> Result<RustType> {
687 return self.scalar_from_format(path, method, origin, "header parameter", name, format);
688 }
689
690 fn lower_cookie_params(
695 &self,
696 path: &str,
697 method: &str,
698 params: &[Resolved<Parameter>],
699 operation_name: &RustIdent,
700 ) -> Result<Option<Cookies>> {
701 let mut cookie_params = Vec::new();
702 let mut seen: Vec<&str> = Vec::new();
703 for parameter in params {
704 let Parameter::Cookie { parameter_data, .. } = ¶meter.value else {
705 continue;
706 };
707 let name = parameter_data.name.as_str();
708 if seen.contains(&name) {
709 continue;
710 }
711 seen.push(name);
712 let ty = self.cookie_param_type(
713 path,
714 method,
715 parameter.origin.as_deref(),
716 ¶meter_data.name,
717 ¶meter_data.format,
718 )?;
719 cookie_params.push(CookieParam {
720 name: to_ident(¶meter_data.name, Case::Snake),
721 cookie_name: parameter_data.name.clone(),
722 ty,
723 required: parameter_data.required,
724 doc: parameter_data.description.as_deref().and_then(trimmed),
725 });
726 }
727 if cookie_params.is_empty() {
728 return Ok(None);
729 }
730 let name = operations::cookies_struct_name(operation_name);
731 return Ok(Some(Cookies {
732 name,
733 params: cookie_params,
734 }));
735 }
736
737 fn cookie_param_type(
741 &self,
742 path: &str,
743 method: &str,
744 origin: Option<&str>,
745 name: &str,
746 format: &ParameterSchemaOrContent,
747 ) -> Result<RustType> {
748 let schema = match format {
749 ParameterSchemaOrContent::Schema(schema) => schema,
750 ParameterSchemaOrContent::Content(_) => {
751 return Err(Error::UnsupportedOperation {
752 method: method.to_owned(),
753 path: path.to_owned(),
754 reason: format!("cookie parameter `{name}` uses `content`, which is not supported"),
755 });
756 }
757 };
758 let schema = match schema {
759 ReferenceOr::Item(schema) => schema.clone(),
760 ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
761 return Err(Error::UnsupportedOperation {
762 method: method.to_owned(),
763 path: path.to_owned(),
764 reason: format!("cookie parameter `{name}` uses a cross-file `$ref`, which is not supported"),
765 });
766 }
767 ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
768 };
769 let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
770 return Error::UnsupportedOperation {
771 method: method.to_owned(),
772 path: path.to_owned(),
773 reason: format!("cookie parameter `{name}` must be a scalar"),
774 };
775 })?;
776 if matches!(ty, RustType::Bytes) {
777 return Err(Error::UnsupportedOperation {
778 method: method.to_owned(),
779 path: path.to_owned(),
780 reason: format!("cookie parameter `{name}` uses a `byte`/`binary` format, which is not supported"),
781 });
782 }
783 return Ok(ty);
784 }
785
786 fn supported_bodies<'m>(
794 &self,
795 content: &'m indexmap::IndexMap<String, openapiv3::MediaType>,
796 priority: &[BodyKind],
797 ) -> Vec<(BodyKind, &'m openapiv3::MediaType)> {
798 let mut selected = Vec::new();
799 for &wanted in priority {
800 for (name, media) in content {
801 if media_type_kind(name) == Some(wanted) {
802 selected.push((wanted, media));
803 break;
804 }
805 }
806 }
807 return selected;
808 }
809
810 fn body_from_media(
814 &self,
815 path: &str,
816 method: &str,
817 origin: Option<&str>,
818 kind: BodyKind,
819 media: &openapiv3::MediaType,
820 ) -> Result<Option<Body>> {
821 let schema = match &media.schema {
822 Some(schema) => schema,
823 None => return Ok(None),
824 };
825 let ty = match kind {
826 BodyKind::Json => self.body_type(path, method, origin, schema)?,
827 BodyKind::Text => {
828 let resolved = match schema {
829 ReferenceOr::Item(schema) => schema.clone(),
830 ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
831 };
832 if !matches!(resolved.schema_kind, SchemaKind::Type(Type::String(_))) {
833 return Err(Error::UnsupportedOperation {
834 method: method.to_owned(),
835 path: path.to_owned(),
836 reason: "text/plain body must be a `string` schema".to_owned(),
837 });
838 }
839 RustType::String
840 }
841 BodyKind::Form => match schema {
842 ReferenceOr::Reference { reference } => {
843 if ref_file_part(reference).is_none() {
851 let resolved = self.spec.resolve_schema(origin, reference)?;
852 if !matches!(resolved.schema_kind, SchemaKind::Type(Type::Object(_))) {
853 return Err(Error::UnsupportedOperation {
854 method: method.to_owned(),
855 path: path.to_owned(),
856 reason:
857 "form (`application/x-www-form-urlencoded`) body must reference an `object` schema"
858 .to_owned(),
859 });
860 }
861 }
862 self.schema_ref_type(path, method, origin, reference)?
863 }
864 ReferenceOr::Item(_) => {
865 return Err(Error::UnsupportedOperation {
866 method: method.to_owned(),
867 path: path.to_owned(),
868 reason: "form (`application/x-www-form-urlencoded`) body must reference a named object schema"
869 .to_owned(),
870 });
871 }
872 },
873 BodyKind::Multipart => {
877 return Err(Error::UnsupportedOperation {
878 method: method.to_owned(),
879 path: path.to_owned(),
880 reason: "multipart/form-data is only supported for request bodies".to_owned(),
881 });
882 }
883 };
884 return Ok(Some(Body { ty, kind }));
885 }
886}
887
888fn path_param_schema<'a>(
891 name: &str,
892 params: &'a [Resolved<Parameter>],
893) -> Option<(&'a ParameterSchemaOrContent, Option<&'a str>)> {
894 for parameter in params {
895 let Parameter::Path { parameter_data, .. } = ¶meter.value else {
896 continue;
897 };
898 if parameter_data.name == name {
899 return Some((¶meter_data.format, parameter.origin.as_deref()));
900 }
901 }
902 return None;
903}
904
905fn body_kind_ident(kind: BodyKind) -> RustIdent {
909 let name = match kind {
910 BodyKind::Json => "Json",
911 BodyKind::Form => "Form",
912 BodyKind::Text => "Text",
913 BodyKind::Multipart => "Multipart",
914 };
915 return to_ident(name, Case::Pascal);
916}
917
918fn declared_content_types(content: &indexmap::IndexMap<String, openapiv3::MediaType>) -> String {
921 return content.keys().cloned().collect::<Vec<_>>().join(", ");
922}
923
924fn media_type_kind(name: &str) -> Option<BodyKind> {
928 let base = name.split(';').next().unwrap_or(name).trim().to_ascii_lowercase();
929 if base == "application/json" || base.ends_with("+json") {
930 return Some(BodyKind::Json);
931 }
932 if base == "application/x-www-form-urlencoded" {
933 return Some(BodyKind::Form);
934 }
935 if base == "multipart/form-data" {
936 return Some(BodyKind::Multipart);
937 }
938 if base == "text/plain" {
939 return Some(BodyKind::Text);
940 }
941 return None;
942}
943
944fn scalar_type(kind: &SchemaKind) -> Option<RustType> {
947 let ty = match kind {
948 SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
949 SchemaKind::Type(Type::Integer(it)) => integer_type(it),
950 SchemaKind::Type(Type::Number(_)) => RustType::F64,
951 SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
952 _ => return None,
953 };
954 return Some(ty);
955}
956
957impl Lowerer<'_> {
958 fn param_type(
966 &self,
967 path: &str,
968 method: &str,
969 name: &str,
970 origin: Option<&str>,
971 format: &ParameterSchemaOrContent,
972 ) -> Result<RustType> {
973 let schema = match format {
974 ParameterSchemaOrContent::Schema(schema) => schema,
975 ParameterSchemaOrContent::Content(_) => {
976 return Err(Error::UnsupportedOperation {
977 method: method.to_owned(),
978 path: path.to_owned(),
979 reason: format!("path parameter `{name}` uses `content`, which is not supported"),
980 });
981 }
982 };
983 let schema = match schema {
984 ReferenceOr::Item(schema) => schema.clone(),
985 ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
986 return Err(Error::UnsupportedOperation {
987 method: method.to_owned(),
988 path: path.to_owned(),
989 reason: format!("path parameter `{name}` uses a cross-file `$ref`, which is not supported"),
990 });
991 }
992 ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
993 };
994 let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
995 return Error::UnsupportedOperation {
996 method: method.to_owned(),
997 path: path.to_owned(),
998 reason: format!("path parameter `{name}` must be a scalar type"),
999 };
1000 })?;
1001 return Ok(ty);
1002 }
1003
1004 fn lower_request_body(
1014 &self,
1015 path: &str,
1016 method: &str,
1017 op_name: &RustIdent,
1018 operation: &OasOperation,
1019 ) -> Result<Option<RequestPayload>> {
1020 let body = match &operation.request_body {
1021 Some(body) => body,
1022 None => return Ok(None),
1023 };
1024 let (body, origin): (RequestBody, Option<String>) = match body {
1025 ReferenceOr::Item(body) => (body.clone(), None),
1026 ReferenceOr::Reference { reference } => {
1027 let resolved = self.spec.resolve_request_body(reference)?;
1028 (resolved.value, resolved.origin)
1029 }
1030 };
1031 let supported = self.supported_bodies(&body.content, &REQUEST_BODY_PRIORITY);
1032 if supported.is_empty() {
1033 if body.content.is_empty() {
1034 return Ok(None);
1035 }
1036 return Err(Error::UnsupportedContentType {
1037 method: method.to_owned(),
1038 path: path.to_owned(),
1039 location: "request body".to_owned(),
1040 declared: declared_content_types(&body.content),
1041 hint: "A request body must declare `application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`. Add one of them, or remove the `requestBody`.".to_owned(),
1042 });
1043 }
1044 let has_multipart = supported.iter().any(|(kind, _)| return *kind == BodyKind::Multipart);
1045 if has_multipart {
1046 if supported.len() > 1 {
1047 return Err(Error::UnsupportedOperation {
1048 method: method.to_owned(),
1049 path: path.to_owned(),
1050 reason: "multipart/form-data cannot be combined with other request content types".to_owned(),
1051 });
1052 }
1053 let Some(&(_, media)) = supported.first() else {
1054 unreachable!("multipart body content type count already validated to be exactly one");
1055 };
1056 let multipart = self.lower_multipart_body(path, method, op_name, origin.as_deref(), media)?;
1057 return Ok(Some(RequestPayload::Multipart(multipart)));
1058 }
1059 let mut variants = Vec::with_capacity(supported.len());
1060 for (kind, media) in supported {
1061 if let Some(body) = self.body_from_media(path, method, origin.as_deref(), kind, media)? {
1062 variants.push(BodyVariant {
1063 variant: body_kind_ident(kind),
1064 body,
1065 });
1066 }
1067 }
1068 if variants.len() == 1 {
1069 let Some(variant) = variants.pop() else {
1070 unreachable!("length checked to be 1 above");
1071 };
1072 return Ok(Some(RequestPayload::Single(variant.body)));
1073 }
1074 if variants.is_empty() {
1075 return Ok(None);
1076 }
1077 return Ok(Some(RequestPayload::Negotiated(NegotiatedBody {
1078 name: operations::request_body_enum_name(op_name),
1079 variants,
1080 })));
1081 }
1082
1083 fn lower_multipart_body(
1098 &self,
1099 path: &str,
1100 method: &str,
1101 op_name: &RustIdent,
1102 origin: Option<&str>,
1103 media: &openapiv3::MediaType,
1104 ) -> Result<Multipart> {
1105 let unsupported = |reason: String| {
1106 return Error::UnsupportedOperation {
1107 method: method.to_owned(),
1108 path: path.to_owned(),
1109 reason,
1110 };
1111 };
1112 let schema = media
1113 .schema
1114 .as_ref()
1115 .ok_or_else(|| return unsupported("multipart/form-data body must declare a schema".to_owned()))?;
1116 let object = self.multipart_object(path, method, origin, schema)?;
1117 let fields = self.lower_multipart_fields(path, method, &object)?;
1118 return Ok(Multipart {
1119 name: operations::multipart_struct_name(op_name),
1120 fields,
1121 });
1122 }
1123
1124 fn multipart_object(
1130 &self,
1131 path: &str,
1132 method: &str,
1133 origin: Option<&str>,
1134 schema: &ReferenceOr<Schema>,
1135 ) -> Result<ObjectType> {
1136 let reject = |reason: &str| {
1137 return Error::UnsupportedOperation {
1138 method: method.to_owned(),
1139 path: path.to_owned(),
1140 reason: reason.to_owned(),
1141 };
1142 };
1143 let not_object = "multipart/form-data body schema must be an `object`";
1144 let cross_file = "multipart/form-data body must be an inline object or a same-document `$ref`; cross-file/external multipart is unsupported";
1145 match schema {
1146 ReferenceOr::Item(item) => {
1147 if origin.is_some() {
1148 return Err(reject(cross_file));
1149 }
1150 match &item.schema_kind {
1151 SchemaKind::Type(Type::Object(object)) => return Ok(object.clone()),
1152 _ => return Err(reject(not_object)),
1153 }
1154 }
1155 ReferenceOr::Reference { reference } => {
1156 if origin.is_some() || ref_file_part(reference).is_some() {
1157 return Err(reject(cross_file));
1158 }
1159 let resolved = self.spec.resolve_schema(origin, reference)?;
1160 match &resolved.schema_kind {
1161 SchemaKind::Type(Type::Object(object)) => return Ok(object.clone()),
1162 _ => return Err(reject(not_object)),
1163 }
1164 }
1165 }
1166 }
1167
1168 fn lower_multipart_fields(&self, path: &str, method: &str, object: &ObjectType) -> Result<Vec<MultipartField>> {
1175 let mut fields = Vec::with_capacity(object.properties.len());
1176 for (wire_name, property) in &object.properties {
1177 let required = object.required.iter().any(|name| {
1178 return name == wire_name;
1179 });
1180 let (kind, nullable) = match property {
1181 ReferenceOr::Item(schema) => (schema.schema_kind.clone(), schema.schema_data.nullable),
1182 ReferenceOr::Reference { reference } => {
1183 if ref_file_part(reference).is_some() {
1184 return Err(Error::UnsupportedOperation {
1185 method: method.to_owned(),
1186 path: path.to_owned(),
1187 reason: format!(
1188 "multipart field `{wire_name}` uses a cross-file `$ref`, which is not supported"
1189 ),
1190 });
1191 }
1192 let resolved = self.spec.resolve_schema(None, reference)?;
1193 (resolved.schema_kind, resolved.schema_data.nullable)
1194 }
1195 };
1196 let ty = scalar_type(&kind).ok_or_else(|| {
1197 return Error::UnsupportedOperation {
1198 method: method.to_owned(),
1199 path: path.to_owned(),
1200 reason: format!(
1201 "multipart field `{wire_name}` must be a scalar or binary string; nested objects and arrays are not supported"
1202 ),
1203 };
1204 })?;
1205 fields.push(MultipartField {
1206 wire_name: wire_name.clone(),
1207 rust_name: to_ident(wire_name, Case::Snake),
1208 is_file: matches!(ty, RustType::Bytes),
1209 ty,
1210 optional: !required || nullable,
1211 });
1212 }
1213 return Ok(fields);
1214 }
1215
1216 fn lower_response_headers(
1220 &self,
1221 path: &str,
1222 method: &str,
1223 origin: Option<&str>,
1224 response: &OasResponse,
1225 ) -> Result<Vec<crate::ir::ResponseHeader>> {
1226 let mut headers = Vec::new();
1227 let mut seen: Vec<String> = Vec::new();
1228 let mut seen_idents: Vec<String> = Vec::new();
1229 for (header_name, header_ref) in &response.headers {
1230 let header = match header_ref {
1231 ReferenceOr::Item(header) => header,
1232 ReferenceOr::Reference { .. } => {
1233 return Err(Error::UnsupportedOperation {
1234 method: method.to_owned(),
1235 path: path.to_owned(),
1236 reason: format!("response header `{header_name}` uses a `$ref`, which is not supported"),
1237 });
1238 }
1239 };
1240 if seen.iter().any(|other| return other.eq_ignore_ascii_case(header_name)) {
1241 continue;
1242 }
1243 if !is_valid_header_name(header_name) {
1244 return Err(Error::UnsupportedOperation {
1245 method: method.to_owned(),
1246 path: path.to_owned(),
1247 reason: format!("response header `{header_name}` has an invalid header name"),
1248 });
1249 }
1250 let ident = to_ident(header_name, Case::Snake);
1251 if RESERVED_RESPONSE_FIELDS.contains(&ident.logical()) {
1256 return Err(Error::UnsupportedOperation {
1257 method: method.to_owned(),
1258 path: path.to_owned(),
1259 reason: format!(
1260 "response header `{header_name}` maps to the reserved Rust field name `{}`",
1261 ident.logical()
1262 ),
1263 });
1264 }
1265 if seen_idents.iter().any(|other| return other == ident.logical()) {
1270 return Err(Error::UnsupportedOperation {
1271 method: method.to_owned(),
1272 path: path.to_owned(),
1273 reason: format!(
1274 "response header `{header_name}` maps to the same Rust field name as another header (`{}`)",
1275 ident.logical()
1276 ),
1277 });
1278 }
1279 seen.push(header_name.clone());
1280 seen_idents.push(ident.logical().to_owned());
1281 let ty = self.scalar_from_format(path, method, origin, "response header", header_name, &header.format)?;
1282 headers.push(crate::ir::ResponseHeader {
1283 name: ident,
1284 header_name: header_name.clone(),
1285 ty,
1286 required: header.required,
1287 doc: header.description.as_deref().and_then(trimmed),
1288 });
1289 }
1290 return Ok(headers);
1291 }
1292
1293 fn lower_responses(
1299 &self,
1300 path: &str,
1301 method: &str,
1302 response_enum: &RustIdent,
1303 operation: &OasOperation,
1304 ) -> Result<Vec<ResponseCase>> {
1305 let mut cases = Vec::new();
1306 for (status_code, response) in &operation.responses.responses {
1307 let (status, variant) = match status_code {
1308 StatusCode::Code(code) => {
1309 let reason = HttpStatus::from_u16(*code).ok().and_then(|status| {
1310 return status.canonical_reason();
1311 });
1312 let reason = reason.ok_or_else(|| {
1313 return Error::UnsupportedOperation {
1314 method: method.to_owned(),
1315 path: path.to_owned(),
1316 reason: format!("status code `{code}` is not a recognised HTTP status"),
1317 };
1318 })?;
1319 (ResponseStatus::Fixed(*code), to_ident(reason, Case::Pascal))
1320 }
1321 StatusCode::Range(range) => {
1322 if !(1..=5).contains(range) {
1323 return Err(Error::UnsupportedOperation {
1324 method: method.to_owned(),
1325 path: path.to_owned(),
1326 reason: format!("response range `{range}XX` is not a valid HTTP status class"),
1327 });
1328 }
1329 let variant = to_ident(&format!("status_{range}xx"), Case::Pascal);
1330 let Ok(range) = u8::try_from(*range) else {
1331 unreachable!("range checked to be within 1..=5 above");
1332 };
1333 (ResponseStatus::Range(range), variant)
1334 }
1335 };
1336 let response = self.resolve_response_ref(response)?;
1337 let location = format!("`{status_code}` response");
1340 let body = self.response_body(path, method, &location, response.origin.as_deref(), &response.value)?;
1341 let body = self.name_response_body(response_enum, &variant, body);
1342 let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?;
1343 cases.push(ResponseCase {
1344 variant,
1345 status,
1346 body,
1347 headers,
1348 doc: trimmed(&response.value.description),
1349 });
1350 }
1351
1352 if let Some(default) = &operation.responses.default {
1353 let response = self.resolve_response_ref(default)?;
1354 let variant = to_ident("default", Case::Pascal);
1355 let body = self.response_body(
1356 path,
1357 method,
1358 "`default` response",
1359 response.origin.as_deref(),
1360 &response.value,
1361 )?;
1362 let body = self.name_response_body(response_enum, &variant, body);
1363 let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?;
1364 cases.push(ResponseCase {
1365 variant,
1366 status: ResponseStatus::Default,
1367 body,
1368 headers,
1369 doc: trimmed(&response.value.description),
1370 });
1371 }
1372
1373 if cases.is_empty() {
1374 return Err(Error::UnsupportedOperation {
1375 method: method.to_owned(),
1376 path: path.to_owned(),
1377 reason: "operation declares no responses".to_owned(),
1378 });
1379 }
1380 return Ok(cases);
1381 }
1382
1383 fn resolve_response_ref(&self, response: &ReferenceOr<OasResponse>) -> Result<Resolved<OasResponse>> {
1386 match response {
1387 ReferenceOr::Item(response) => {
1388 return Ok(Resolved {
1389 value: response.clone(),
1390 origin: None,
1391 });
1392 }
1393 ReferenceOr::Reference { reference } => return self.spec.resolve_response(reference),
1394 }
1395 }
1396
1397 fn response_body(
1410 &self,
1411 path: &str,
1412 method: &str,
1413 location: &str,
1414 origin: Option<&str>,
1415 response: &OasResponse,
1416 ) -> Result<Option<LoweredResponseBody>> {
1417 let supported = self.supported_bodies(&response.content, &RESPONSE_BODY_PRIORITY);
1418 if supported.is_empty() && !response.content.is_empty() {
1419 return Err(Error::UnsupportedContentType {
1420 method: method.to_owned(),
1421 path: path.to_owned(),
1422 location: location.to_owned(),
1423 declared: declared_content_types(&response.content),
1424 hint: "A response body must declare `application/json`, `application/x-www-form-urlencoded`, or `text/plain`. Add one of them, or declare no `content:` for a bodyless response.".to_owned(),
1425 });
1426 }
1427 let mut variants = Vec::with_capacity(supported.len());
1428 for (kind, media) in supported {
1429 if let Some(body) = self.body_from_media(path, method, origin, kind, media)? {
1430 variants.push(BodyVariant {
1431 variant: body_kind_ident(kind),
1432 body,
1433 });
1434 }
1435 }
1436 if variants.len() == 1 {
1437 let Some(variant) = variants.pop() else {
1438 unreachable!("length checked to be 1 above");
1439 };
1440 return Ok(Some(LoweredResponseBody::Single(variant.body)));
1441 }
1442 if variants.is_empty() {
1443 return Ok(None);
1444 }
1445 return Ok(Some(LoweredResponseBody::Negotiated(variants)));
1446 }
1447
1448 fn name_response_body(
1452 &self,
1453 response_enum: &RustIdent,
1454 variant: &RustIdent,
1455 lowered: Option<LoweredResponseBody>,
1456 ) -> Option<ResponseBody> {
1457 return lowered.map(|body| {
1458 return match body {
1459 LoweredResponseBody::Single(body) => ResponseBody::Single(body),
1460 LoweredResponseBody::Negotiated(variants) => ResponseBody::Negotiated(NegotiatedBody {
1461 name: operations::response_body_enum_name(response_enum, variant),
1462 variants,
1463 }),
1464 };
1465 });
1466 }
1467
1468 fn body_type(
1473 &self,
1474 path: &str,
1475 method: &str,
1476 origin: Option<&str>,
1477 schema: &ReferenceOr<Schema>,
1478 ) -> Result<RustType> {
1479 match schema {
1480 ReferenceOr::Reference { reference } => return self.schema_ref_type(path, method, origin, reference),
1481 ReferenceOr::Item(schema) => return self.inline_body_type(path, method, origin, schema),
1482 }
1483 }
1484
1485 fn inline_body_type(&self, path: &str, method: &str, origin: Option<&str>, schema: &Schema) -> Result<RustType> {
1487 let ty = match &schema.schema_kind {
1488 SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
1489 SchemaKind::Type(Type::Integer(it)) => integer_type(it),
1490 SchemaKind::Type(Type::Number(_)) => RustType::F64,
1491 SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
1492 SchemaKind::Type(Type::Array(at)) => {
1493 let element = match &at.items {
1494 Some(ReferenceOr::Reference { reference }) => {
1495 self.schema_ref_type(path, method, origin, reference)?
1496 }
1497 Some(ReferenceOr::Item(item)) => self.inline_body_type(path, method, origin, item)?,
1498 None => RustType::Value,
1499 };
1500 RustType::Vec(Box::new(element))
1501 }
1502 SchemaKind::Any(_) => RustType::Value,
1503 _ => {
1504 return Err(Error::UnsupportedOperation {
1505 method: method.to_owned(),
1506 path: path.to_owned(),
1507 reason: "composite request/response bodies must reference a named schema (`$ref`)".to_owned(),
1508 });
1509 }
1510 };
1511 return Ok(ty);
1512 }
1513
1514 fn schema_ref_type(&self, path: &str, method: &str, origin: Option<&str>, reference: &str) -> Result<RustType> {
1520 let target = ref_component_name(reference, "schemas").ok_or_else(|| {
1521 return Error::UnsupportedOperation {
1522 method: method.to_owned(),
1523 path: path.to_owned(),
1524 reason: format!("reference `{reference}` must point at a component schema"),
1525 };
1526 })?;
1527 let file = ref_file_part(reference)
1528 .map(str::to_owned)
1529 .or_else(|| return origin.map(str::to_owned));
1530 let Some(file) = file else {
1531 if !self.spec.schemas().contains_key(target) {
1536 return Err(Error::UnresolvedRef(reference.to_owned()));
1537 }
1538 return Ok(RustType::Named(target.to_owned()));
1539 };
1540 let module = self.import_mapping.get(&file).ok_or_else(|| {
1541 return Error::UnsupportedOperation {
1542 method: method.to_owned(),
1543 path: path.to_owned(),
1544 reason: format!("cross-file reference `{reference}` needs an `import-mapping` entry for `{file}`"),
1545 };
1546 })?;
1547 return Ok(RustType::External {
1548 module: module.clone(),
1549 name: self.spec.external_schema_name(&file, target, reference)?,
1550 });
1551 }
1552}
1553
1554fn operation_name(path: &str, method: &str, operation: &OasOperation) -> Result<crate::naming::RustIdent> {
1562 let at = format!("{method} {path}");
1563 if let Some(name) = crate::lower::extension::str_value(&operation.extensions, X_RUST_NAME, &at)? {
1564 return Ok(operations::operation_method_name(name));
1565 }
1566 if let Some(id) = &operation.operation_id {
1567 return Ok(operations::operation_method_name(id));
1568 }
1569 return Ok(operations::operation_method_name(&at));
1570}
1571
1572fn operation_collision_hint(operation: &OasOperation) -> String {
1583 if operation.extensions.contains_key(X_RUST_NAME) {
1584 return format!(
1585 "This operation already sets `{X_RUST_NAME}`, and that name collides too. \
1586 Give it a name that no other operation uses."
1587 );
1588 }
1589 if operation.operation_id.is_some() {
1590 return format!(
1591 "Two `operationId`s that differ only in case or in punctuation produce one Rust name. \
1592 Give one of the two operations a different `operationId`, or set `{X_RUST_NAME}` on it \
1593 to name the generated method directly."
1594 );
1595 }
1596 return format!(
1597 "This operation declares no `operationId`, so its name comes from the method and the path. \
1598 Add an `operationId`, or set `{X_RUST_NAME}` on it to name the generated method directly."
1599 );
1600}
1601
1602fn operation_doc(operation: &OasOperation) -> Option<String> {
1604 if let Some(summary) = &operation.summary
1605 && let Some(text) = trimmed(summary)
1606 {
1607 return Some(text);
1608 }
1609 return operation.description.as_ref().and_then(|text| {
1610 return trimmed(text);
1611 });
1612}
1613
1614fn path_param_names(path: &str) -> Vec<String> {
1616 let mut names = Vec::new();
1617 let mut rest = path;
1618 while let Some(open) = rest.find('{') {
1619 let after_open = &rest[open + 1..];
1620 let Some(close) = after_open.find('}') else {
1621 break;
1622 };
1623 names.push(after_open[..close].to_owned());
1624 rest = &after_open[close + 1..];
1625 }
1626 return names;
1627}
1628
1629fn trimmed(text: &str) -> Option<String> {
1631 let trimmed = text.trim();
1632 if trimmed.is_empty() {
1633 return None;
1634 }
1635 return Some(trimmed.to_owned());
1636}
1637
1638#[cfg(test)]
1639mod tests {
1640 use super::*;
1641
1642 #[test]
1643 fn valid_header_names_accept_tokens_and_reject_separators() {
1644 assert!(is_valid_header_name("X-Request-Id"));
1646 assert!(is_valid_header_name("X-RateLimit-Remaining"));
1647 assert!(is_valid_header_name("Sec-CH-UA-Platform-Version"));
1648 assert!(is_valid_header_name("a.b"));
1649 assert!(!is_valid_header_name(""));
1653 assert!(!is_valid_header_name("X/Y"));
1654 assert!(!is_valid_header_name("X:Y"));
1655 assert!(!is_valid_header_name("X Y"));
1656 assert!(!is_valid_header_name("X(Y)"));
1657 }
1658
1659 #[test]
1660 fn extracts_path_param_names_in_order() {
1661 assert_eq!(path_param_names("/v1/widgets"), Vec::<String>::new());
1662 assert_eq!(path_param_names("/pets/{id}"), vec!["id".to_owned()]);
1663 assert_eq!(
1664 path_param_names("/orgs/{org}/pets/{petId}"),
1665 vec!["org".to_owned(), "petId".to_owned()],
1666 );
1667 }
1668
1669 #[test]
1670 fn response_variants_are_named_after_the_status_reason() {
1671 let variant = |code| {
1672 let reason = HttpStatus::from_u16(code)
1673 .expect("test status code is a valid HTTP status")
1674 .canonical_reason()
1675 .expect("status code has a canonical reason phrase");
1676 return to_ident(reason, Case::Pascal).logical().to_owned();
1677 };
1678 assert_eq!(variant(200), "Ok");
1679 assert_eq!(variant(204), "NoContent");
1680 assert_eq!(variant(404), "NotFound");
1681 assert_eq!(variant(500), "InternalServerError");
1682 }
1683
1684 #[test]
1685 fn range_response_variants_are_derived_from_the_range_digit() {
1686 let variant = |range: u16| {
1687 return to_ident(&format!("status_{range}xx"), Case::Pascal)
1688 .logical()
1689 .to_owned();
1690 };
1691 assert_eq!(variant(4), "Status4xx");
1692 assert_eq!(variant(5), "Status5xx");
1693 }
1694}