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