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