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::PathStyle;
49use openapiv3::QueryStyle;
50use openapiv3::ReferenceOr;
51use openapiv3::RequestBody;
52use openapiv3::Response as OasResponse;
53use openapiv3::Schema;
54use openapiv3::SchemaData;
55use openapiv3::SchemaKind;
56use openapiv3::StatusCode;
57use openapiv3::Type;
58
59use crate::diagnostic::Warning;
60use crate::diagnostic::report_warnings;
61use crate::error::Error;
62use crate::error::Result;
63use crate::ir::Body;
64use crate::ir::BodyKind;
65use crate::ir::BodyVariant;
66use crate::ir::CookieParam;
67use crate::ir::Cookies;
68use crate::ir::Field;
69use crate::ir::HeaderParam;
70use crate::ir::Headers;
71use crate::ir::Multipart;
72use crate::ir::MultipartField;
73use crate::ir::NegotiatedBody;
74use crate::ir::Operation;
75use crate::ir::Param;
76use crate::ir::RequestPayload;
77use crate::ir::ResponseBody;
78use crate::ir::ResponseCase;
79use crate::ir::ResponseStatus;
80use crate::ir::RustType;
81use crate::ir::Service;
82use crate::ir::Struct;
83use crate::loader::Resolved;
84use crate::loader::Spec;
85use crate::loader::ref_component_name;
86use crate::loader::ref_file_part;
87use crate::lower::default::lower_default;
88use crate::lower::schema::integer_type;
89use crate::lower::schema::string_format_type;
90use crate::lower::security;
91use crate::naming::Case;
92use crate::naming::RustIdent;
93use crate::naming::X_RUST_NAME;
94use crate::naming::operations;
95use crate::naming::to_ident;
96
97pub(crate) const IGNORED_HEADER_NAMES: [&str; 3] = ["accept", "content-type", "authorization"];
101
102pub(crate) const REQUEST_BODY_PRIORITY: [BodyKind; 4] =
106 [BodyKind::Json, BodyKind::Form, BodyKind::Multipart, BodyKind::Text];
107
108pub(crate) const RESPONSE_BODY_PRIORITY: [BodyKind; 3] = [BodyKind::Json, BodyKind::Form, BodyKind::Text];
113
114enum LoweredResponseBody {
118 Single(Body),
119 Negotiated(Vec<BodyVariant>),
120}
121
122const RESERVED_RESPONSE_FIELDS: [&str; 2] = ["status", "body"];
127
128fn is_valid_header_name(name: &str) -> bool {
133 if name.is_empty() {
134 return false;
135 }
136 for byte in name.as_bytes() {
137 let valid = matches!(
141 byte,
142 b'!' | b'#'..=b'\'' | b'*'..=b'+' | b'-' | b'.' | b'0'..=b'9' | b'A'..=b'Z' | b'^'..=b'z' | b'|' | b'~'
143 );
144 if !valid {
145 return false;
146 }
147 }
148 return true;
149}
150
151pub fn generate_service(
155 spec: &Spec,
156 import_mapping: &BTreeMap<String, String>,
157 response_type_suffix: &str,
158) -> Result<Service> {
159 let lowerer = Lowerer {
160 spec,
161 import_mapping,
162 response_type_suffix,
163 };
164 return lowerer.lower();
165}
166
167struct Lowerer<'a> {
169 spec: &'a Spec,
170 import_mapping: &'a BTreeMap<String, String>,
171 response_type_suffix: &'a str,
172}
173
174impl Lowerer<'_> {
175 fn lower(&self) -> Result<Service> {
177 let catalogue = security::scheme_catalogue(self.spec);
178 let mut operations = Vec::new();
179 let mut used_schemes: Vec<String> = Vec::new();
180 let mut claimed: BTreeMap<String, String> = BTreeMap::new();
186 let mut collisions = crate::lower::validate::Diagnostics::new();
187 for (path, entry) in self.spec.paths().iter() {
188 let item = match entry {
189 ReferenceOr::Item(item) => item,
190 ReferenceOr::Reference { .. } => {
191 return Err(Error::UnsupportedOperation {
192 method: "*".to_owned(),
193 path: path.clone(),
194 reason: "path-item `$ref`s are not supported".to_owned(),
195 });
196 }
197 };
198 for (method, operation) in item.iter() {
199 let mut lowered = self.lower_operation(path, method, operation, &item.parameters)?;
200 let route = format!("{method} {path}");
201 match claimed.get(lowered.name.logical()) {
202 Some(first) => {
203 collisions.push(Error::OperationNameCollision {
204 ident: lowered.name.logical().to_owned(),
205 first: first.clone(),
206 second: route,
207 hint: operation_collision_hint(operation),
208 });
209 continue;
213 }
214 None => {
215 claimed.insert(lowered.name.logical().to_owned(), route);
216 }
217 }
218 lowered.security = self.operation_security(operation);
219 for key in &lowered.security {
220 if !used_schemes.iter().any(|existing| return existing == key) {
221 used_schemes.push(key.clone());
222 }
223 }
224 operations.push(lowered);
225 }
226 }
227 collisions.into_result()?;
228 let security_schemes = catalogue
229 .into_iter()
230 .filter(|scheme| return used_schemes.iter().any(|key| return *key == scheme.key))
231 .collect();
232 return Ok(Service {
233 operations,
234 security_schemes,
235 });
236 }
237
238 fn operation_security(&self, operation: &OasOperation) -> Vec<String> {
247 let effective = security::effective_requirements(operation.security.as_deref(), self.spec.global_security());
248 let Some(requirements) = effective else {
249 return Vec::new();
250 };
251 return security::required_keys(requirements);
252 }
253
254 fn lower_operation(
256 &self,
257 path: &str,
258 method: &str,
259 operation: &OasOperation,
260 shared_params: &[ReferenceOr<Parameter>],
261 ) -> Result<Operation> {
262 let name = operation_name(path, method, operation)?;
263 let response_enum = operations::response_enum_name(&name, self.response_type_suffix);
264
265 let params = self.resolve_parameters(operation, shared_params)?;
266 let path_params = self.lower_path_params(path, method, ¶ms)?;
267 let query = self.lower_query_params(path, method, ¶ms, &name)?;
268 let headers = self.lower_header_params(path, method, ¶ms, &name)?;
269 let cookies = self.lower_cookie_params(path, method, ¶ms, &name)?;
270 let request = self.lower_request_body(path, method, &name, operation)?;
271 let responses = self.lower_responses(path, method, &name, operation)?;
272
273 return Ok(Operation {
274 name,
275 response_enum,
276 doc: operation_doc(operation),
277 method: method.to_owned(),
278 path: path.to_owned(),
279 path_params,
280 query,
281 headers,
282 cookies,
283 request,
284 responses,
285 security: Vec::new(),
286 });
287 }
288
289 fn resolve_parameters(
295 &self,
296 operation: &OasOperation,
297 shared_params: &[ReferenceOr<Parameter>],
298 ) -> Result<Vec<Resolved<Parameter>>> {
299 let mut resolved = Vec::new();
300 for parameter in operation.parameters.iter().chain(shared_params) {
301 let entry = match parameter {
302 ReferenceOr::Item(param) => Resolved {
303 value: param.clone(),
304 origin: None,
305 },
306 ReferenceOr::Reference { reference } => self.spec.resolve_parameter(reference)?,
307 };
308 resolved.push(entry);
309 }
310 return Ok(resolved);
311 }
312
313 fn lower_path_params(&self, path: &str, method: &str, params: &[Resolved<Parameter>]) -> Result<Vec<Param>> {
316 let placeholders = path_param_names(path);
317 let mut path_params = Vec::new();
318 for name in &placeholders {
319 let declared = path_param_schema(name, params);
320 let ty = match declared {
321 Some((format, origin)) => self.param_type(path, method, name, origin, format)?,
322 None => {
323 return Err(Error::UndeclaredPathParameter {
324 method: method.to_owned(),
325 path: path.to_owned(),
326 name: name.clone(),
327 });
328 }
329 };
330 path_params.push(Param {
331 name: to_ident(name, Case::Snake),
332 ty,
333 });
334 }
335 let mut seen = Vec::new();
340 for parameter in params {
341 let Parameter::Path { parameter_data, style } = ¶meter.value else {
342 continue;
343 };
344 if !placeholders
345 .iter()
346 .any(|placeholder| return placeholder == ¶meter_data.name)
347 {
348 return Err(Error::InvalidPathParameter {
349 method: method.to_owned(),
350 path: path.to_owned(),
351 name: parameter_data.name.clone(),
352 });
353 }
354 if seen.contains(¶meter_data.name.as_str()) {
355 continue;
356 }
357 seen.push(parameter_data.name.as_str());
358 if !matches!(style, PathStyle::Simple) {
359 self.warn(
360 parameter.origin.as_deref(),
361 &format!("{method} {path}"),
362 format!(
363 "path parameter `{}` uses unsupported style `{style:?}`. Generated code uses `simple` encoding",
364 parameter_data.name
365 ),
366 );
367 }
368 }
369 return Ok(path_params);
370 }
371
372 fn lower_query_params(
378 &self,
379 path: &str,
380 method: &str,
381 params: &[Resolved<Parameter>],
382 operation_name: &RustIdent,
383 ) -> Result<Option<Struct>> {
384 let owner = operations::query_struct_name(operation_name);
385 let mut fields = Vec::new();
386 let mut seen: Vec<&str> = Vec::new();
387 for parameter in params {
388 let Parameter::Query {
389 parameter_data, style, ..
390 } = ¶meter.value
391 else {
392 continue;
393 };
394 if seen.contains(¶meter_data.name.as_str()) {
395 continue;
396 }
397 seen.push(¶meter_data.name);
398 fields.push(self.query_field(path, method, parameter.origin.as_deref(), parameter_data, style, &owner)?);
399 }
400 if fields.is_empty() {
401 return Ok(None);
402 }
403 let name = owner;
404 return Ok(Some(Struct {
405 name,
406 doc: None,
407 deprecated: None,
408 fields,
409 additional_properties: None,
410 deny_unknown_fields: false,
415 }));
416 }
417
418 fn query_field(
421 &self,
422 path: &str,
423 method: &str,
424 origin: Option<&str>,
425 data: &ParameterData,
426 style: &QueryStyle,
427 owner: &RustIdent,
428 ) -> Result<Field> {
429 let schema = self.query_param_schema(path, method, origin, data)?;
430 let mut ty = self.query_param_type(path, method, origin, data, style, &schema)?;
431
432 let declared = match data.required {
434 true => None,
435 false => schema.schema_data.default.clone(),
436 };
437 if !data.required && declared.is_none() {
438 ty = ty.optional();
439 }
440 let default = match &declared {
443 Some(json) => Some(lower_default(json, &ty, &|_| return None, owner.logical(), &data.name)?),
444 None => None,
445 };
446
447 let ident = to_ident(&data.name, Case::Snake);
448 let rename = crate::naming::rename_for(&data.name, &ident);
449 let constraints = crate::lower::constraints::constraints_of(&schema);
452 let field = Field {
453 name: ident,
454 rename,
455 doc: data.description.as_deref().and_then(trimmed),
456 deprecated: None,
457 ty,
458 required: data.required,
459 omit_empty: None,
460 serde_skip: false,
461 default,
462 constraints,
463 access: crate::ir::Access::ReadWrite,
466 };
467 crate::lower::constraints::check_constraints(&field)?;
468 return Ok(field);
469 }
470
471 fn query_param_schema(
477 &self,
478 path: &str,
479 method: &str,
480 origin: Option<&str>,
481 data: &ParameterData,
482 ) -> Result<Schema> {
483 let ParameterSchemaOrContent::Schema(schema) = &data.format else {
484 return Err(Error::UnsupportedOperation {
485 method: method.to_owned(),
486 path: path.to_owned(),
487 reason: format!("query parameter `{}` uses `content`, which is not supported", data.name),
488 });
489 };
490 return self.resolve_param_schema(path, method, origin, &data.name, schema);
491 }
492
493 fn query_param_type(
500 &self,
501 path: &str,
502 method: &str,
503 origin: Option<&str>,
504 data: &ParameterData,
505 style: &QueryStyle,
506 schema: &Schema,
507 ) -> Result<RustType> {
508 let name = data.name.as_str();
509 let explode = data.explode;
510 if let SchemaKind::Type(Type::Array(array)) = &schema.schema_kind {
511 if !matches!(style, QueryStyle::Form) || explode == Some(false) {
512 return Err(Error::UnsupportedOperation {
513 method: method.to_owned(),
514 path: path.to_owned(),
515 reason: format!(
516 "query parameter `{name}` uses a non-default array encoding; only `style: form` with `explode: true` (repeated keys) is supported"
517 ),
518 });
519 }
520 let element = match &array.items {
521 Some(ReferenceOr::Item(item)) => {
522 self.warn_scalar_enum(
523 origin,
524 &format!("{method} {path} query parameter `{name}` items"),
525 &item.schema_kind,
526 );
527 scalar_type(&item.schema_kind)
528 }
529 Some(ReferenceOr::Reference { reference }) if ref_file_part(reference).is_some() => {
530 return Err(Error::UnsupportedOperation {
531 method: method.to_owned(),
532 path: path.to_owned(),
533 reason: format!(
534 "query parameter `{name}` uses array items via a cross-file `$ref`, which is not supported"
535 ),
536 });
537 }
538 Some(ReferenceOr::Reference { reference }) => {
539 let item = self.spec.resolve_schema(origin, reference)?;
540 self.warn_scalar_enum(
541 origin,
542 &format!("{method} {path} query parameter `{name}` items"),
543 &item.schema_kind,
544 );
545 scalar_type(&item.schema_kind)
546 }
547 None => {
548 return Err(Error::UnsupportedOperation {
549 method: method.to_owned(),
550 path: path.to_owned(),
551 reason: format!("query parameter `{name}` is an array without `items`"),
552 });
553 }
554 };
555 let element = element.ok_or_else(|| {
556 return Error::UnsupportedOperation {
557 method: method.to_owned(),
558 path: path.to_owned(),
559 reason: format!("query parameter `{name}` must be an array of scalars"),
560 };
561 })?;
562 return Ok(RustType::Vec(Box::new(element)));
563 }
564 let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
565 return Error::UnsupportedOperation {
566 method: method.to_owned(),
567 path: path.to_owned(),
568 reason: format!("query parameter `{name}` must be a scalar or an array of scalars"),
569 };
570 })?;
571 self.warn_scalar_enum(
572 origin,
573 &format!("{method} {path} query parameter `{name}`"),
574 &schema.schema_kind,
575 );
576 if !matches!(style, QueryStyle::Form) {
577 self.warn(
578 origin,
579 &format!("{method} {path}"),
580 format!(
581 "query parameter `{name}` uses unsupported scalar style `{style:?}`. Generated code uses `form` encoding"
582 ),
583 );
584 }
585 return Ok(ty);
586 }
587
588 fn resolve_param_schema(
593 &self,
594 path: &str,
595 method: &str,
596 origin: Option<&str>,
597 name: &str,
598 schema: &ReferenceOr<Schema>,
599 ) -> Result<Schema> {
600 match schema {
601 ReferenceOr::Item(schema) => return Ok(schema.clone()),
602 ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
603 return Err(Error::UnsupportedOperation {
604 method: method.to_owned(),
605 path: path.to_owned(),
606 reason: format!("query parameter `{name}` uses a cross-file `$ref`, which is not supported"),
607 });
608 }
609 ReferenceOr::Reference { reference } => return self.spec.resolve_schema(origin, reference),
610 }
611 }
612
613 fn lower_header_params(
619 &self,
620 path: &str,
621 method: &str,
622 params: &[Resolved<Parameter>],
623 operation_name: &RustIdent,
624 ) -> Result<Option<Headers>> {
625 let mut header_params = Vec::new();
626 let mut seen: Vec<&str> = Vec::new();
627 for parameter in params {
628 let Parameter::Header { parameter_data, .. } = ¶meter.value else {
629 continue;
630 };
631 let name = parameter_data.name.as_str();
632 if IGNORED_HEADER_NAMES
633 .iter()
634 .any(|ignored| return ignored.eq_ignore_ascii_case(name))
635 {
636 continue;
637 }
638 if seen.iter().any(|other| return other.eq_ignore_ascii_case(name)) {
639 continue;
640 }
641 seen.push(name);
642 header_params.push(self.header_param(path, method, parameter.origin.as_deref(), parameter_data)?);
643 }
644 if header_params.is_empty() {
645 return Ok(None);
646 }
647 let name = operations::headers_struct_name(operation_name);
648 return Ok(Some(Headers {
649 name,
650 params: header_params,
651 }));
652 }
653
654 fn header_param(
657 &self,
658 path: &str,
659 method: &str,
660 origin: Option<&str>,
661 data: &ParameterData,
662 ) -> Result<HeaderParam> {
663 let ty = self.header_param_type(path, method, origin, &data.name, &data.format)?;
664 return Ok(HeaderParam {
665 name: to_ident(&data.name, Case::Snake),
666 header_name: data.name.clone(),
667 ty,
668 required: data.required,
669 doc: data.description.as_deref().and_then(trimmed),
670 });
671 }
672
673 fn scalar_from_format(
679 &self,
680 path: &str,
681 method: &str,
682 origin: Option<&str>,
683 kind_label: &str,
684 name: &str,
685 format: &ParameterSchemaOrContent,
686 ) -> Result<RustType> {
687 let schema = match format {
688 ParameterSchemaOrContent::Schema(schema) => schema,
689 ParameterSchemaOrContent::Content(_) => {
690 return Err(Error::UnsupportedOperation {
691 method: method.to_owned(),
692 path: path.to_owned(),
693 reason: format!("{kind_label} `{name}` uses `content`, which is not supported"),
694 });
695 }
696 };
697 let schema = match schema {
698 ReferenceOr::Item(schema) => schema.clone(),
699 ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
700 return Err(Error::UnsupportedOperation {
701 method: method.to_owned(),
702 path: path.to_owned(),
703 reason: format!("{kind_label} `{name}` uses a cross-file `$ref`, which is not supported"),
704 });
705 }
706 ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
707 };
708 let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
709 return Error::UnsupportedOperation {
710 method: method.to_owned(),
711 path: path.to_owned(),
712 reason: format!("{kind_label} `{name}` must be a scalar"),
713 };
714 })?;
715 if matches!(ty, RustType::Bytes) {
716 return Err(Error::UnsupportedOperation {
717 method: method.to_owned(),
718 path: path.to_owned(),
719 reason: format!("{kind_label} `{name}` uses a `byte`/`binary` format, which is not supported"),
720 });
721 }
722 self.warn_scalar_enum(
723 origin,
724 &format!("{method} {path} {kind_label} `{name}`"),
725 &schema.schema_kind,
726 );
727 return Ok(ty);
728 }
729
730 fn header_param_type(
734 &self,
735 path: &str,
736 method: &str,
737 origin: Option<&str>,
738 name: &str,
739 format: &ParameterSchemaOrContent,
740 ) -> Result<RustType> {
741 return self.scalar_from_format(path, method, origin, "header parameter", name, format);
742 }
743
744 fn lower_cookie_params(
749 &self,
750 path: &str,
751 method: &str,
752 params: &[Resolved<Parameter>],
753 operation_name: &RustIdent,
754 ) -> Result<Option<Cookies>> {
755 let mut cookie_params = Vec::new();
756 let mut seen: Vec<&str> = Vec::new();
757 for parameter in params {
758 let Parameter::Cookie { parameter_data, .. } = ¶meter.value else {
759 continue;
760 };
761 let name = parameter_data.name.as_str();
762 if seen.contains(&name) {
763 continue;
764 }
765 seen.push(name);
766 let ty = self.cookie_param_type(
767 path,
768 method,
769 parameter.origin.as_deref(),
770 ¶meter_data.name,
771 ¶meter_data.format,
772 )?;
773 cookie_params.push(CookieParam {
774 name: to_ident(¶meter_data.name, Case::Snake),
775 cookie_name: parameter_data.name.clone(),
776 ty,
777 required: parameter_data.required,
778 doc: parameter_data.description.as_deref().and_then(trimmed),
779 });
780 }
781 if cookie_params.is_empty() {
782 return Ok(None);
783 }
784 let name = operations::cookies_struct_name(operation_name);
785 return Ok(Some(Cookies {
786 name,
787 params: cookie_params,
788 }));
789 }
790
791 fn cookie_param_type(
795 &self,
796 path: &str,
797 method: &str,
798 origin: Option<&str>,
799 name: &str,
800 format: &ParameterSchemaOrContent,
801 ) -> Result<RustType> {
802 let schema = match format {
803 ParameterSchemaOrContent::Schema(schema) => schema,
804 ParameterSchemaOrContent::Content(_) => {
805 return Err(Error::UnsupportedOperation {
806 method: method.to_owned(),
807 path: path.to_owned(),
808 reason: format!("cookie parameter `{name}` uses `content`, which is not supported"),
809 });
810 }
811 };
812 let schema = match schema {
813 ReferenceOr::Item(schema) => schema.clone(),
814 ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
815 return Err(Error::UnsupportedOperation {
816 method: method.to_owned(),
817 path: path.to_owned(),
818 reason: format!("cookie parameter `{name}` uses a cross-file `$ref`, which is not supported"),
819 });
820 }
821 ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
822 };
823 let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
824 return Error::UnsupportedOperation {
825 method: method.to_owned(),
826 path: path.to_owned(),
827 reason: format!("cookie parameter `{name}` must be a scalar"),
828 };
829 })?;
830 if matches!(ty, RustType::Bytes) {
831 return Err(Error::UnsupportedOperation {
832 method: method.to_owned(),
833 path: path.to_owned(),
834 reason: format!("cookie parameter `{name}` uses a `byte`/`binary` format, which is not supported"),
835 });
836 }
837 self.warn_scalar_enum(
838 origin,
839 &format!("{method} {path} cookie parameter `{name}`"),
840 &schema.schema_kind,
841 );
842 return Ok(ty);
843 }
844
845 fn warn_scalar_enum(&self, origin: Option<&str>, context: &str, kind: &SchemaKind) {
846 let has_enum = match kind {
847 SchemaKind::Type(Type::String(schema)) => !schema.enumeration.is_empty(),
848 SchemaKind::Type(Type::Integer(schema)) => !schema.enumeration.is_empty(),
849 _ => false,
850 };
851 if has_enum {
852 self.warn(
853 origin,
854 context,
855 "the declared `enum` is ignored. Generated code uses the base scalar type without enum validation"
856 .to_owned(),
857 );
858 }
859 }
860
861 fn supported_bodies<'m>(
869 &self,
870 content: &'m indexmap::IndexMap<String, openapiv3::MediaType>,
871 priority: &[BodyKind],
872 origin: Option<&str>,
873 context: &str,
874 ) -> Vec<(BodyKind, &'m openapiv3::MediaType)> {
875 let mut selected = Vec::new();
876 for &wanted in priority {
877 let mut first = None;
878 for (name, media) in content {
879 if media_type_kind(name) == Some(wanted) {
880 match first {
881 Some(first) => self.warn(
882 origin,
883 context,
884 format!("media type `{name}` is ignored because `{first}` is the first representation of the same body kind"),
885 ),
886 None => {
887 selected.push((wanted, media));
888 first = Some(name);
889 }
890 }
891 }
892 }
893 }
894 if !selected.is_empty() {
895 for name in content.keys() {
896 if !media_type_kind(name).is_some_and(|kind| return priority.contains(&kind)) {
897 self.warn(origin, context, format!("unsupported media type `{name}` is ignored"));
898 }
899 }
900 }
901 return selected;
902 }
903
904 fn warn(&self, origin: Option<&str>, context: &str, message: String) {
905 let document = self.spec.source().display().to_string();
906 let message = match origin {
907 Some(origin) => format!("{message} (resolved from `{origin}`)"),
908 None => message,
909 };
910 report_warnings(&document, &[Warning::new(context, message)]);
911 }
912
913 fn body_from_media(
917 &self,
918 path: &str,
919 method: &str,
920 origin: Option<&str>,
921 kind: BodyKind,
922 media: &openapiv3::MediaType,
923 ) -> Result<Option<Body>> {
924 let schema = match &media.schema {
925 Some(schema) => schema,
926 None => return Ok(None),
927 };
928 let ty = match kind {
929 BodyKind::Json => self.body_type(path, method, origin, schema)?,
930 BodyKind::Text => {
931 let resolved = match schema {
932 ReferenceOr::Item(schema) => schema.clone(),
933 ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
934 };
935 if !matches!(resolved.schema_kind, SchemaKind::Type(Type::String(_))) {
936 return Err(Error::UnsupportedOperation {
937 method: method.to_owned(),
938 path: path.to_owned(),
939 reason: "text/plain body must be a `string` schema".to_owned(),
940 });
941 }
942 RustType::String
943 }
944 BodyKind::Form => match schema {
945 ReferenceOr::Reference { reference } => {
946 if ref_file_part(reference).is_none() {
954 let resolved = self.spec.resolve_schema(origin, reference)?;
955 if !matches!(resolved.schema_kind, SchemaKind::Type(Type::Object(_))) {
956 return Err(Error::UnsupportedOperation {
957 method: method.to_owned(),
958 path: path.to_owned(),
959 reason:
960 "form (`application/x-www-form-urlencoded`) body must reference an `object` schema"
961 .to_owned(),
962 });
963 }
964 }
965 self.schema_ref_type(path, method, origin, reference)?
966 }
967 ReferenceOr::Item(_) => {
968 return Err(Error::UnsupportedOperation {
969 method: method.to_owned(),
970 path: path.to_owned(),
971 reason: "form (`application/x-www-form-urlencoded`) body must reference a named object schema"
972 .to_owned(),
973 });
974 }
975 },
976 BodyKind::Multipart => {
980 return Err(Error::UnsupportedOperation {
981 method: method.to_owned(),
982 path: path.to_owned(),
983 reason: "multipart/form-data is only supported for request bodies".to_owned(),
984 });
985 }
986 };
987 return Ok(Some(Body { ty, kind }));
988 }
989}
990
991fn path_param_schema<'a>(
994 name: &str,
995 params: &'a [Resolved<Parameter>],
996) -> Option<(&'a ParameterSchemaOrContent, Option<&'a str>)> {
997 for parameter in params {
998 let Parameter::Path { parameter_data, .. } = ¶meter.value else {
999 continue;
1000 };
1001 if parameter_data.name == name {
1002 return Some((¶meter_data.format, parameter.origin.as_deref()));
1003 }
1004 }
1005 return None;
1006}
1007
1008fn body_kind_ident(kind: BodyKind) -> RustIdent {
1012 let name = match kind {
1013 BodyKind::Json => "Json",
1014 BodyKind::Form => "Form",
1015 BodyKind::Text => "Text",
1016 BodyKind::Multipart => "Multipart",
1017 };
1018 return to_ident(name, Case::Pascal);
1019}
1020
1021fn declared_content_types(content: &indexmap::IndexMap<String, openapiv3::MediaType>) -> String {
1024 return content.keys().cloned().collect::<Vec<_>>().join(", ");
1025}
1026
1027pub(crate) fn media_type_kind(name: &str) -> Option<BodyKind> {
1031 let base = name.split(';').next().unwrap_or(name).trim().to_ascii_lowercase();
1032 if base == "application/json" || base.ends_with("+json") {
1033 return Some(BodyKind::Json);
1034 }
1035 if base == "application/x-www-form-urlencoded" {
1036 return Some(BodyKind::Form);
1037 }
1038 if base == "multipart/form-data" {
1039 return Some(BodyKind::Multipart);
1040 }
1041 if base == "text/plain" {
1042 return Some(BodyKind::Text);
1043 }
1044 return None;
1045}
1046
1047fn scalar_type(kind: &SchemaKind) -> Option<RustType> {
1050 let ty = match kind {
1051 SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
1052 SchemaKind::Type(Type::Integer(it)) => integer_type(it),
1053 SchemaKind::Type(Type::Number(_)) => RustType::F64,
1054 SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
1055 _ => return None,
1056 };
1057 return Some(ty);
1058}
1059
1060impl Lowerer<'_> {
1061 fn param_type(
1069 &self,
1070 path: &str,
1071 method: &str,
1072 name: &str,
1073 origin: Option<&str>,
1074 format: &ParameterSchemaOrContent,
1075 ) -> Result<RustType> {
1076 let schema = match format {
1077 ParameterSchemaOrContent::Schema(schema) => schema,
1078 ParameterSchemaOrContent::Content(_) => {
1079 return Err(Error::UnsupportedOperation {
1080 method: method.to_owned(),
1081 path: path.to_owned(),
1082 reason: format!("path parameter `{name}` uses `content`, which is not supported"),
1083 });
1084 }
1085 };
1086 let schema = match schema {
1087 ReferenceOr::Item(schema) => schema.clone(),
1088 ReferenceOr::Reference { reference } if ref_file_part(reference).is_some() => {
1089 return Err(Error::UnsupportedOperation {
1090 method: method.to_owned(),
1091 path: path.to_owned(),
1092 reason: format!("path parameter `{name}` uses a cross-file `$ref`, which is not supported"),
1093 });
1094 }
1095 ReferenceOr::Reference { reference } => self.spec.resolve_schema(origin, reference)?,
1096 };
1097 let ty = scalar_type(&schema.schema_kind).ok_or_else(|| {
1098 return Error::UnsupportedOperation {
1099 method: method.to_owned(),
1100 path: path.to_owned(),
1101 reason: format!("path parameter `{name}` must be a scalar type"),
1102 };
1103 })?;
1104 self.warn_scalar_enum(
1105 origin,
1106 &format!("{method} {path} path parameter `{name}`"),
1107 &schema.schema_kind,
1108 );
1109 return Ok(ty);
1110 }
1111
1112 fn lower_request_body(
1122 &self,
1123 path: &str,
1124 method: &str,
1125 op_name: &RustIdent,
1126 operation: &OasOperation,
1127 ) -> Result<Option<RequestPayload>> {
1128 let body = match &operation.request_body {
1129 Some(body) => body,
1130 None => return Ok(None),
1131 };
1132 let (body, origin): (RequestBody, Option<String>) = match body {
1133 ReferenceOr::Item(body) => (body.clone(), None),
1134 ReferenceOr::Reference { reference } => {
1135 let resolved = self.spec.resolve_request_body(reference)?;
1136 (resolved.value, resolved.origin)
1137 }
1138 };
1139 let supported = self.supported_bodies(
1140 &body.content,
1141 &REQUEST_BODY_PRIORITY,
1142 origin.as_deref(),
1143 &format!("{method} {path} request body"),
1144 );
1145 if supported.is_empty() {
1146 if body.content.is_empty() {
1147 return Ok(None);
1148 }
1149 return Err(Error::UnsupportedContentType {
1150 method: method.to_owned(),
1151 path: path.to_owned(),
1152 location: "request body".to_owned(),
1153 declared: declared_content_types(&body.content),
1154 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(),
1155 });
1156 }
1157 let has_multipart = supported.iter().any(|(kind, _)| return *kind == BodyKind::Multipart);
1158 if has_multipart {
1159 if supported.len() > 1 {
1160 return Err(Error::UnsupportedOperation {
1161 method: method.to_owned(),
1162 path: path.to_owned(),
1163 reason: "multipart/form-data cannot be combined with other request content types".to_owned(),
1164 });
1165 }
1166 let Some(&(_, media)) = supported.first() else {
1167 unreachable!("multipart body content type count already validated to be exactly one");
1168 };
1169 let multipart = self.lower_multipart_body(path, method, op_name, origin.as_deref(), media)?;
1170 return Ok(Some(RequestPayload::Multipart(multipart)));
1171 }
1172 let mut variants = Vec::with_capacity(supported.len());
1173 for (kind, media) in supported {
1174 if let Some(body) = self.body_from_media(path, method, origin.as_deref(), kind, media)? {
1175 variants.push(BodyVariant {
1176 variant: body_kind_ident(kind),
1177 body,
1178 });
1179 }
1180 }
1181 if variants.len() == 1 {
1182 let Some(variant) = variants.pop() else {
1183 unreachable!("length checked to be 1 above");
1184 };
1185 return Ok(Some(RequestPayload::Single(variant.body)));
1186 }
1187 if variants.is_empty() {
1188 return Ok(None);
1189 }
1190 return Ok(Some(RequestPayload::Negotiated(NegotiatedBody {
1191 name: operations::request_body_enum_name(op_name),
1192 variants,
1193 })));
1194 }
1195
1196 fn lower_multipart_body(
1211 &self,
1212 path: &str,
1213 method: &str,
1214 op_name: &RustIdent,
1215 origin: Option<&str>,
1216 media: &openapiv3::MediaType,
1217 ) -> Result<Multipart> {
1218 let unsupported = |reason: String| {
1219 return Error::UnsupportedOperation {
1220 method: method.to_owned(),
1221 path: path.to_owned(),
1222 reason,
1223 };
1224 };
1225 let schema = media
1226 .schema
1227 .as_ref()
1228 .ok_or_else(|| return unsupported("multipart/form-data body must declare a schema".to_owned()))?;
1229 let object = self.multipart_object(path, method, origin, schema)?;
1230 let fields = self.lower_multipart_fields(path, method, &object)?;
1231 return Ok(Multipart {
1232 name: operations::multipart_struct_name(op_name),
1233 fields,
1234 });
1235 }
1236
1237 fn multipart_object(
1243 &self,
1244 path: &str,
1245 method: &str,
1246 origin: Option<&str>,
1247 schema: &ReferenceOr<Schema>,
1248 ) -> Result<ObjectType> {
1249 let reject = |reason: &str| {
1250 return Error::UnsupportedOperation {
1251 method: method.to_owned(),
1252 path: path.to_owned(),
1253 reason: reason.to_owned(),
1254 };
1255 };
1256 let not_object = "multipart/form-data body schema must be an `object`";
1257 let cross_file = "multipart/form-data body must be an inline object or a same-document `$ref`; cross-file/external multipart is unsupported";
1258 match schema {
1259 ReferenceOr::Item(item) => {
1260 if origin.is_some() {
1261 return Err(reject(cross_file));
1262 }
1263 match &item.schema_kind {
1264 SchemaKind::Type(Type::Object(object)) => return Ok(object.clone()),
1265 _ => return Err(reject(not_object)),
1266 }
1267 }
1268 ReferenceOr::Reference { reference } => {
1269 if origin.is_some() || ref_file_part(reference).is_some() {
1270 return Err(reject(cross_file));
1271 }
1272 let resolved = self.spec.resolve_schema(origin, reference)?;
1273 match &resolved.schema_kind {
1274 SchemaKind::Type(Type::Object(object)) => return Ok(object.clone()),
1275 _ => return Err(reject(not_object)),
1276 }
1277 }
1278 }
1279 }
1280
1281 fn lower_multipart_fields(&self, path: &str, method: &str, object: &ObjectType) -> Result<Vec<MultipartField>> {
1288 let mut fields = Vec::with_capacity(object.properties.len());
1289 for (wire_name, property) in &object.properties {
1290 let required = object.required.iter().any(|name| {
1291 return name == wire_name;
1292 });
1293 let wrapped = match property {
1297 ReferenceOr::Item(schema) => crate::loader::single_ref_member(schema).map(|reference| {
1298 return ReferenceOr::Reference {
1299 reference: reference.to_owned(),
1300 };
1301 }),
1302 ReferenceOr::Reference { .. } => None,
1303 };
1304 let wrapper = match (&wrapped, property) {
1310 (Some(_), ReferenceOr::Item(schema)) => Some(&schema.schema_data),
1311 _ => None,
1312 };
1313 if let (Some(wrapper), Some(ReferenceOr::Reference { reference })) = (wrapper, &wrapped)
1314 && ref_file_part(reference).is_some()
1315 && !multipart_part_is_sent(wrapper, path, method, wire_name)?
1316 {
1317 continue;
1318 }
1319 let (kind, nullable) = match wrapped.as_ref().unwrap_or(property) {
1320 ReferenceOr::Item(schema) => {
1321 if !multipart_part_is_sent(&schema.schema_data, path, method, wire_name)? {
1322 continue;
1323 }
1324 (schema.schema_kind.clone(), schema.schema_data.nullable)
1325 }
1326 ReferenceOr::Reference { reference } => {
1327 if ref_file_part(reference).is_some() {
1328 return Err(Error::UnsupportedOperation {
1329 method: method.to_owned(),
1330 path: path.to_owned(),
1331 reason: format!(
1332 "multipart field `{wire_name}` uses a cross-file `$ref`, which is not supported"
1333 ),
1334 });
1335 }
1336 let resolved = self.spec.resolve_schema(None, reference)?;
1337 let mut data = resolved.schema_data;
1338 if let Some(wrapper) = wrapper {
1339 data.nullable |= wrapper.nullable;
1340 data.read_only |= wrapper.read_only;
1341 data.write_only |= wrapper.write_only;
1342 }
1343 if !multipart_part_is_sent(&data, path, method, wire_name)? {
1344 continue;
1345 }
1346 (resolved.schema_kind, data.nullable)
1347 }
1348 };
1349 let ty = scalar_type(&kind).ok_or_else(|| {
1350 return Error::UnsupportedOperation {
1351 method: method.to_owned(),
1352 path: path.to_owned(),
1353 reason: format!(
1354 "multipart field `{wire_name}` must be a scalar or binary string; nested objects and arrays are not supported"
1355 ),
1356 };
1357 })?;
1358 fields.push(MultipartField {
1359 wire_name: wire_name.clone(),
1360 rust_name: to_ident(wire_name, Case::Snake),
1361 is_file: matches!(ty, RustType::Bytes),
1362 ty,
1363 optional: !required || nullable,
1364 });
1365 }
1366 return Ok(fields);
1367 }
1368
1369 fn lower_response_headers(
1373 &self,
1374 path: &str,
1375 method: &str,
1376 origin: Option<&str>,
1377 response: &OasResponse,
1378 ) -> Result<Vec<crate::ir::ResponseHeader>> {
1379 let mut headers = Vec::new();
1380 let mut seen: Vec<String> = Vec::new();
1381 let mut seen_idents: Vec<String> = Vec::new();
1382 for (header_name, header_ref) in &response.headers {
1383 let header = match header_ref {
1384 ReferenceOr::Item(header) => header,
1385 ReferenceOr::Reference { .. } => {
1386 return Err(Error::UnsupportedOperation {
1387 method: method.to_owned(),
1388 path: path.to_owned(),
1389 reason: format!("response header `{header_name}` uses a `$ref`, which is not supported"),
1390 });
1391 }
1392 };
1393 if seen.iter().any(|other| return other.eq_ignore_ascii_case(header_name)) {
1394 continue;
1395 }
1396 if !is_valid_header_name(header_name) {
1397 return Err(Error::UnsupportedOperation {
1398 method: method.to_owned(),
1399 path: path.to_owned(),
1400 reason: format!("response header `{header_name}` has an invalid header name"),
1401 });
1402 }
1403 let ident = to_ident(header_name, Case::Snake);
1404 if RESERVED_RESPONSE_FIELDS.contains(&ident.logical()) {
1409 return Err(Error::UnsupportedOperation {
1410 method: method.to_owned(),
1411 path: path.to_owned(),
1412 reason: format!(
1413 "response header `{header_name}` maps to the reserved Rust field name `{}`",
1414 ident.logical()
1415 ),
1416 });
1417 }
1418 if seen_idents.iter().any(|other| return other == ident.logical()) {
1423 return Err(Error::UnsupportedOperation {
1424 method: method.to_owned(),
1425 path: path.to_owned(),
1426 reason: format!(
1427 "response header `{header_name}` maps to the same Rust field name as another header (`{}`)",
1428 ident.logical()
1429 ),
1430 });
1431 }
1432 seen.push(header_name.clone());
1433 seen_idents.push(ident.logical().to_owned());
1434 let ty = self.scalar_from_format(path, method, origin, "response header", header_name, &header.format)?;
1435 headers.push(crate::ir::ResponseHeader {
1436 name: ident,
1437 header_name: header_name.clone(),
1438 ty,
1439 required: header.required,
1440 doc: header.description.as_deref().and_then(trimmed),
1441 });
1442 }
1443 return Ok(headers);
1444 }
1445
1446 fn lower_responses(
1452 &self,
1453 path: &str,
1454 method: &str,
1455 operation_name: &RustIdent,
1456 operation: &OasOperation,
1457 ) -> Result<Vec<ResponseCase>> {
1458 let mut cases = Vec::new();
1459 for (status_code, response) in &operation.responses.responses {
1460 let (status, variant) = match status_code {
1461 StatusCode::Code(code) => {
1462 let reason = HttpStatus::from_u16(*code).ok().and_then(|status| {
1463 return status.canonical_reason();
1464 });
1465 let reason = reason.ok_or_else(|| {
1466 return Error::UnsupportedOperation {
1467 method: method.to_owned(),
1468 path: path.to_owned(),
1469 reason: format!("status code `{code}` is not a recognised HTTP status"),
1470 };
1471 })?;
1472 (ResponseStatus::Fixed(*code), to_ident(reason, Case::Pascal))
1473 }
1474 StatusCode::Range(range) => {
1475 if !(1..=5).contains(range) {
1476 return Err(Error::UnsupportedOperation {
1477 method: method.to_owned(),
1478 path: path.to_owned(),
1479 reason: format!("response range `{range}XX` is not a valid HTTP status class"),
1480 });
1481 }
1482 let variant = to_ident(&format!("status_{range}xx"), Case::Pascal);
1483 let Ok(range) = u8::try_from(*range) else {
1484 unreachable!("range checked to be within 1..=5 above");
1485 };
1486 (ResponseStatus::Range(range), variant)
1487 }
1488 };
1489 let response = self.resolve_response_ref(response)?;
1490 let location = format!("`{status_code}` response");
1493 let body = self.response_body(path, method, &location, response.origin.as_deref(), &response.value)?;
1494 let body = self.name_response_body(operation_name, &variant, body);
1495 let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?;
1496 cases.push(ResponseCase {
1497 variant,
1498 status,
1499 body,
1500 headers,
1501 doc: trimmed(&response.value.description),
1502 });
1503 }
1504
1505 if let Some(default) = &operation.responses.default {
1506 let response = self.resolve_response_ref(default)?;
1507 let variant = to_ident("default", Case::Pascal);
1508 let body = self.response_body(
1509 path,
1510 method,
1511 "`default` response",
1512 response.origin.as_deref(),
1513 &response.value,
1514 )?;
1515 let body = self.name_response_body(operation_name, &variant, body);
1516 let headers = self.lower_response_headers(path, method, response.origin.as_deref(), &response.value)?;
1517 cases.push(ResponseCase {
1518 variant,
1519 status: ResponseStatus::Default,
1520 body,
1521 headers,
1522 doc: trimmed(&response.value.description),
1523 });
1524 }
1525
1526 if cases.is_empty() {
1527 return Err(Error::UnsupportedOperation {
1528 method: method.to_owned(),
1529 path: path.to_owned(),
1530 reason: "operation declares no responses".to_owned(),
1531 });
1532 }
1533 return Ok(cases);
1534 }
1535
1536 fn resolve_response_ref(&self, response: &ReferenceOr<OasResponse>) -> Result<Resolved<OasResponse>> {
1539 match response {
1540 ReferenceOr::Item(response) => {
1541 return Ok(Resolved {
1542 value: response.clone(),
1543 origin: None,
1544 });
1545 }
1546 ReferenceOr::Reference { reference } => return self.spec.resolve_response(reference),
1547 }
1548 }
1549
1550 fn response_body(
1563 &self,
1564 path: &str,
1565 method: &str,
1566 location: &str,
1567 origin: Option<&str>,
1568 response: &OasResponse,
1569 ) -> Result<Option<LoweredResponseBody>> {
1570 let supported = self.supported_bodies(
1571 &response.content,
1572 &RESPONSE_BODY_PRIORITY,
1573 origin,
1574 &format!("{method} {path} {location}"),
1575 );
1576 if supported.is_empty() && !response.content.is_empty() {
1577 return Err(Error::UnsupportedContentType {
1578 method: method.to_owned(),
1579 path: path.to_owned(),
1580 location: location.to_owned(),
1581 declared: declared_content_types(&response.content),
1582 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(),
1583 });
1584 }
1585 let mut variants = Vec::with_capacity(supported.len());
1586 for (kind, media) in supported {
1587 if let Some(body) = self.body_from_media(path, method, origin, kind, media)? {
1588 variants.push(BodyVariant {
1589 variant: body_kind_ident(kind),
1590 body,
1591 });
1592 }
1593 }
1594 if variants.len() == 1 {
1595 let Some(variant) = variants.pop() else {
1596 unreachable!("length checked to be 1 above");
1597 };
1598 return Ok(Some(LoweredResponseBody::Single(variant.body)));
1599 }
1600 if variants.is_empty() {
1601 return Ok(None);
1602 }
1603 return Ok(Some(LoweredResponseBody::Negotiated(variants)));
1604 }
1605
1606 fn name_response_body(
1610 &self,
1611 operation_name: &RustIdent,
1612 variant: &RustIdent,
1613 lowered: Option<LoweredResponseBody>,
1614 ) -> Option<ResponseBody> {
1615 return lowered.map(|body| {
1616 return match body {
1617 LoweredResponseBody::Single(body) => ResponseBody::Single(body),
1618 LoweredResponseBody::Negotiated(variants) => ResponseBody::Negotiated(NegotiatedBody {
1619 name: operations::response_body_enum_name(operation_name, variant),
1620 variants,
1621 }),
1622 };
1623 });
1624 }
1625
1626 fn body_type(
1631 &self,
1632 path: &str,
1633 method: &str,
1634 origin: Option<&str>,
1635 schema: &ReferenceOr<Schema>,
1636 ) -> Result<RustType> {
1637 match schema {
1638 ReferenceOr::Reference { reference } => return self.schema_ref_type(path, method, origin, reference),
1639 ReferenceOr::Item(schema) => return self.inline_body_type(path, method, origin, schema),
1640 }
1641 }
1642
1643 fn inline_body_type(&self, path: &str, method: &str, origin: Option<&str>, schema: &Schema) -> Result<RustType> {
1645 let ty = match &schema.schema_kind {
1646 SchemaKind::Type(Type::String(st)) => string_format_type(&st.format),
1647 SchemaKind::Type(Type::Integer(it)) => integer_type(it),
1648 SchemaKind::Type(Type::Number(_)) => RustType::F64,
1649 SchemaKind::Type(Type::Boolean(_)) => RustType::Bool,
1650 SchemaKind::Type(Type::Array(at)) => {
1651 let element = match &at.items {
1652 Some(ReferenceOr::Reference { reference }) => {
1653 self.schema_ref_type(path, method, origin, reference)?
1654 }
1655 Some(ReferenceOr::Item(item)) => self.inline_body_type(path, method, origin, item)?,
1656 None => RustType::Value,
1657 };
1658 RustType::Vec(Box::new(element))
1659 }
1660 SchemaKind::Type(Type::Object(object)) if object.properties.is_empty() => {
1661 let value = match &object.additional_properties {
1662 Some(openapiv3::AdditionalProperties::Schema(value)) => {
1663 self.body_type(path, method, origin, value)?
1664 }
1665 Some(openapiv3::AdditionalProperties::Any(true)) | None => RustType::Value,
1666 Some(openapiv3::AdditionalProperties::Any(false)) => {
1667 return Err(Error::UnsupportedOperation {
1668 method: method.to_owned(),
1669 path: path.to_owned(),
1670 reason: "closed object bodies must reference a named schema (`$ref`)".to_owned(),
1671 });
1672 }
1673 };
1674 RustType::Map(Box::new(value))
1675 }
1676 SchemaKind::Any(_) => RustType::Value,
1677 SchemaKind::AllOf { all_of } => {
1678 let [member] = all_of.as_slice() else {
1679 return Err(Error::UnsupportedOperation {
1680 method: method.to_owned(),
1681 path: path.to_owned(),
1682 reason: "composite request/response bodies must reference a named schema (`$ref`)".to_owned(),
1683 });
1684 };
1685 self.body_type(path, method, origin, member)?
1686 }
1687 _ => {
1688 return Err(Error::UnsupportedOperation {
1689 method: method.to_owned(),
1690 path: path.to_owned(),
1691 reason: "composite request/response bodies must reference a named schema (`$ref`)".to_owned(),
1692 });
1693 }
1694 };
1695 return Ok(if schema.schema_data.nullable {
1696 crate::lower::schema::nullable_type(self.spec, ty)
1697 } else {
1698 ty
1699 });
1700 }
1701
1702 fn schema_ref_type(&self, path: &str, method: &str, origin: Option<&str>, reference: &str) -> Result<RustType> {
1708 let target = ref_component_name(reference, "schemas").ok_or_else(|| {
1709 return Error::UnsupportedOperation {
1710 method: method.to_owned(),
1711 path: path.to_owned(),
1712 reason: format!("reference `{reference}` must point at a component schema"),
1713 };
1714 })?;
1715 let file = ref_file_part(reference)
1716 .map(str::to_owned)
1717 .or_else(|| return origin.map(str::to_owned));
1718 let Some(file) = file else {
1719 if !self.spec.schemas().contains_key(target) {
1724 return Err(Error::UnresolvedRef(reference.to_owned()));
1725 }
1726 return Ok(RustType::Named(target.to_owned()));
1727 };
1728 let module = self.import_mapping.get(&file).ok_or_else(|| {
1729 return Error::UnsupportedOperation {
1730 method: method.to_owned(),
1731 path: path.to_owned(),
1732 reason: format!("cross-file reference `{reference}` needs an `import-mapping` entry for `{file}`"),
1733 };
1734 })?;
1735 return Ok(RustType::External {
1736 module: module.clone(),
1737 name: self.spec.external_schema_name(&file, target, reference)?,
1738 });
1739 }
1740}
1741
1742fn multipart_part_is_sent(data: &SchemaData, path: &str, method: &str, wire_name: &str) -> Result<bool> {
1748 let at = format!("{method} {path} multipart field `{wire_name}`");
1749 let access = crate::lower::schema::access_of(data, &at)?;
1750 return Ok(access.carried_by(crate::ir::Direction::Request));
1751}
1752
1753fn operation_name(path: &str, method: &str, operation: &OasOperation) -> Result<crate::naming::RustIdent> {
1761 let at = format!("{method} {path}");
1762 if let Some(name) = crate::lower::extension::str_value(&operation.extensions, X_RUST_NAME, &at)? {
1763 return Ok(operations::operation_method_name(name));
1764 }
1765 if let Some(id) = &operation.operation_id {
1766 return Ok(operations::operation_method_name(id));
1767 }
1768 return Ok(operations::operation_method_name(&at));
1769}
1770
1771fn operation_collision_hint(operation: &OasOperation) -> String {
1782 if operation.extensions.contains_key(X_RUST_NAME) {
1783 return format!(
1784 "This operation already sets `{X_RUST_NAME}`, and that name collides too. \
1785 Give it a name that no other operation uses."
1786 );
1787 }
1788 if operation.operation_id.is_some() {
1789 return format!(
1790 "Two `operationId`s that differ only in case or in punctuation produce one Rust name. \
1791 Give one of the two operations a different `operationId`, or set `{X_RUST_NAME}` on it \
1792 to name the generated method directly."
1793 );
1794 }
1795 return format!(
1796 "This operation declares no `operationId`, so its name comes from the method and the path. \
1797 Add an `operationId`, or set `{X_RUST_NAME}` on it to name the generated method directly."
1798 );
1799}
1800
1801fn operation_doc(operation: &OasOperation) -> Option<String> {
1803 if let Some(summary) = &operation.summary
1804 && let Some(text) = trimmed(summary)
1805 {
1806 return Some(text);
1807 }
1808 return operation.description.as_ref().and_then(|text| {
1809 return trimmed(text);
1810 });
1811}
1812
1813fn path_param_names(path: &str) -> Vec<String> {
1815 let mut names = Vec::new();
1816 let mut rest = path;
1817 while let Some(open) = rest.find('{') {
1818 let after_open = &rest[open + 1..];
1819 let Some(close) = after_open.find('}') else {
1820 break;
1821 };
1822 names.push(after_open[..close].to_owned());
1823 rest = &after_open[close + 1..];
1824 }
1825 return names;
1826}
1827
1828fn trimmed(text: &str) -> Option<String> {
1830 let trimmed = text.trim();
1831 if trimmed.is_empty() {
1832 return None;
1833 }
1834 return Some(trimmed.to_owned());
1835}
1836
1837#[cfg(test)]
1838mod tests {
1839 use super::*;
1840
1841 #[test]
1842 fn all_of_inline_json_body_maps_keep_value_types_and_nullability() {
1843 let document = serde_json::from_value(serde_json::json!({
1844 "openapi":"3.0.3", "info":{"title":"maps","version":"1"}, "paths":{}
1845 }))
1846 .expect("document");
1847 let spec = Spec::from_parts(document, "maps.yaml".into());
1848 let import_mapping = BTreeMap::new();
1849 let lowerer = Lowerer {
1850 spec: &spec,
1851 import_mapping: &import_mapping,
1852 response_type_suffix: "",
1853 };
1854 for (member, expected) in [
1855 (
1856 serde_json::json!({"type":"object","additionalProperties":{"type":"string"}}),
1857 RustType::String,
1858 ),
1859 (serde_json::json!({"type":"object"}), RustType::Value),
1860 (
1861 serde_json::json!({"type":"object","additionalProperties":true}),
1862 RustType::Value,
1863 ),
1864 ] {
1865 for nullable in [false, true] {
1866 let schema = serde_json::from_value(serde_json::json!({"nullable":nullable,"allOf":[member]}))
1867 .expect("map schema");
1868 let map = RustType::Map(Box::new(expected.clone()));
1869 let expected = if nullable {
1870 RustType::Nullable(Box::new(map))
1871 } else {
1872 map
1873 };
1874 assert_eq!(
1875 lowerer
1876 .inline_body_type("/maps", "post", None, &schema)
1877 .expect("map body"),
1878 expected
1879 );
1880 }
1881 }
1882 let closed = serde_json::from_value(serde_json::json!({
1883 "allOf":[{"type":"object","additionalProperties":false}]
1884 }))
1885 .expect("closed schema");
1886 let error = lowerer
1887 .inline_body_type("/maps", "post", None, &closed)
1888 .expect_err("closed inline body requires a named schema")
1889 .to_string();
1890 assert!(error.contains("closed object bodies"), "{error}");
1891 }
1892
1893 #[test]
1894 fn valid_header_names_accept_tokens_and_reject_separators() {
1895 assert!(is_valid_header_name("X-Request-Id"));
1897 assert!(is_valid_header_name("X-RateLimit-Remaining"));
1898 assert!(is_valid_header_name("Sec-CH-UA-Platform-Version"));
1899 assert!(is_valid_header_name("a.b"));
1900 assert!(!is_valid_header_name(""));
1904 assert!(!is_valid_header_name("X/Y"));
1905 assert!(!is_valid_header_name("X:Y"));
1906 assert!(!is_valid_header_name("X Y"));
1907 assert!(!is_valid_header_name("X(Y)"));
1908 }
1909
1910 #[test]
1911 fn extracts_path_param_names_in_order() {
1912 assert_eq!(path_param_names("/v1/widgets"), Vec::<String>::new());
1913 assert_eq!(path_param_names("/pets/{id}"), vec!["id".to_owned()]);
1914 assert_eq!(
1915 path_param_names("/orgs/{org}/pets/{petId}"),
1916 vec!["org".to_owned(), "petId".to_owned()],
1917 );
1918 }
1919
1920 #[test]
1921 fn response_variants_are_named_after_the_status_reason() {
1922 let variant = |code| {
1923 let reason = HttpStatus::from_u16(code)
1924 .expect("test status code is a valid HTTP status")
1925 .canonical_reason()
1926 .expect("status code has a canonical reason phrase");
1927 return to_ident(reason, Case::Pascal).logical().to_owned();
1928 };
1929 assert_eq!(variant(200), "Ok");
1930 assert_eq!(variant(204), "NoContent");
1931 assert_eq!(variant(404), "NotFound");
1932 assert_eq!(variant(500), "InternalServerError");
1933 }
1934
1935 #[test]
1936 fn range_response_variants_are_derived_from_the_range_digit() {
1937 let variant = |range: u16| {
1938 return to_ident(&format!("status_{range}xx"), Case::Pascal)
1939 .logical()
1940 .to_owned();
1941 };
1942 assert_eq!(variant(4), "Status4xx");
1943 assert_eq!(variant(5), "Status5xx");
1944 }
1945}