1use serde::Serialize;
4use serde::de::DeserializeOwned;
5use serde_json::{Value, json};
6use std::collections::BTreeMap;
7
8use mime::{
9 APPLICATION_JSON, APPLICATION_OCTET_STREAM, APPLICATION_WWW_FORM_URLENCODED,
10 MULTIPART_FORM_DATA, TEXT_EVENT_STREAM, TEXT_PLAIN_UTF_8,
11};
12
13use super::{
14 op::OpenApiOperation,
15 param::OpenApiParameter,
16 schema::{FieldConstraint, OpenApiSchema, Probe, Probed, ReadAsMap},
17};
18
19pub trait IntoStatusCode {
21 fn into_status_code(self) -> u16;
23}
24
25impl IntoStatusCode for u16 {
26 #[inline]
27 fn into_status_code(self) -> u16 {
28 self
29 }
30}
31
32impl IntoStatusCode for u32 {
33 #[inline]
34 fn into_status_code(self) -> u16 {
35 self as u16
36 }
37}
38
39impl IntoStatusCode for i32 {
40 #[inline]
41 fn into_status_code(self) -> u16 {
42 self as u16
43 }
44}
45
46impl IntoStatusCode for http::StatusCode {
47 #[inline]
48 fn into_status_code(self) -> u16 {
49 self.as_u16()
50 }
51}
52
53#[derive(Clone, Debug)]
54enum ResponseBody {
55 NoContent,
56 Content {
57 schema: Box<OpenApiSchema>,
58 example: Option<Value>,
59 content_type: String,
60 },
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum ConstraintTarget {
67 RequestBody,
69 QueryParameter {
72 described_before: usize,
74 },
75 PathParameter {
77 described_before: usize,
79 },
80}
81
82#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct UndescribedInput {
97 kind: InputKind,
98 type_name: &'static str,
99 read_as_map: Option<String>,
100}
101
102impl UndescribedInput {
103 fn new<T>(kind: InputKind, found: ReadAsMap) -> Self {
104 Self {
105 kind,
106 type_name: std::any::type_name::<T>(),
107 read_as_map: (!found.at_root).then_some(found.expecting),
108 }
109 }
110
111 pub fn kind(&self) -> InputKind {
113 self.kind
114 }
115
116 pub fn type_name(&self) -> &'static str {
118 self.type_name
119 }
120
121 pub fn read_as_map(&self) -> Option<&str> {
124 self.read_as_map.as_deref()
125 }
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum InputKind {
132 RequestBody,
134 QueryParameters,
136}
137
138#[derive(Clone, Debug, Default)]
140pub struct OpenApiRouteConfig {
141 tags: Vec<String>,
142 docs: Option<Vec<String>>,
143 summary: Option<String>,
144 description: Option<String>,
145 operation_id: Option<String>,
146 request_schema: Option<OpenApiSchema>,
147 request_example: Option<Value>,
148 request_content_type: Option<String>,
149 responses: BTreeMap<u16, ResponseBody>,
150 extra_parameters: Vec<OpenApiParameter>,
151 undescribed: Vec<UndescribedInput>,
152}
153
154impl OpenApiRouteConfig {
155 pub fn undescribed_inputs(&self) -> &[UndescribedInput] {
158 &self.undescribed
159 }
160
161 pub fn described_parameters(&self) -> usize {
166 self.extra_parameters.len()
167 }
168
169 pub(crate) fn docs(&self) -> Option<&[String]> {
171 self.docs.as_deref()
172 }
173
174 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
176 self.tags.push(tag.into());
177 self
178 }
179
180 pub fn with_tags<I, T>(mut self, tags: I) -> Self
182 where
183 I: IntoIterator<Item = T>,
184 T: Into<String>,
185 {
186 self.tags.extend(tags.into_iter().map(Into::into));
187 self
188 }
189
190 pub fn with_doc(mut self, doc: impl Into<String>) -> Self {
192 self.docs.get_or_insert_with(Vec::new).push(doc.into());
193 self
194 }
195
196 pub fn with_docs<I, S>(mut self, docs: I) -> Self
198 where
199 I: IntoIterator<Item = S>,
200 S: Into<String>,
201 {
202 self.docs
203 .get_or_insert_with(Vec::new)
204 .extend(docs.into_iter().map(Into::into));
205 self
206 }
207
208 pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
210 self.summary = Some(summary.into());
211 self
212 }
213
214 pub fn with_description(mut self, description: impl Into<String>) -> Self {
216 self.description = Some(description.into());
217 self
218 }
219
220 pub fn with_operation_id(mut self, operation_id: impl Into<String>) -> Self {
222 self.operation_id = Some(operation_id.into());
223 self
224 }
225
226 pub fn with_request_schema(mut self, schema: OpenApiSchema) -> Self {
231 self.request_schema = Some(schema);
232 self.undescribed
233 .retain(|input| input.kind != InputKind::RequestBody);
234 self
235 }
236
237 pub fn with_query_schema(mut self, schema: OpenApiSchema) -> Self {
245 self.undescribed
246 .retain(|input| input.kind != InputKind::QueryParameters);
247 self.with_query_parameters(schema)
248 }
249
250 pub fn with_response_schema(
252 mut self,
253 status: impl IntoStatusCode,
254 schema: OpenApiSchema,
255 ) -> Self {
256 let status = status.into_status_code();
257 self.responses.insert(
258 status,
259 ResponseBody::Content {
260 schema: Box::new(schema),
261 example: None,
262 content_type: APPLICATION_JSON.to_string(),
263 },
264 );
265 self
266 }
267
268 pub fn consumes_named_path<T: DeserializeOwned>(self) -> Self {
270 self.with_named_path_parameters_from_deserialize::<T>()
271 }
272
273 pub fn consumes_query<T: DeserializeOwned>(self) -> Self {
275 self.with_query_parameters_from_deserialize::<T>()
276 }
277
278 pub fn consumes_json<T: DeserializeOwned>(self) -> Self {
280 self.with_request_type_from_deserialize::<T>(APPLICATION_JSON.as_ref())
281 }
282
283 pub fn consumes_form<T: DeserializeOwned>(self) -> Self {
285 self.with_request_type_from_deserialize::<T>(APPLICATION_WWW_FORM_URLENCODED.as_ref())
286 }
287
288 pub fn consumes_multipart(self) -> Self {
290 self.with_multipart_request()
291 }
292
293 pub fn consumes_stream(self) -> Self {
295 self.with_stream_request()
296 }
297
298 pub fn produces_text(mut self, status: impl IntoStatusCode) -> Self {
300 let status = status.into_status_code();
301 self.responses.insert(
302 status,
303 ResponseBody::Content {
304 schema: Box::new(OpenApiSchema::string()),
305 example: None,
306 content_type: TEXT_PLAIN_UTF_8.to_string(),
307 },
308 );
309 self
310 }
311
312 pub fn produces_no_schema(mut self, status: impl IntoStatusCode) -> Self {
314 let status = status.into_status_code();
315 self.responses.insert(status, ResponseBody::NoContent);
316 self
317 }
318
319 pub fn produces_json<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
321 self.produces_json_example(status, T::default())
322 }
323
324 pub fn produces_empty_json(mut self, status: impl IntoStatusCode) -> Self {
326 let status = status.into_status_code();
327 self.responses.insert(
328 status,
329 ResponseBody::Content {
330 schema: Box::new(OpenApiSchema::object()),
331 example: None,
332 content_type: APPLICATION_JSON.to_string(),
333 },
334 );
335 self
336 }
337
338 pub fn produces_form<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
340 self.produces_form_example(status, T::default())
341 }
342
343 pub fn produces_empty_form(mut self, status: impl IntoStatusCode) -> Self {
345 let status = status.into_status_code();
346 self.responses.insert(
347 status,
348 ResponseBody::Content {
349 schema: Box::new(OpenApiSchema::object()),
350 example: None,
351 content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
352 },
353 );
354 self
355 }
356
357 pub fn produces_multipart(mut self, status: impl IntoStatusCode) -> Self {
359 let status = status.into_status_code();
360 self.responses.insert(
361 status,
362 ResponseBody::Content {
363 schema: Box::new(OpenApiSchema::multipart()),
364 example: None,
365 content_type: MULTIPART_FORM_DATA.to_string(),
366 },
367 );
368 self
369 }
370
371 pub fn produces_stream(mut self, status: impl IntoStatusCode) -> Self {
373 let status = status.into_status_code();
374 self.responses.insert(
375 status,
376 ResponseBody::Content {
377 schema: Box::new(OpenApiSchema::binary()),
378 example: None,
379 content_type: APPLICATION_OCTET_STREAM.to_string(),
380 },
381 );
382 self
383 }
384
385 pub fn produces_sse(mut self, status: impl IntoStatusCode) -> Self {
387 let status = status.into_status_code();
388 self.responses.insert(
389 status,
390 ResponseBody::Content {
391 schema: Box::new(OpenApiSchema::string().with_title("SSE stream")),
392 example: None,
393 content_type: TEXT_EVENT_STREAM.to_string(),
394 },
395 );
396 self
397 }
398
399 pub fn produces_json_example<T: Serialize>(
401 mut self,
402 status: impl IntoStatusCode,
403 example: T,
404 ) -> Self {
405 let status = status.into_status_code();
406 let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
407 self.responses.insert(
408 status,
409 ResponseBody::Content {
410 schema: Box::new(OpenApiSchema::from_example(&example)),
411 example: Some(example),
412 content_type: APPLICATION_JSON.to_string(),
413 },
414 );
415 self
416 }
417
418 pub fn produces_form_example<T: Serialize>(
420 mut self,
421 status: impl IntoStatusCode,
422 example: T,
423 ) -> Self {
424 let status = status.into_status_code();
425 let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
426
427 let encoded = match &example {
428 Value::Object(map) => serde_urlencoded::to_string(map).unwrap_or_default(),
429 _ => String::new(),
430 };
431
432 self.responses.insert(
433 status,
434 ResponseBody::Content {
435 schema: Box::new(OpenApiSchema::string()),
436 example: Some(Value::String(encoded)),
437 content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
438 },
439 );
440 self
441 }
442
443 #[cfg(feature = "problem-details")]
447 pub fn produces_problem(mut self, status: impl IntoStatusCode) -> Self {
448 let status = status.into_status_code();
449 let title = http::StatusCode::from_u16(status)
450 .ok()
451 .and_then(|s| s.canonical_reason())
452 .unwrap_or("Error");
453 let example = json!({
454 "type": "https://tools.ietf.org/html/rfc9110",
455 "title": title,
456 "status": status
457 });
458 self.responses.insert(
459 status,
460 ResponseBody::Content {
461 schema: Box::new(OpenApiSchema::from_example(&example)),
462 example: Some(example),
463 content_type: "application/problem+json".to_string(),
464 },
465 );
466 self
467 }
468
469 #[cfg(feature = "problem-details")]
473 pub fn produces_problem_example<T: Serialize>(
474 mut self,
475 status: impl IntoStatusCode,
476 example: T,
477 ) -> Self {
478 let status = status.into_status_code();
479 let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
480 self.responses.insert(
481 status,
482 ResponseBody::Content {
483 schema: Box::new(OpenApiSchema::from_example(&example)),
484 example: Some(example),
485 content_type: "application/problem+json".to_string(),
486 },
487 );
488 self
489 }
490
491 fn with_multipart_request(mut self) -> Self {
493 self.request_schema
494 .get_or_insert_with(OpenApiSchema::multipart);
495 self.request_content_type = Some(MULTIPART_FORM_DATA.to_string());
496 self
497 }
498
499 fn with_stream_request(mut self) -> Self {
501 self.request_schema
502 .get_or_insert_with(OpenApiSchema::binary);
503 self.request_content_type = Some(APPLICATION_OCTET_STREAM.to_string());
504 self
505 }
506
507 fn with_query_parameter(mut self, name: String, schema: OpenApiSchema, required: bool) -> Self {
508 self.extra_parameters.push(OpenApiParameter {
509 name,
510 location: "query".to_string(),
511 required,
512 schema,
513 });
514 self
515 }
516
517 fn with_path_parameter(mut self, name: String, schema: OpenApiSchema) -> Self {
518 self.extra_parameters.push(OpenApiParameter {
519 name,
520 location: "path".to_string(),
521 required: true,
522 schema,
523 });
524 self
525 }
526
527 fn with_request_type_from_deserialize<T: DeserializeOwned>(
529 mut self,
530 content_type: &str,
531 ) -> Self {
532 match probe::<T>() {
533 Probed::Shape(schema, example) => {
534 self.request_schema = Some(schema.with_title(type_display_name::<T>()));
535 self.request_example = Some(example);
536 }
537 Probed::ReadAsMap(found) => {
538 let schema = if found.at_root {
543 OpenApiSchema::object()
544 } else {
545 OpenApiSchema::empty()
546 };
547 self.request_schema = Some(schema.with_title(type_display_name::<T>()));
548 self.undescribed
549 .push(UndescribedInput::new::<T>(InputKind::RequestBody, found));
550 }
551 Probed::Nothing => {}
552 }
553 self.request_content_type = Some(content_type.to_string());
554 self
555 }
556
557 pub fn with_constraints(
564 mut self,
565 target: ConstraintTarget,
566 constraints: &[FieldConstraint],
567 ) -> Self {
568 match target {
569 ConstraintTarget::RequestBody => {
570 if let Some(schema) = self.request_schema.as_mut() {
571 schema.apply_field_constraints(constraints);
572 }
573 }
574 ConstraintTarget::QueryParameter { described_before } => {
575 self.apply_to_parameters("query", described_before, constraints);
576 }
577 ConstraintTarget::PathParameter { described_before } => {
578 self.apply_to_parameters("path", described_before, constraints);
579 }
580 }
581 self
582 }
583
584 fn apply_to_parameters(
592 &mut self,
593 location: &str,
594 described_before: usize,
595 constraints: &[FieldConstraint],
596 ) {
597 let Some(described) = self.extra_parameters.get_mut(described_before..) else {
598 return;
599 };
600
601 for parameter in described
602 .iter_mut()
603 .filter(|parameter| parameter.location == location)
604 {
605 for constraint in constraints
607 .iter()
608 .filter(|constraint| constraint.field == parameter.name)
609 {
610 parameter.schema.apply_constraint(&constraint.constraint);
611 }
612 }
613 }
614
615 fn with_query_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
616 match probe::<T>() {
617 Probed::Shape(schema, _) => self.with_query_parameters(schema),
618 Probed::ReadAsMap(found) => {
619 self.undescribed.push(UndescribedInput::new::<T>(
620 InputKind::QueryParameters,
621 found,
622 ));
623 self
624 }
625 Probed::Nothing => self,
626 }
627 }
628
629 fn with_query_parameters(mut self, schema: OpenApiSchema) -> Self {
631 if let Some(properties) = schema.properties {
632 let required = schema.required.unwrap_or_default();
633
634 for (name, property_schema) in properties {
635 let is_required = required.iter().any(|f| f == &name);
636 self = self.with_query_parameter(name, property_schema, is_required);
637 }
638 }
639 self
640 }
641
642 fn with_named_path_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
646 if let Probed::Shape(schema, _) = probe::<T>()
647 && let Some(properties) = schema.properties
648 {
649 for (name, property_schema) in properties {
650 self = self.with_path_parameter(name, property_schema);
651 }
652 }
653 self
654 }
655
656 pub fn merge_outer(mut self, outer: &Self) -> Self {
662 let own_tags = std::mem::take(&mut self.tags);
663 let own_parameters = std::mem::take(&mut self.extra_parameters);
664
665 let mut merged = self.merge(outer);
666
667 merged.tags.extend(own_tags);
668 merged.extra_parameters.extend(own_parameters);
671 merged
672 }
673
674 pub fn merge(mut self, other: &Self) -> Self {
676 if self.tags.is_empty() {
677 self.tags = other.tags.clone();
678 } else {
679 self.tags.extend(other.tags.clone());
680 }
681 if self.summary.is_none() {
682 self.summary = other.summary.clone();
683 }
684 if self.description.is_none() {
685 self.description = other.description.clone();
686 }
687 if self.operation_id.is_none() {
688 self.operation_id = other.operation_id.clone();
689 }
690
691 let inherits_body = self.request_schema.is_none();
696 self.undescribed.extend(
697 other
698 .undescribed
699 .iter()
700 .filter(|input| match input.kind {
701 InputKind::RequestBody => inherits_body,
702 InputKind::QueryParameters => true,
703 })
704 .cloned(),
705 );
706
707 if inherits_body {
708 self.request_schema = other.request_schema.clone();
709 if self.request_example.is_none() {
710 self.request_example = other.request_example.clone();
711 }
712 }
713
714 if self.request_content_type.is_none() {
715 self.request_content_type = other.request_content_type.clone();
716 }
717
718 for (status, body) in &other.responses {
719 self.responses
720 .entry(*status)
721 .or_insert_with(|| body.clone());
722 }
723
724 if !other.extra_parameters.is_empty() {
725 self.extra_parameters.extend(other.extra_parameters.clone());
726 }
727
728 match (&mut self.docs, &other.docs) {
729 (None, Some(d)) => self.docs = Some(d.clone()),
730 (Some(dst), Some(src)) => {
731 for s in src {
732 if !dst.iter().any(|x| x == s) {
733 dst.push(s.clone());
734 }
735 }
736 }
737 _ => {}
738 }
739 self
740 }
741
742 pub(super) fn apply_to_operation(
743 &self,
744 operation: &mut OpenApiOperation,
745 schemas: &mut BTreeMap<String, OpenApiSchema>,
746 ) {
747 if let Some(summary) = &self.summary {
748 operation.summary = Some(summary.clone());
749 }
750 if let Some(description) = &self.description {
751 operation.description = Some(description.clone());
752 }
753 if let Some(operation_id) = &self.operation_id {
754 operation.operation_id = Some(operation_id.clone());
755 }
756
757 if !self.tags.is_empty() {
758 operation.tags = Some(self.tags.clone());
759 }
760
761 if self.request_schema.is_some() || self.request_example.is_some() {
762 let mut schema = self
763 .request_schema
764 .clone()
765 .unwrap_or_else(OpenApiSchema::object);
766 let example = self.request_example.clone();
767 let content_type = self
768 .request_content_type
769 .as_deref()
770 .unwrap_or(APPLICATION_JSON.as_ref());
771
772 schema = intern_schema_if_object_named(schema, schemas);
773 operation.set_request_body(schema, example, content_type);
774 }
775
776 if !self.responses.is_empty() {
777 operation.clear_all_responses();
778 for (status, body) in &self.responses {
779 match body {
780 ResponseBody::NoContent => {
781 operation.clear_response_body(*status);
782 }
783 ResponseBody::Content {
784 schema,
785 example,
786 content_type,
787 } => {
788 let schema = intern_schema_if_object_named((**schema).clone(), schemas);
789 operation.set_response_body(*status, schema, example.clone(), content_type);
790 }
791 }
792 }
793 }
794
795 if !self.extra_parameters.is_empty() {
796 let params = operation.parameters.get_or_insert_with(Vec::new);
797
798 for p in &self.extra_parameters {
799 if let Some(existing) = params
800 .iter_mut()
801 .find(|x| x.name == p.name && x.location == p.location)
802 {
803 *existing = p.clone();
804 } else {
805 params.push(p.clone());
806 }
807 }
808 }
809 }
810}
811
812fn intern_schema_if_object_named(
813 mut schema: OpenApiSchema,
814 schemas: &mut BTreeMap<String, OpenApiSchema>,
815) -> OpenApiSchema {
816 if schema.schema_ref.is_some() {
817 return schema;
818 }
819
820 let base = match schema.title.clone() {
821 Some(t) if !t.is_empty() => t,
822 _ => return schema,
823 };
824
825 if schema.properties.is_none() {
826 return schema;
827 }
828
829 let mut name = base.clone();
830 if schemas.contains_key(&name) {
831 let mut i = 2;
832 while schemas.contains_key(&format!("{base}_{i}")) {
833 i += 1;
834 }
835 name = format!("{base}_{i}");
836 }
837
838 schema.title = None;
839
840 schemas.entry(name.clone()).or_insert(schema);
841
842 OpenApiSchema::reference(&name)
843}
844
845fn probe<T: DeserializeOwned>() -> Probed {
846 let mut probe = Probe::new();
847 let _ = T::deserialize(&mut probe);
848 probe.finish()
849}
850
851fn type_display_name<T>() -> String {
852 std::any::type_name::<T>()
853 .rsplit("::")
854 .next()
855 .unwrap_or("Model")
856 .to_string()
857}
858
859#[cfg(test)]
860#[allow(unused)]
861mod tests {
862 use super::{ConstraintTarget, InputKind, IntoStatusCode, OpenApiRouteConfig, ResponseBody};
863 use crate::{
864 op::OpenApiOperation,
865 schema::{FieldConstraint, OpenApiSchema, SchemaConstraint},
866 };
867 use serde::{Deserialize, Serialize};
868 use serde_json::json;
869 use std::collections::BTreeMap;
870
871 #[derive(Deserialize)]
872 struct Payload {
873 name: String,
874 age: u64,
875 }
876
877 #[derive(Serialize, Default)]
878 struct ResponsePayload {
879 message: String,
880 }
881
882 #[derive(Deserialize)]
883 struct OptionalQuery {
884 required_name: String,
885 optional_age: Option<()>,
886 }
887
888 #[derive(Deserialize)]
889 #[allow(dead_code)]
890 struct Page {
891 page: u32,
892 }
893
894 #[test]
895 fn with_constraints_touches_only_the_parameter_its_extractor_added() {
896 let cfg = OpenApiRouteConfig::default();
899
900 let described_before = cfg.described_parameters();
901 let cfg = cfg.consumes_query::<Page>().with_constraints(
902 ConstraintTarget::QueryParameter { described_before },
903 &[FieldConstraint::new(
904 "page",
905 SchemaConstraint::Minimum(1.into()),
906 )],
907 );
908
909 let described_before = cfg.described_parameters();
910 let cfg = cfg.consumes_query::<Page>().with_constraints(
911 ConstraintTarget::QueryParameter { described_before },
912 &[FieldConstraint::new(
913 "page",
914 SchemaConstraint::Minimum(100.into()),
915 )],
916 );
917
918 let bounds = cfg
919 .extra_parameters
920 .iter()
921 .map(|parameter| parameter.schema.minimum.clone())
922 .collect::<Vec<_>>();
923
924 assert_eq!(bounds, vec![Some(1.into()), Some(100.into())]);
925 }
926
927 #[test]
928 fn with_constraints_of_an_extractor_that_described_nothing_reach_nobody() {
929 let cfg = OpenApiRouteConfig::default();
932 let described_before = cfg.described_parameters();
933 let cfg = cfg.consumes_query::<Page>().with_constraints(
934 ConstraintTarget::QueryParameter { described_before },
935 &[FieldConstraint::new(
936 "page",
937 SchemaConstraint::Minimum(1.into()),
938 )],
939 );
940
941 let described_before = cfg.described_parameters();
942 let cfg = cfg.with_constraints(
943 ConstraintTarget::QueryParameter { described_before },
944 &[FieldConstraint::new(
945 "page",
946 SchemaConstraint::Minimum(100.into()),
947 )],
948 );
949
950 assert_eq!(cfg.extra_parameters.len(), 1);
951 assert_eq!(cfg.extra_parameters[0].schema.minimum, Some(1.into()));
952 }
953
954 #[test]
955 fn with_constraints_intersects_a_bound_declared_twice() {
956 let cfg = OpenApiRouteConfig::default()
958 .consumes_query::<Page>()
959 .with_constraints(
960 ConstraintTarget::QueryParameter {
961 described_before: 0,
962 },
963 &[
964 FieldConstraint::new("page", SchemaConstraint::Minimum(10.into())),
965 FieldConstraint::new("page", SchemaConstraint::Minimum(1.into())),
966 FieldConstraint::new("page", SchemaConstraint::Maximum(50.into())),
967 FieldConstraint::new("page", SchemaConstraint::Maximum(500.into())),
968 ],
969 );
970
971 let schema = &cfg.extra_parameters[0].schema;
972
973 assert_eq!(schema.minimum, Some(10.into()));
974 assert_eq!(schema.maximum, Some(50.into()));
975 }
976
977 #[test]
978 fn consumes_query_keeps_parameters_of_a_typed_optional_field() {
979 #[derive(serde::Deserialize)]
980 #[allow(dead_code)]
981 struct OptionalString {
982 per_page: u32,
983 sort: Option<String>,
984 }
985
986 let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalString>();
987 let names = cfg
988 .extra_parameters
989 .iter()
990 .map(|p| p.name.as_str())
991 .collect::<Vec<_>>();
992
993 assert_eq!(names, vec!["per_page", "sort"]);
995 assert!(!cfg.extra_parameters[1].required);
996 }
997
998 #[derive(Deserialize)]
999 #[allow(dead_code)]
1000 struct Inner {
1001 name: String,
1002 }
1003
1004 #[derive(Deserialize)]
1005 #[allow(dead_code)]
1006 struct Flat {
1007 #[serde(flatten)]
1008 inner: Inner,
1009 page: u32,
1010 }
1011
1012 fn body_schema(cfg: &OpenApiRouteConfig) -> serde_json::Value {
1013 let mut operation = OpenApiOperation::default();
1014 let mut schemas = BTreeMap::new();
1015 cfg.apply_to_operation(&mut operation, &mut schemas);
1016
1017 let operation = serde_json::to_value(&operation).expect("operation serializes");
1018 operation["requestBody"]["content"]["application/json"]["schema"].clone()
1019 }
1020
1021 #[test]
1022 fn consumes_json_reports_a_body_read_as_a_map() {
1023 let cfg = OpenApiRouteConfig::default().consumes_json::<Flat>();
1024
1025 let [input] = cfg.undescribed_inputs() else {
1026 panic!("one undescribed input: {:?}", cfg.undescribed_inputs());
1027 };
1028 assert_eq!(input.kind(), InputKind::RequestBody);
1029 assert_eq!(input.type_name(), std::any::type_name::<Flat>());
1030 assert_eq!(input.read_as_map(), None);
1031
1032 assert_eq!(
1035 body_schema(&cfg),
1036 json!({ "type": "object", "title": "Flat" })
1037 );
1038 assert!(cfg.request_example.is_none());
1039 }
1040
1041 #[test]
1042 fn consumes_json_reports_a_struct_read_as_a_map_inside_the_body() {
1043 let cfg = OpenApiRouteConfig::default().consumes_json::<Vec<Flat>>();
1044
1045 let [input] = cfg.undescribed_inputs() else {
1046 panic!("one undescribed input: {:?}", cfg.undescribed_inputs());
1047 };
1048 assert_eq!(input.kind(), InputKind::RequestBody);
1049 assert_eq!(input.read_as_map(), Some("struct Flat"));
1050
1051 assert_eq!(body_schema(&cfg)["type"], serde_json::Value::Null);
1053 }
1054
1055 #[test]
1056 fn consumes_form_reports_a_body_read_as_a_map() {
1057 let cfg = OpenApiRouteConfig::default().consumes_form::<Flat>();
1058
1059 let [input] = cfg.undescribed_inputs() else {
1060 panic!("one undescribed input: {:?}", cfg.undescribed_inputs());
1061 };
1062 assert_eq!(input.kind(), InputKind::RequestBody);
1063 assert_eq!(
1064 cfg.request_content_type.as_deref(),
1065 Some("application/x-www-form-urlencoded")
1066 );
1067 }
1068
1069 #[test]
1070 fn consumes_json_does_not_report_a_map() {
1071 let cfg =
1072 OpenApiRouteConfig::default().consumes_json::<std::collections::HashMap<String, u32>>();
1073
1074 assert!(cfg.undescribed_inputs().is_empty());
1075 assert!(cfg.request_schema.is_some());
1076 }
1077
1078 #[test]
1079 fn consumes_query_reports_a_query_read_as_a_map() {
1080 let cfg = OpenApiRouteConfig::default().consumes_query::<Flat>();
1081
1082 let [input] = cfg.undescribed_inputs() else {
1083 panic!("one undescribed input: {:?}", cfg.undescribed_inputs());
1084 };
1085 assert_eq!(input.kind(), InputKind::QueryParameters);
1086 assert!(cfg.extra_parameters.is_empty());
1087 }
1088
1089 #[test]
1090 fn consumes_named_path_leaves_a_struct_read_as_a_map_to_the_template() {
1091 let cfg = OpenApiRouteConfig::default().consumes_named_path::<Flat>();
1092
1093 assert!(cfg.undescribed_inputs().is_empty());
1094 assert!(cfg.extra_parameters.is_empty());
1095 }
1096
1097 #[test]
1098 fn with_request_schema_describes_an_undescribed_body() {
1099 let cfg = OpenApiRouteConfig::default()
1100 .consumes_query::<Flat>()
1101 .consumes_json::<Flat>()
1102 .with_request_schema(
1103 OpenApiSchema::object()
1104 .with_property("name", OpenApiSchema::string())
1105 .with_property("page", OpenApiSchema::integer())
1106 .with_required(["name", "page"]),
1107 );
1108
1109 let kinds = cfg
1111 .undescribed_inputs()
1112 .iter()
1113 .map(|input| input.kind())
1114 .collect::<Vec<_>>();
1115 assert_eq!(kinds, vec![InputKind::QueryParameters]);
1116 assert_eq!(
1117 body_schema(&cfg)["properties"]["page"]["type"],
1118 json!("integer")
1119 );
1120 }
1121
1122 #[test]
1123 fn with_query_schema_describes_an_undescribed_query() {
1124 let cfg = OpenApiRouteConfig::default()
1125 .consumes_json::<Flat>()
1126 .consumes_query::<Flat>()
1127 .with_query_schema(
1128 OpenApiSchema::object()
1129 .with_property("name", OpenApiSchema::string())
1130 .with_property("page", OpenApiSchema::integer())
1131 .with_required(["page"]),
1132 );
1133
1134 let kinds = cfg
1135 .undescribed_inputs()
1136 .iter()
1137 .map(|input| input.kind())
1138 .collect::<Vec<_>>();
1139 assert_eq!(kinds, vec![InputKind::RequestBody]);
1140
1141 let parameters = cfg
1142 .extra_parameters
1143 .iter()
1144 .map(|p| (p.name.as_str(), p.location.as_str(), p.required))
1145 .collect::<Vec<_>>();
1146 assert_eq!(
1147 parameters,
1148 vec![("name", "query", false), ("page", "query", true)]
1149 );
1150 }
1151
1152 #[test]
1153 fn with_query_schema_replaces_a_parameter_of_the_same_name() {
1154 let cfg = OpenApiRouteConfig::default()
1155 .consumes_query::<Page>()
1156 .with_query_schema(
1157 OpenApiSchema::object().with_property("page", OpenApiSchema::string()),
1158 );
1159
1160 let mut operation = OpenApiOperation::default();
1161 cfg.apply_to_operation(&mut operation, &mut BTreeMap::new());
1162
1163 let parameters = operation.parameters.expect("parameters");
1164 assert_eq!(parameters.len(), 1);
1165 assert_eq!(parameters[0].schema.schema_type.as_deref(), Some("string"));
1166 assert!(!parameters[0].required);
1167 }
1168
1169 fn kinds(cfg: &OpenApiRouteConfig) -> Vec<InputKind> {
1170 cfg.undescribed_inputs()
1171 .iter()
1172 .map(|input| input.kind())
1173 .collect()
1174 }
1175
1176 #[test]
1180 fn merge_outer_inherits_the_undescribed_inputs_it_inherits() {
1181 let group = OpenApiRouteConfig::default()
1182 .consumes_query::<Flat>()
1183 .consumes_json::<Flat>();
1184
1185 let bare = OpenApiRouteConfig::default().merge_outer(&group);
1186 assert_eq!(
1187 kinds(&bare),
1188 [InputKind::QueryParameters, InputKind::RequestBody]
1189 );
1190
1191 let own_body = OpenApiRouteConfig::default()
1192 .consumes_json::<Payload>()
1193 .merge_outer(&group);
1194 assert_eq!(kinds(&own_body), [InputKind::QueryParameters]);
1195 assert!(
1196 own_body
1197 .request_schema
1198 .as_ref()
1199 .and_then(|schema| schema.properties.as_ref())
1200 .is_some_and(|properties| properties.contains_key("name")),
1201 "the route keeps its own body"
1202 );
1203
1204 let own_flat_body = OpenApiRouteConfig::default()
1206 .consumes_json::<Vec<Flat>>()
1207 .merge_outer(&group);
1208 let bodies = own_flat_body
1209 .undescribed_inputs()
1210 .iter()
1211 .filter(|input| input.kind() == InputKind::RequestBody)
1212 .map(|input| input.type_name())
1213 .collect::<Vec<_>>();
1214 assert_eq!(bodies, [std::any::type_name::<Vec<Flat>>()]);
1215 }
1216
1217 #[test]
1220 fn merge_outer_takes_an_example_only_with_the_body_it_belongs_to() {
1221 let group = OpenApiRouteConfig::default().consumes_json::<Payload>();
1222 assert!(group.request_example.is_some());
1223
1224 let flat = OpenApiRouteConfig::default()
1225 .consumes_json::<Flat>()
1226 .merge_outer(&group);
1227 assert!(flat.request_example.is_none());
1228
1229 let by_hand = OpenApiRouteConfig::default()
1230 .with_request_schema(OpenApiSchema::string())
1231 .merge_outer(&group);
1232 assert!(by_hand.request_example.is_none());
1233
1234 let bare = OpenApiRouteConfig::default().merge_outer(&group);
1235 assert_eq!(bare.request_example, group.request_example);
1236 }
1237
1238 #[test]
1239 fn merge_outer_inherits_no_input_a_group_describes_by_hand() {
1240 let group = OpenApiRouteConfig::default()
1241 .consumes_query::<Flat>()
1242 .with_query_schema(
1243 OpenApiSchema::object().with_property("name", OpenApiSchema::string()),
1244 );
1245
1246 let merged = OpenApiRouteConfig::default().merge_outer(&group);
1247
1248 assert!(merged.undescribed_inputs().is_empty());
1249 }
1250
1251 #[test]
1252 fn consumes_query_marks_non_optional_fields_as_required() {
1253 let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalQuery>();
1254
1255 let required_name = cfg
1256 .extra_parameters
1257 .iter()
1258 .find(|p| p.name == "required_name")
1259 .expect("required_name param");
1260 let optional_age = cfg
1261 .extra_parameters
1262 .iter()
1263 .find(|p| p.name == "optional_age")
1264 .expect("optional_age param");
1265
1266 assert!(required_name.required);
1267 assert!(!optional_age.required);
1268 }
1269
1270 #[test]
1271 fn infer_request_schema_from_deserialize_type() {
1272 let cfg = OpenApiRouteConfig::default()
1273 .with_request_type_from_deserialize::<Payload>("application/json");
1274
1275 let schema = cfg
1276 .request_schema
1277 .expect("request schema should be inferred");
1278 let props = schema.properties.expect("object properties should exist");
1279
1280 assert!(props.contains_key("name"));
1281 assert!(props.contains_key("age"));
1282 assert_eq!(
1283 cfg.request_content_type.as_deref(),
1284 Some("application/json")
1285 );
1286 }
1287
1288 #[test]
1289 fn infer_query_parameters_from_deserialize_type() {
1290 let cfg = OpenApiRouteConfig::default().with_query_parameters_from_deserialize::<Payload>();
1291
1292 assert_eq!(cfg.extra_parameters.len(), 2);
1293 assert!(cfg.extra_parameters.iter().any(|p| p.name == "name"));
1294 assert!(cfg.extra_parameters.iter().any(|p| p.name == "age"));
1295 assert!(cfg.extra_parameters.iter().all(|p| p.location == "query"));
1296 }
1297
1298 #[test]
1299 fn merge_outer_keeps_the_route_ahead_of_the_scope_it_is_in() {
1300 let group = OpenApiRouteConfig::default()
1301 .with_tag("group")
1302 .with_summary("group summary")
1303 .with_query_parameter("id".into(), OpenApiSchema::string(), false);
1304 let route = OpenApiRouteConfig::default()
1305 .with_tag("route")
1306 .with_query_parameter("id".into(), OpenApiSchema::integer(), true);
1307
1308 let merged = route.merge_outer(&group);
1309
1310 assert_eq!(merged.tags, vec!["group".to_string(), "route".to_string()]);
1313 assert_eq!(merged.summary.as_deref(), Some("group summary"));
1314 assert_eq!(merged.extra_parameters.len(), 2);
1315 assert!(merged.extra_parameters[1].required);
1316 }
1317
1318 #[test]
1319 fn merge_combines_docs_and_preserves_existing_values() {
1320 let base = OpenApiRouteConfig::default()
1321 .with_tag("base")
1322 .with_summary("base summary")
1323 .with_doc("v1");
1324 let other = OpenApiRouteConfig::default()
1325 .with_tag("extra")
1326 .with_summary("other summary")
1327 .with_doc("v1")
1328 .with_doc("admin");
1329
1330 let merged = base.merge(&other);
1331
1332 assert_eq!(merged.summary.as_deref(), Some("base summary"));
1333 assert_eq!(merged.tags.as_slice(), ["base", "extra"]);
1334 assert_eq!(merged.docs.expect("docs"), vec!["v1", "admin"]);
1335 }
1336
1337 #[test]
1338 fn apply_to_operation_adds_content_and_interns_named_schema() {
1339 let cfg = OpenApiRouteConfig::default()
1340 .with_request_schema(
1341 OpenApiSchema::object()
1342 .with_title("CreateUser")
1343 .with_property("name", OpenApiSchema::string()),
1344 )
1345 .with_response_schema(
1346 200u16,
1347 OpenApiSchema::object()
1348 .with_title("User")
1349 .with_property("id", OpenApiSchema::integer()),
1350 )
1351 .produces_json_example(
1352 200u16,
1353 ResponsePayload {
1354 message: "ok".to_string(),
1355 },
1356 )
1357 .with_summary("create user")
1358 .with_operation_id("createUser");
1359
1360 let mut op = OpenApiOperation::default();
1361 let mut schemas = BTreeMap::new();
1362
1363 cfg.apply_to_operation(&mut op, &mut schemas);
1364
1365 let op_json = serde_json::to_value(op).expect("serialize operation");
1366 assert_eq!(op_json["summary"], "create user");
1367 assert_eq!(op_json["operationId"], "createUser");
1368 assert!(
1369 op_json["requestBody"]["content"]
1370 .get("application/json")
1371 .is_some()
1372 );
1373 assert_eq!(
1374 op_json["responses"]["200"]["content"]["application/json"]["example"],
1375 json!({"message":"ok"})
1376 );
1377
1378 assert!(schemas.contains_key("CreateUser"));
1379 }
1380
1381 #[test]
1382 fn produces_no_schema_clears_content() {
1383 let cfg = OpenApiRouteConfig::default()
1384 .produces_json::<ResponsePayload>(200)
1385 .produces_no_schema(200);
1386
1387 let mut op = OpenApiOperation::default();
1388 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1389
1390 let op_json = serde_json::to_value(op).expect("serialize operation");
1391 assert!(op_json["responses"]["200"].get("content").is_none());
1392 }
1393
1394 #[test]
1395 fn with_tags_extends_existing_tags() {
1396 let cfg = OpenApiRouteConfig::default()
1397 .with_tag("base")
1398 .with_tags(["one", "two"]);
1399
1400 assert_eq!(cfg.tags, vec!["base", "one", "two"]);
1401 }
1402
1403 #[test]
1404 fn with_docs_initializes_and_extends_docs() {
1405 let cfg = OpenApiRouteConfig::default()
1406 .with_docs(["v1", "admin"])
1407 .with_doc("internal");
1408
1409 assert_eq!(cfg.docs.expect("docs"), vec!["v1", "admin", "internal"]);
1410 }
1411
1412 #[test]
1413 fn with_description_sets_operation_description() {
1414 let cfg = OpenApiRouteConfig::default().with_description("desc");
1415 assert_eq!(cfg.description.as_deref(), Some("desc"));
1416 }
1417
1418 #[test]
1419 fn consumes_methods_set_request_content_type() {
1420 let query_cfg = OpenApiRouteConfig::default().consumes_query::<Payload>();
1421 assert_eq!(query_cfg.extra_parameters.len(), 2);
1422
1423 let json_cfg = OpenApiRouteConfig::default().consumes_json::<Payload>();
1424 assert_eq!(
1425 json_cfg.request_content_type.as_deref(),
1426 Some("application/json")
1427 );
1428 assert!(json_cfg.request_schema.is_some());
1429
1430 let form_cfg = OpenApiRouteConfig::default().consumes_form::<Payload>();
1431 assert_eq!(
1432 form_cfg.request_content_type.as_deref(),
1433 Some("application/x-www-form-urlencoded")
1434 );
1435 assert!(form_cfg.request_schema.is_some());
1436
1437 let multipart_cfg = OpenApiRouteConfig::default().consumes_multipart();
1438 assert_eq!(
1439 multipart_cfg.request_content_type.as_deref(),
1440 Some("multipart/form-data")
1441 );
1442
1443 let stream_cfg = OpenApiRouteConfig::default().consumes_stream();
1444 assert_eq!(
1445 stream_cfg.request_content_type.as_deref(),
1446 Some("application/octet-stream")
1447 );
1448 assert_eq!(
1449 stream_cfg.request_schema.expect("schema").format.as_deref(),
1450 Some("binary")
1451 );
1452 }
1453
1454 #[test]
1455 fn produces_helpers_set_expected_response_schema_and_content_type() {
1456 fn apply(cfg: OpenApiRouteConfig) -> serde_json::Value {
1457 let mut op = OpenApiOperation::default();
1458 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1459 serde_json::to_value(op).expect("serialize")
1460 }
1461
1462 let text_json = apply(OpenApiRouteConfig::default().produces_text(200));
1463 assert!(
1464 text_json["responses"]["200"]["content"]
1465 .get("text/plain; charset=utf-8")
1466 .is_some()
1467 );
1468 assert_eq!(
1469 text_json["responses"]["200"]["content"]["text/plain; charset=utf-8"]["schema"]["type"],
1470 "string"
1471 );
1472
1473 let empty_json_json = apply(OpenApiRouteConfig::default().produces_empty_json(200));
1474 assert!(
1475 empty_json_json["responses"]["200"]["content"]
1476 .get("application/json")
1477 .is_some()
1478 );
1479 assert_eq!(
1480 empty_json_json["responses"]["200"]["content"]["application/json"]["schema"]["type"],
1481 "object"
1482 );
1483
1484 let form_json = apply(OpenApiRouteConfig::default().produces_form::<ResponsePayload>(200));
1485 assert!(
1486 form_json["responses"]["200"]["content"]
1487 .get("application/x-www-form-urlencoded")
1488 .is_some()
1489 );
1490 assert_eq!(
1491 form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1492 ["type"],
1493 "string"
1494 );
1495
1496 let empty_form_json = apply(OpenApiRouteConfig::default().produces_empty_form(200));
1497 assert!(
1498 empty_form_json["responses"]["200"]["content"]
1499 .get("application/x-www-form-urlencoded")
1500 .is_some()
1501 );
1502 assert_eq!(
1503 empty_form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1504 ["type"],
1505 "object"
1506 );
1507
1508 let stream_json = apply(OpenApiRouteConfig::default().produces_stream(200));
1509 assert!(
1510 stream_json["responses"]["200"]["content"]
1511 .get("application/octet-stream")
1512 .is_some()
1513 );
1514 assert_eq!(
1515 stream_json["responses"]["200"]["content"]["application/octet-stream"]["schema"]["format"],
1516 "binary"
1517 );
1518
1519 let sse_json = apply(OpenApiRouteConfig::default().produces_sse(200));
1520 assert!(
1521 sse_json["responses"]["200"]["content"]
1522 .get("text/event-stream")
1523 .is_some()
1524 );
1525 assert_eq!(
1526 sse_json["responses"]["200"]["content"]["text/event-stream"]["schema"]["title"],
1527 "SSE stream"
1528 );
1529 }
1530
1531 #[test]
1532 fn produces_form_example_encodes_object_into_string_example() {
1533 #[derive(Serialize)]
1534 struct FormOut {
1535 first: String,
1536 second: i32,
1537 }
1538
1539 let cfg = OpenApiRouteConfig::default().produces_form_example(
1540 200u16,
1541 FormOut {
1542 first: "hello".to_string(),
1543 second: 7,
1544 },
1545 );
1546
1547 let mut op = OpenApiOperation::default();
1548 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1549 let op_json = serde_json::to_value(op).expect("serialize");
1550
1551 assert!(
1552 op_json["responses"]["200"]["content"]
1553 .get("application/x-www-form-urlencoded")
1554 .is_some()
1555 );
1556 assert_eq!(
1557 op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]["type"],
1558 "string"
1559 );
1560 assert_eq!(
1561 op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["example"],
1562 "first=hello&second=7"
1563 );
1564 }
1565
1566 #[test]
1567 fn produces_multiple_status_codes() {
1568 #[derive(Serialize, Default)]
1569 struct UserDto {
1570 id: u64,
1571 }
1572 #[derive(Serialize, Default)]
1573 struct ErrorDto {
1574 message: String,
1575 }
1576
1577 let cfg = OpenApiRouteConfig::default()
1578 .produces_json::<UserDto>(200)
1579 .produces_json::<ErrorDto>(404);
1580
1581 let mut op = OpenApiOperation::default();
1582 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1583 let op_json = serde_json::to_value(op).expect("serialize");
1584
1585 assert!(
1587 op_json["responses"]["200"]["content"]
1588 .get("application/json")
1589 .is_some()
1590 );
1591 assert!(
1592 op_json["responses"]["404"]["content"]
1593 .get("application/json")
1594 .is_some()
1595 );
1596 assert_eq!(
1598 op_json["responses"]
1599 .as_object()
1600 .expect("responses object")
1601 .len(),
1602 2
1603 );
1604 }
1605
1606 #[test]
1607 fn explicit_responses_replace_default_200() {
1608 let cfg = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(201);
1609
1610 let mut op = OpenApiOperation::default();
1611 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1612 let op_json = serde_json::to_value(op).expect("serialize");
1613
1614 assert!(op_json["responses"].get("200").is_none());
1616 assert!(
1617 op_json["responses"]["201"]["content"]
1618 .get("application/json")
1619 .is_some()
1620 );
1621 assert_eq!(op_json["responses"]["201"]["description"], "Created");
1622 }
1623
1624 #[test]
1625 fn merge_does_not_override_existing_responses() {
1626 let base = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(200);
1627 let other = OpenApiRouteConfig::default()
1628 .produces_text(200)
1629 .produces_json::<ResponsePayload>(404);
1630
1631 let merged = base.merge(&other);
1632
1633 let mut op = OpenApiOperation::default();
1634 merged.apply_to_operation(&mut op, &mut BTreeMap::new());
1635 let op_json = serde_json::to_value(op).expect("serialize");
1636
1637 assert!(
1639 op_json["responses"]["200"]["content"]
1640 .get("application/json")
1641 .is_some()
1642 );
1643 assert!(
1644 op_json["responses"]["200"]["content"]
1645 .get("text/plain; charset=utf-8")
1646 .is_none()
1647 );
1648 assert!(
1650 op_json["responses"]["404"]["content"]
1651 .get("application/json")
1652 .is_some()
1653 );
1654 }
1655
1656 #[test]
1657 fn into_status_code_works_for_u16_and_http_status_code() {
1658 assert_eq!(200u16.into_status_code(), 200);
1659 assert_eq!(201.into_status_code(), 201);
1660 assert_eq!(202u32.into_status_code(), 202);
1661 assert_eq!(http::StatusCode::NOT_FOUND.into_status_code(), 404);
1662 assert_eq!(http::StatusCode::CREATED.into_status_code(), 201);
1663 }
1664
1665 #[cfg(feature = "problem-details")]
1666 mod problem_tests {
1667 use super::*;
1668
1669 #[test]
1670 fn produces_problem_generates_problem_json_schema_for_status() {
1671 let cfg = OpenApiRouteConfig::default().produces_problem(400u16);
1672 let mut op = OpenApiOperation::default();
1673 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1674 let op_json = serde_json::to_value(op).expect("serialize");
1675
1676 let content = &op_json["responses"]["400"]["content"]["application/problem+json"];
1677 assert!(
1678 content.is_object(),
1679 "application/problem+json content should exist"
1680 );
1681 assert_eq!(content["example"]["title"], "Bad Request");
1682 assert_eq!(content["example"]["status"], 400);
1683 assert_eq!(
1684 content["example"]["type"],
1685 "https://tools.ietf.org/html/rfc9110"
1686 );
1687 assert_eq!(op_json["responses"]["400"]["description"], "Bad Request");
1688 assert!(op_json["responses"].get("200").is_none());
1690 }
1691
1692 #[test]
1693 fn produces_problem_example_uses_provided_value() {
1694 #[derive(Serialize)]
1695 struct CustomProblem {
1696 r#type: String,
1697 title: String,
1698 status: u16,
1699 detail: String,
1700 }
1701
1702 let cfg = OpenApiRouteConfig::default().produces_problem_example(
1703 422u16,
1704 CustomProblem {
1705 r#type: "https://tools.ietf.org/html/rfc9110".into(),
1706 title: "Unprocessable Content".into(),
1707 status: 422,
1708 detail: "Validation failed".into(),
1709 },
1710 );
1711
1712 let mut op = OpenApiOperation::default();
1713 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1714 let op_json = serde_json::to_value(op).expect("serialize");
1715
1716 let content = &op_json["responses"]["422"]["content"]["application/problem+json"];
1717 assert!(
1718 content.is_object(),
1719 "application/problem+json content should exist"
1720 );
1721 assert_eq!(content["example"]["detail"], "Validation failed");
1722 assert_eq!(content["example"]["status"], 422);
1723 assert_eq!(
1724 op_json["responses"]["422"]["description"],
1725 "Unprocessable Entity"
1726 );
1727 }
1728
1729 #[test]
1730 fn produces_problem_composes_with_other_status_responses() {
1731 let cfg = OpenApiRouteConfig::default()
1732 .produces_json::<ResponsePayload>(200)
1733 .produces_problem(400)
1734 .produces_problem(500);
1735
1736 let mut op = OpenApiOperation::default();
1737 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1738 let op_json = serde_json::to_value(op).expect("serialize");
1739
1740 assert!(
1741 op_json["responses"]["200"]["content"]
1742 .get("application/json")
1743 .is_some()
1744 );
1745 assert!(
1746 op_json["responses"]["400"]["content"]
1747 .get("application/problem+json")
1748 .is_some()
1749 );
1750 assert!(
1751 op_json["responses"]["500"]["content"]
1752 .get("application/problem+json")
1753 .is_some()
1754 );
1755 assert_eq!(op_json["responses"].as_object().unwrap().len(), 3);
1756 }
1757 }
1758
1759 #[test]
1760 fn produces_multipart_sets_response_content_type() {
1761 let cfg = OpenApiRouteConfig::default().produces_multipart(200u16);
1762 let resp = cfg.responses.get(&200u16).expect("200 response defined");
1763 match resp {
1764 ResponseBody::Content { content_type, .. } => {
1765 assert_eq!(content_type, "multipart/form-data");
1766 }
1767 _ => panic!("expected Content variant"),
1768 }
1769 }
1770}