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},
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, Default)]
84pub struct OpenApiRouteConfig {
85 tags: Vec<String>,
86 docs: Option<Vec<String>>,
87 summary: Option<String>,
88 description: Option<String>,
89 operation_id: Option<String>,
90 request_schema: Option<OpenApiSchema>,
91 request_example: Option<Value>,
92 request_content_type: Option<String>,
93 responses: BTreeMap<u16, ResponseBody>,
94 extra_parameters: Vec<OpenApiParameter>,
95}
96
97impl OpenApiRouteConfig {
98 pub fn described_parameters(&self) -> usize {
103 self.extra_parameters.len()
104 }
105
106 pub(crate) fn docs(&self) -> Option<&[String]> {
108 self.docs.as_deref()
109 }
110
111 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
113 self.tags.push(tag.into());
114 self
115 }
116
117 pub fn with_tags<I, T>(mut self, tags: I) -> Self
119 where
120 I: IntoIterator<Item = T>,
121 T: Into<String>,
122 {
123 self.tags.extend(tags.into_iter().map(Into::into));
124 self
125 }
126
127 pub fn with_doc(mut self, doc: impl Into<String>) -> Self {
129 self.docs.get_or_insert_with(Vec::new).push(doc.into());
130 self
131 }
132
133 pub fn with_docs<I, S>(mut self, docs: I) -> Self
135 where
136 I: IntoIterator<Item = S>,
137 S: Into<String>,
138 {
139 self.docs
140 .get_or_insert_with(Vec::new)
141 .extend(docs.into_iter().map(Into::into));
142 self
143 }
144
145 pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
147 self.summary = Some(summary.into());
148 self
149 }
150
151 pub fn with_description(mut self, description: impl Into<String>) -> Self {
153 self.description = Some(description.into());
154 self
155 }
156
157 pub fn with_operation_id(mut self, operation_id: impl Into<String>) -> Self {
159 self.operation_id = Some(operation_id.into());
160 self
161 }
162
163 pub fn with_request_schema(mut self, schema: OpenApiSchema) -> Self {
165 self.request_schema = Some(schema);
166 self
167 }
168
169 pub fn with_response_schema(
171 mut self,
172 status: impl IntoStatusCode,
173 schema: OpenApiSchema,
174 ) -> Self {
175 let status = status.into_status_code();
176 self.responses.insert(
177 status,
178 ResponseBody::Content {
179 schema: Box::new(schema),
180 example: None,
181 content_type: APPLICATION_JSON.to_string(),
182 },
183 );
184 self
185 }
186
187 pub fn consumes_named_path<T: DeserializeOwned>(self) -> Self {
189 self.with_named_path_parameters_from_deserialize::<T>()
190 }
191
192 pub fn consumes_query<T: DeserializeOwned>(self) -> Self {
194 self.with_query_parameters_from_deserialize::<T>()
195 }
196
197 pub fn consumes_json<T: DeserializeOwned>(self) -> Self {
199 self.with_request_type_from_deserialize::<T>(APPLICATION_JSON.as_ref())
200 }
201
202 pub fn consumes_form<T: DeserializeOwned>(self) -> Self {
204 self.with_request_type_from_deserialize::<T>(APPLICATION_WWW_FORM_URLENCODED.as_ref())
205 }
206
207 pub fn consumes_multipart(self) -> Self {
209 self.with_multipart_request()
210 }
211
212 pub fn consumes_stream(self) -> Self {
214 self.with_stream_request()
215 }
216
217 pub fn produces_text(mut self, status: impl IntoStatusCode) -> Self {
219 let status = status.into_status_code();
220 self.responses.insert(
221 status,
222 ResponseBody::Content {
223 schema: Box::new(OpenApiSchema::string()),
224 example: None,
225 content_type: TEXT_PLAIN_UTF_8.to_string(),
226 },
227 );
228 self
229 }
230
231 pub fn produces_no_schema(mut self, status: impl IntoStatusCode) -> Self {
233 let status = status.into_status_code();
234 self.responses.insert(status, ResponseBody::NoContent);
235 self
236 }
237
238 pub fn produces_json<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
240 self.produces_json_example(status, T::default())
241 }
242
243 pub fn produces_empty_json(mut self, status: impl IntoStatusCode) -> Self {
245 let status = status.into_status_code();
246 self.responses.insert(
247 status,
248 ResponseBody::Content {
249 schema: Box::new(OpenApiSchema::object()),
250 example: None,
251 content_type: APPLICATION_JSON.to_string(),
252 },
253 );
254 self
255 }
256
257 pub fn produces_form<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
259 self.produces_form_example(status, T::default())
260 }
261
262 pub fn produces_empty_form(mut self, status: impl IntoStatusCode) -> Self {
264 let status = status.into_status_code();
265 self.responses.insert(
266 status,
267 ResponseBody::Content {
268 schema: Box::new(OpenApiSchema::object()),
269 example: None,
270 content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
271 },
272 );
273 self
274 }
275
276 pub fn produces_multipart(mut self, status: impl IntoStatusCode) -> Self {
278 let status = status.into_status_code();
279 self.responses.insert(
280 status,
281 ResponseBody::Content {
282 schema: Box::new(OpenApiSchema::multipart()),
283 example: None,
284 content_type: MULTIPART_FORM_DATA.to_string(),
285 },
286 );
287 self
288 }
289
290 pub fn produces_stream(mut self, status: impl IntoStatusCode) -> Self {
292 let status = status.into_status_code();
293 self.responses.insert(
294 status,
295 ResponseBody::Content {
296 schema: Box::new(OpenApiSchema::binary()),
297 example: None,
298 content_type: APPLICATION_OCTET_STREAM.to_string(),
299 },
300 );
301 self
302 }
303
304 pub fn produces_sse(mut self, status: impl IntoStatusCode) -> Self {
306 let status = status.into_status_code();
307 self.responses.insert(
308 status,
309 ResponseBody::Content {
310 schema: Box::new(OpenApiSchema::string().with_title("SSE stream")),
311 example: None,
312 content_type: TEXT_EVENT_STREAM.to_string(),
313 },
314 );
315 self
316 }
317
318 pub fn produces_json_example<T: Serialize>(
320 mut self,
321 status: impl IntoStatusCode,
322 example: T,
323 ) -> Self {
324 let status = status.into_status_code();
325 let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
326 self.responses.insert(
327 status,
328 ResponseBody::Content {
329 schema: Box::new(OpenApiSchema::from_example(&example)),
330 example: Some(example),
331 content_type: APPLICATION_JSON.to_string(),
332 },
333 );
334 self
335 }
336
337 pub fn produces_form_example<T: Serialize>(
339 mut self,
340 status: impl IntoStatusCode,
341 example: T,
342 ) -> Self {
343 let status = status.into_status_code();
344 let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
345
346 let encoded = match &example {
347 Value::Object(map) => serde_urlencoded::to_string(map).unwrap_or_default(),
348 _ => String::new(),
349 };
350
351 self.responses.insert(
352 status,
353 ResponseBody::Content {
354 schema: Box::new(OpenApiSchema::string()),
355 example: Some(Value::String(encoded)),
356 content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
357 },
358 );
359 self
360 }
361
362 #[cfg(feature = "problem-details")]
366 pub fn produces_problem(mut self, status: impl IntoStatusCode) -> Self {
367 let status = status.into_status_code();
368 let title = http::StatusCode::from_u16(status)
369 .ok()
370 .and_then(|s| s.canonical_reason())
371 .unwrap_or("Error");
372 let example = json!({
373 "type": "https://tools.ietf.org/html/rfc9110",
374 "title": title,
375 "status": status
376 });
377 self.responses.insert(
378 status,
379 ResponseBody::Content {
380 schema: Box::new(OpenApiSchema::from_example(&example)),
381 example: Some(example),
382 content_type: "application/problem+json".to_string(),
383 },
384 );
385 self
386 }
387
388 #[cfg(feature = "problem-details")]
392 pub fn produces_problem_example<T: Serialize>(
393 mut self,
394 status: impl IntoStatusCode,
395 example: T,
396 ) -> Self {
397 let status = status.into_status_code();
398 let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
399 self.responses.insert(
400 status,
401 ResponseBody::Content {
402 schema: Box::new(OpenApiSchema::from_example(&example)),
403 example: Some(example),
404 content_type: "application/problem+json".to_string(),
405 },
406 );
407 self
408 }
409
410 fn with_multipart_request(mut self) -> Self {
412 self.request_schema
413 .get_or_insert_with(OpenApiSchema::multipart);
414 self.request_content_type = Some(MULTIPART_FORM_DATA.to_string());
415 self
416 }
417
418 fn with_stream_request(mut self) -> Self {
420 self.request_schema
421 .get_or_insert_with(OpenApiSchema::binary);
422 self.request_content_type = Some(APPLICATION_OCTET_STREAM.to_string());
423 self
424 }
425
426 fn with_query_parameter(mut self, name: String, schema: OpenApiSchema, required: bool) -> Self {
427 self.extra_parameters.push(OpenApiParameter {
428 name,
429 location: "query".to_string(),
430 required,
431 schema,
432 });
433 self
434 }
435
436 fn with_path_parameter(mut self, name: String, schema: OpenApiSchema) -> Self {
437 self.extra_parameters.push(OpenApiParameter {
438 name,
439 location: "path".to_string(),
440 required: true,
441 schema,
442 });
443 self
444 }
445
446 fn with_request_type_from_deserialize<T: DeserializeOwned>(
448 mut self,
449 content_type: &str,
450 ) -> Self {
451 if let Some((schema, example)) = schema_and_example_from_deserialize::<T>() {
452 self.request_schema = Some(schema.with_title(type_display_name::<T>()));
453 self.request_example = Some(example);
454 }
455 self.request_content_type = Some(content_type.to_string());
456 self
457 }
458
459 pub fn with_constraints(
466 mut self,
467 target: ConstraintTarget,
468 constraints: &[FieldConstraint],
469 ) -> Self {
470 match target {
471 ConstraintTarget::RequestBody => {
472 if let Some(schema) = self.request_schema.as_mut() {
473 schema.apply_field_constraints(constraints);
474 }
475 }
476 ConstraintTarget::QueryParameter { described_before } => {
477 self.apply_to_parameters("query", described_before, constraints);
478 }
479 ConstraintTarget::PathParameter { described_before } => {
480 self.apply_to_parameters("path", described_before, constraints);
481 }
482 }
483 self
484 }
485
486 fn apply_to_parameters(
494 &mut self,
495 location: &str,
496 described_before: usize,
497 constraints: &[FieldConstraint],
498 ) {
499 let Some(described) = self.extra_parameters.get_mut(described_before..) else {
500 return;
501 };
502
503 for parameter in described
504 .iter_mut()
505 .filter(|parameter| parameter.location == location)
506 {
507 for constraint in constraints
509 .iter()
510 .filter(|constraint| constraint.field == parameter.name)
511 {
512 parameter.schema.apply_constraint(&constraint.constraint);
513 }
514 }
515 }
516
517 fn with_query_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
518 if let Some((schema, _)) = schema_and_example_from_deserialize::<T>()
519 && let Some(properties) = schema.properties
520 {
521 let required = schema.required.unwrap_or_default();
522
523 for (name, property_schema) in properties {
524 let is_required = required.iter().any(|f| f == &name);
525 self = self.with_query_parameter(name, property_schema, is_required);
526 }
527 }
528 self
529 }
530
531 fn with_named_path_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
532 if let Some((schema, _)) = schema_and_example_from_deserialize::<T>()
533 && let Some(properties) = schema.properties
534 {
535 for (name, property_schema) in properties {
536 self = self.with_path_parameter(name, property_schema);
537 }
538 }
539 self
540 }
541
542 pub fn merge_outer(mut self, outer: &Self) -> Self {
548 let own_tags = std::mem::take(&mut self.tags);
549 let own_parameters = std::mem::take(&mut self.extra_parameters);
550
551 let mut merged = self.merge(outer);
552
553 merged.tags.extend(own_tags);
554 merged.extra_parameters.extend(own_parameters);
557 merged
558 }
559
560 pub fn merge(mut self, other: &Self) -> Self {
562 if self.tags.is_empty() {
563 self.tags = other.tags.clone();
564 } else {
565 self.tags.extend(other.tags.clone());
566 }
567 if self.summary.is_none() {
568 self.summary = other.summary.clone();
569 }
570 if self.description.is_none() {
571 self.description = other.description.clone();
572 }
573 if self.operation_id.is_none() {
574 self.operation_id = other.operation_id.clone();
575 }
576 if self.request_schema.is_none() {
577 self.request_schema = other.request_schema.clone();
578 }
579 if self.request_example.is_none() {
580 self.request_example = other.request_example.clone();
581 }
582 if self.request_content_type.is_none() {
583 self.request_content_type = other.request_content_type.clone();
584 }
585 for (status, body) in &other.responses {
586 self.responses
587 .entry(*status)
588 .or_insert_with(|| body.clone());
589 }
590 if !other.extra_parameters.is_empty() {
591 self.extra_parameters.extend(other.extra_parameters.clone());
592 }
593 match (&mut self.docs, &other.docs) {
594 (None, Some(d)) => self.docs = Some(d.clone()),
595 (Some(dst), Some(src)) => {
596 for s in src {
597 if !dst.iter().any(|x| x == s) {
598 dst.push(s.clone());
599 }
600 }
601 }
602 _ => {}
603 }
604 self
605 }
606
607 pub(super) fn apply_to_operation(
608 &self,
609 operation: &mut OpenApiOperation,
610 schemas: &mut BTreeMap<String, OpenApiSchema>,
611 ) {
612 if let Some(summary) = &self.summary {
613 operation.summary = Some(summary.clone());
614 }
615 if let Some(description) = &self.description {
616 operation.description = Some(description.clone());
617 }
618 if let Some(operation_id) = &self.operation_id {
619 operation.operation_id = Some(operation_id.clone());
620 }
621
622 if !self.tags.is_empty() {
623 operation.tags = Some(self.tags.clone());
624 }
625
626 if self.request_schema.is_some() || self.request_example.is_some() {
627 let mut schema = self
628 .request_schema
629 .clone()
630 .unwrap_or_else(OpenApiSchema::object);
631 let example = self.request_example.clone();
632 let content_type = self
633 .request_content_type
634 .as_deref()
635 .unwrap_or(APPLICATION_JSON.as_ref());
636
637 schema = intern_schema_if_object_named(schema, schemas);
638 operation.set_request_body(schema, example, content_type);
639 }
640
641 if !self.responses.is_empty() {
642 operation.clear_all_responses();
643 for (status, body) in &self.responses {
644 match body {
645 ResponseBody::NoContent => {
646 operation.clear_response_body(*status);
647 }
648 ResponseBody::Content {
649 schema,
650 example,
651 content_type,
652 } => {
653 let schema = intern_schema_if_object_named((**schema).clone(), schemas);
654 operation.set_response_body(*status, schema, example.clone(), content_type);
655 }
656 }
657 }
658 }
659
660 if !self.extra_parameters.is_empty() {
661 let params = operation.parameters.get_or_insert_with(Vec::new);
662
663 for p in &self.extra_parameters {
664 if let Some(existing) = params
665 .iter_mut()
666 .find(|x| x.name == p.name && x.location == p.location)
667 {
668 *existing = p.clone();
669 } else {
670 params.push(p.clone());
671 }
672 }
673 }
674 }
675}
676
677fn intern_schema_if_object_named(
678 mut schema: OpenApiSchema,
679 schemas: &mut BTreeMap<String, OpenApiSchema>,
680) -> OpenApiSchema {
681 if schema.schema_ref.is_some() {
682 return schema;
683 }
684
685 let base = match schema.title.clone() {
686 Some(t) if !t.is_empty() => t,
687 _ => return schema,
688 };
689
690 if schema.properties.is_none() {
691 return schema;
692 }
693
694 let mut name = base.clone();
695 if schemas.contains_key(&name) {
696 let mut i = 2;
697 while schemas.contains_key(&format!("{base}_{i}")) {
698 i += 1;
699 }
700 name = format!("{base}_{i}");
701 }
702
703 schema.title = None;
704
705 schemas.entry(name.clone()).or_insert(schema);
706
707 OpenApiSchema::reference(&name)
708}
709
710fn schema_and_example_from_deserialize<T: DeserializeOwned>() -> Option<(OpenApiSchema, Value)> {
711 let mut probe = Probe::new();
712 let _ = T::deserialize(&mut probe);
713 probe.finish()
714}
715
716fn type_display_name<T>() -> String {
717 std::any::type_name::<T>()
718 .rsplit("::")
719 .next()
720 .unwrap_or("Model")
721 .to_string()
722}
723
724#[cfg(test)]
725#[allow(unused)]
726mod tests {
727 use super::{ConstraintTarget, IntoStatusCode, OpenApiRouteConfig, ResponseBody};
728 use crate::{
729 op::OpenApiOperation,
730 schema::{FieldConstraint, OpenApiSchema, SchemaConstraint},
731 };
732 use serde::{Deserialize, Serialize};
733 use serde_json::json;
734 use std::collections::BTreeMap;
735
736 #[derive(Deserialize)]
737 struct Payload {
738 name: String,
739 age: u64,
740 }
741
742 #[derive(Serialize, Default)]
743 struct ResponsePayload {
744 message: String,
745 }
746
747 #[derive(Deserialize)]
748 struct OptionalQuery {
749 required_name: String,
750 optional_age: Option<()>,
751 }
752
753 #[derive(Deserialize)]
754 #[allow(dead_code)]
755 struct Page {
756 page: u32,
757 }
758
759 #[test]
760 fn with_constraints_touches_only_the_parameter_its_extractor_added() {
761 let cfg = OpenApiRouteConfig::default();
764
765 let described_before = cfg.described_parameters();
766 let cfg = cfg.consumes_query::<Page>().with_constraints(
767 ConstraintTarget::QueryParameter { described_before },
768 &[FieldConstraint::new(
769 "page",
770 SchemaConstraint::Minimum(1.into()),
771 )],
772 );
773
774 let described_before = cfg.described_parameters();
775 let cfg = cfg.consumes_query::<Page>().with_constraints(
776 ConstraintTarget::QueryParameter { described_before },
777 &[FieldConstraint::new(
778 "page",
779 SchemaConstraint::Minimum(100.into()),
780 )],
781 );
782
783 let bounds = cfg
784 .extra_parameters
785 .iter()
786 .map(|parameter| parameter.schema.minimum.clone())
787 .collect::<Vec<_>>();
788
789 assert_eq!(bounds, vec![Some(1.into()), Some(100.into())]);
790 }
791
792 #[test]
793 fn with_constraints_of_an_extractor_that_described_nothing_reach_nobody() {
794 let cfg = OpenApiRouteConfig::default();
797 let described_before = cfg.described_parameters();
798 let cfg = cfg.consumes_query::<Page>().with_constraints(
799 ConstraintTarget::QueryParameter { described_before },
800 &[FieldConstraint::new(
801 "page",
802 SchemaConstraint::Minimum(1.into()),
803 )],
804 );
805
806 let described_before = cfg.described_parameters();
807 let cfg = cfg.with_constraints(
808 ConstraintTarget::QueryParameter { described_before },
809 &[FieldConstraint::new(
810 "page",
811 SchemaConstraint::Minimum(100.into()),
812 )],
813 );
814
815 assert_eq!(cfg.extra_parameters.len(), 1);
816 assert_eq!(cfg.extra_parameters[0].schema.minimum, Some(1.into()));
817 }
818
819 #[test]
820 fn with_constraints_intersects_a_bound_declared_twice() {
821 let cfg = OpenApiRouteConfig::default()
823 .consumes_query::<Page>()
824 .with_constraints(
825 ConstraintTarget::QueryParameter {
826 described_before: 0,
827 },
828 &[
829 FieldConstraint::new("page", SchemaConstraint::Minimum(10.into())),
830 FieldConstraint::new("page", SchemaConstraint::Minimum(1.into())),
831 FieldConstraint::new("page", SchemaConstraint::Maximum(50.into())),
832 FieldConstraint::new("page", SchemaConstraint::Maximum(500.into())),
833 ],
834 );
835
836 let schema = &cfg.extra_parameters[0].schema;
837
838 assert_eq!(schema.minimum, Some(10.into()));
839 assert_eq!(schema.maximum, Some(50.into()));
840 }
841
842 #[test]
843 fn consumes_query_keeps_parameters_of_a_typed_optional_field() {
844 #[derive(serde::Deserialize)]
845 #[allow(dead_code)]
846 struct OptionalString {
847 per_page: u32,
848 sort: Option<String>,
849 }
850
851 let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalString>();
852 let names = cfg
853 .extra_parameters
854 .iter()
855 .map(|p| p.name.as_str())
856 .collect::<Vec<_>>();
857
858 assert_eq!(names, vec!["per_page", "sort"]);
860 assert!(!cfg.extra_parameters[1].required);
861 }
862
863 #[test]
864 fn consumes_query_marks_non_optional_fields_as_required() {
865 let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalQuery>();
866
867 let required_name = cfg
868 .extra_parameters
869 .iter()
870 .find(|p| p.name == "required_name")
871 .expect("required_name param");
872 let optional_age = cfg
873 .extra_parameters
874 .iter()
875 .find(|p| p.name == "optional_age")
876 .expect("optional_age param");
877
878 assert!(required_name.required);
879 assert!(!optional_age.required);
880 }
881
882 #[test]
883 fn infer_request_schema_from_deserialize_type() {
884 let cfg = OpenApiRouteConfig::default()
885 .with_request_type_from_deserialize::<Payload>("application/json");
886
887 let schema = cfg
888 .request_schema
889 .expect("request schema should be inferred");
890 let props = schema.properties.expect("object properties should exist");
891
892 assert!(props.contains_key("name"));
893 assert!(props.contains_key("age"));
894 assert_eq!(
895 cfg.request_content_type.as_deref(),
896 Some("application/json")
897 );
898 }
899
900 #[test]
901 fn infer_query_parameters_from_deserialize_type() {
902 let cfg = OpenApiRouteConfig::default().with_query_parameters_from_deserialize::<Payload>();
903
904 assert_eq!(cfg.extra_parameters.len(), 2);
905 assert!(cfg.extra_parameters.iter().any(|p| p.name == "name"));
906 assert!(cfg.extra_parameters.iter().any(|p| p.name == "age"));
907 assert!(cfg.extra_parameters.iter().all(|p| p.location == "query"));
908 }
909
910 #[test]
911 fn merge_outer_keeps_the_route_ahead_of_the_scope_it_is_in() {
912 let group = OpenApiRouteConfig::default()
913 .with_tag("group")
914 .with_summary("group summary")
915 .with_query_parameter("id".into(), OpenApiSchema::string(), false);
916 let route = OpenApiRouteConfig::default()
917 .with_tag("route")
918 .with_query_parameter("id".into(), OpenApiSchema::integer(), true);
919
920 let merged = route.merge_outer(&group);
921
922 assert_eq!(merged.tags, vec!["group".to_string(), "route".to_string()]);
925 assert_eq!(merged.summary.as_deref(), Some("group summary"));
926 assert_eq!(merged.extra_parameters.len(), 2);
927 assert!(merged.extra_parameters[1].required);
928 }
929
930 #[test]
931 fn merge_combines_docs_and_preserves_existing_values() {
932 let base = OpenApiRouteConfig::default()
933 .with_tag("base")
934 .with_summary("base summary")
935 .with_doc("v1");
936 let other = OpenApiRouteConfig::default()
937 .with_tag("extra")
938 .with_summary("other summary")
939 .with_doc("v1")
940 .with_doc("admin");
941
942 let merged = base.merge(&other);
943
944 assert_eq!(merged.summary.as_deref(), Some("base summary"));
945 assert_eq!(merged.tags.as_slice(), ["base", "extra"]);
946 assert_eq!(merged.docs.expect("docs"), vec!["v1", "admin"]);
947 }
948
949 #[test]
950 fn apply_to_operation_adds_content_and_interns_named_schema() {
951 let cfg = OpenApiRouteConfig::default()
952 .with_request_schema(
953 OpenApiSchema::object()
954 .with_title("CreateUser")
955 .with_property("name", OpenApiSchema::string()),
956 )
957 .with_response_schema(
958 200u16,
959 OpenApiSchema::object()
960 .with_title("User")
961 .with_property("id", OpenApiSchema::integer()),
962 )
963 .produces_json_example(
964 200u16,
965 ResponsePayload {
966 message: "ok".to_string(),
967 },
968 )
969 .with_summary("create user")
970 .with_operation_id("createUser");
971
972 let mut op = OpenApiOperation::default();
973 let mut schemas = BTreeMap::new();
974
975 cfg.apply_to_operation(&mut op, &mut schemas);
976
977 let op_json = serde_json::to_value(op).expect("serialize operation");
978 assert_eq!(op_json["summary"], "create user");
979 assert_eq!(op_json["operationId"], "createUser");
980 assert!(
981 op_json["requestBody"]["content"]
982 .get("application/json")
983 .is_some()
984 );
985 assert_eq!(
986 op_json["responses"]["200"]["content"]["application/json"]["example"],
987 json!({"message":"ok"})
988 );
989
990 assert!(schemas.contains_key("CreateUser"));
991 }
992
993 #[test]
994 fn produces_no_schema_clears_content() {
995 let cfg = OpenApiRouteConfig::default()
996 .produces_json::<ResponsePayload>(200)
997 .produces_no_schema(200);
998
999 let mut op = OpenApiOperation::default();
1000 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1001
1002 let op_json = serde_json::to_value(op).expect("serialize operation");
1003 assert!(op_json["responses"]["200"].get("content").is_none());
1004 }
1005
1006 #[test]
1007 fn with_tags_extends_existing_tags() {
1008 let cfg = OpenApiRouteConfig::default()
1009 .with_tag("base")
1010 .with_tags(["one", "two"]);
1011
1012 assert_eq!(cfg.tags, vec!["base", "one", "two"]);
1013 }
1014
1015 #[test]
1016 fn with_docs_initializes_and_extends_docs() {
1017 let cfg = OpenApiRouteConfig::default()
1018 .with_docs(["v1", "admin"])
1019 .with_doc("internal");
1020
1021 assert_eq!(cfg.docs.expect("docs"), vec!["v1", "admin", "internal"]);
1022 }
1023
1024 #[test]
1025 fn with_description_sets_operation_description() {
1026 let cfg = OpenApiRouteConfig::default().with_description("desc");
1027 assert_eq!(cfg.description.as_deref(), Some("desc"));
1028 }
1029
1030 #[test]
1031 fn consumes_methods_set_request_content_type() {
1032 let query_cfg = OpenApiRouteConfig::default().consumes_query::<Payload>();
1033 assert_eq!(query_cfg.extra_parameters.len(), 2);
1034
1035 let json_cfg = OpenApiRouteConfig::default().consumes_json::<Payload>();
1036 assert_eq!(
1037 json_cfg.request_content_type.as_deref(),
1038 Some("application/json")
1039 );
1040 assert!(json_cfg.request_schema.is_some());
1041
1042 let form_cfg = OpenApiRouteConfig::default().consumes_form::<Payload>();
1043 assert_eq!(
1044 form_cfg.request_content_type.as_deref(),
1045 Some("application/x-www-form-urlencoded")
1046 );
1047 assert!(form_cfg.request_schema.is_some());
1048
1049 let multipart_cfg = OpenApiRouteConfig::default().consumes_multipart();
1050 assert_eq!(
1051 multipart_cfg.request_content_type.as_deref(),
1052 Some("multipart/form-data")
1053 );
1054
1055 let stream_cfg = OpenApiRouteConfig::default().consumes_stream();
1056 assert_eq!(
1057 stream_cfg.request_content_type.as_deref(),
1058 Some("application/octet-stream")
1059 );
1060 assert_eq!(
1061 stream_cfg.request_schema.expect("schema").format.as_deref(),
1062 Some("binary")
1063 );
1064 }
1065
1066 #[test]
1067 fn produces_helpers_set_expected_response_schema_and_content_type() {
1068 fn apply(cfg: OpenApiRouteConfig) -> serde_json::Value {
1069 let mut op = OpenApiOperation::default();
1070 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1071 serde_json::to_value(op).expect("serialize")
1072 }
1073
1074 let text_json = apply(OpenApiRouteConfig::default().produces_text(200));
1075 assert!(
1076 text_json["responses"]["200"]["content"]
1077 .get("text/plain; charset=utf-8")
1078 .is_some()
1079 );
1080 assert_eq!(
1081 text_json["responses"]["200"]["content"]["text/plain; charset=utf-8"]["schema"]["type"],
1082 "string"
1083 );
1084
1085 let empty_json_json = apply(OpenApiRouteConfig::default().produces_empty_json(200));
1086 assert!(
1087 empty_json_json["responses"]["200"]["content"]
1088 .get("application/json")
1089 .is_some()
1090 );
1091 assert_eq!(
1092 empty_json_json["responses"]["200"]["content"]["application/json"]["schema"]["type"],
1093 "object"
1094 );
1095
1096 let form_json = apply(OpenApiRouteConfig::default().produces_form::<ResponsePayload>(200));
1097 assert!(
1098 form_json["responses"]["200"]["content"]
1099 .get("application/x-www-form-urlencoded")
1100 .is_some()
1101 );
1102 assert_eq!(
1103 form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1104 ["type"],
1105 "string"
1106 );
1107
1108 let empty_form_json = apply(OpenApiRouteConfig::default().produces_empty_form(200));
1109 assert!(
1110 empty_form_json["responses"]["200"]["content"]
1111 .get("application/x-www-form-urlencoded")
1112 .is_some()
1113 );
1114 assert_eq!(
1115 empty_form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1116 ["type"],
1117 "object"
1118 );
1119
1120 let stream_json = apply(OpenApiRouteConfig::default().produces_stream(200));
1121 assert!(
1122 stream_json["responses"]["200"]["content"]
1123 .get("application/octet-stream")
1124 .is_some()
1125 );
1126 assert_eq!(
1127 stream_json["responses"]["200"]["content"]["application/octet-stream"]["schema"]["format"],
1128 "binary"
1129 );
1130
1131 let sse_json = apply(OpenApiRouteConfig::default().produces_sse(200));
1132 assert!(
1133 sse_json["responses"]["200"]["content"]
1134 .get("text/event-stream")
1135 .is_some()
1136 );
1137 assert_eq!(
1138 sse_json["responses"]["200"]["content"]["text/event-stream"]["schema"]["title"],
1139 "SSE stream"
1140 );
1141 }
1142
1143 #[test]
1144 fn produces_form_example_encodes_object_into_string_example() {
1145 #[derive(Serialize)]
1146 struct FormOut {
1147 first: String,
1148 second: i32,
1149 }
1150
1151 let cfg = OpenApiRouteConfig::default().produces_form_example(
1152 200u16,
1153 FormOut {
1154 first: "hello".to_string(),
1155 second: 7,
1156 },
1157 );
1158
1159 let mut op = OpenApiOperation::default();
1160 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1161 let op_json = serde_json::to_value(op).expect("serialize");
1162
1163 assert!(
1164 op_json["responses"]["200"]["content"]
1165 .get("application/x-www-form-urlencoded")
1166 .is_some()
1167 );
1168 assert_eq!(
1169 op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]["type"],
1170 "string"
1171 );
1172 assert_eq!(
1173 op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["example"],
1174 "first=hello&second=7"
1175 );
1176 }
1177
1178 #[test]
1179 fn produces_multiple_status_codes() {
1180 #[derive(Serialize, Default)]
1181 struct UserDto {
1182 id: u64,
1183 }
1184 #[derive(Serialize, Default)]
1185 struct ErrorDto {
1186 message: String,
1187 }
1188
1189 let cfg = OpenApiRouteConfig::default()
1190 .produces_json::<UserDto>(200)
1191 .produces_json::<ErrorDto>(404);
1192
1193 let mut op = OpenApiOperation::default();
1194 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1195 let op_json = serde_json::to_value(op).expect("serialize");
1196
1197 assert!(
1199 op_json["responses"]["200"]["content"]
1200 .get("application/json")
1201 .is_some()
1202 );
1203 assert!(
1204 op_json["responses"]["404"]["content"]
1205 .get("application/json")
1206 .is_some()
1207 );
1208 assert_eq!(
1210 op_json["responses"]
1211 .as_object()
1212 .expect("responses object")
1213 .len(),
1214 2
1215 );
1216 }
1217
1218 #[test]
1219 fn explicit_responses_replace_default_200() {
1220 let cfg = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(201);
1221
1222 let mut op = OpenApiOperation::default();
1223 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1224 let op_json = serde_json::to_value(op).expect("serialize");
1225
1226 assert!(op_json["responses"].get("200").is_none());
1228 assert!(
1229 op_json["responses"]["201"]["content"]
1230 .get("application/json")
1231 .is_some()
1232 );
1233 assert_eq!(op_json["responses"]["201"]["description"], "Created");
1234 }
1235
1236 #[test]
1237 fn merge_does_not_override_existing_responses() {
1238 let base = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(200);
1239 let other = OpenApiRouteConfig::default()
1240 .produces_text(200)
1241 .produces_json::<ResponsePayload>(404);
1242
1243 let merged = base.merge(&other);
1244
1245 let mut op = OpenApiOperation::default();
1246 merged.apply_to_operation(&mut op, &mut BTreeMap::new());
1247 let op_json = serde_json::to_value(op).expect("serialize");
1248
1249 assert!(
1251 op_json["responses"]["200"]["content"]
1252 .get("application/json")
1253 .is_some()
1254 );
1255 assert!(
1256 op_json["responses"]["200"]["content"]
1257 .get("text/plain; charset=utf-8")
1258 .is_none()
1259 );
1260 assert!(
1262 op_json["responses"]["404"]["content"]
1263 .get("application/json")
1264 .is_some()
1265 );
1266 }
1267
1268 #[test]
1269 fn into_status_code_works_for_u16_and_http_status_code() {
1270 assert_eq!(200u16.into_status_code(), 200);
1271 assert_eq!(201.into_status_code(), 201);
1272 assert_eq!(202u32.into_status_code(), 202);
1273 assert_eq!(http::StatusCode::NOT_FOUND.into_status_code(), 404);
1274 assert_eq!(http::StatusCode::CREATED.into_status_code(), 201);
1275 }
1276
1277 #[cfg(feature = "problem-details")]
1278 mod problem_tests {
1279 use super::*;
1280
1281 #[test]
1282 fn produces_problem_generates_problem_json_schema_for_status() {
1283 let cfg = OpenApiRouteConfig::default().produces_problem(400u16);
1284 let mut op = OpenApiOperation::default();
1285 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1286 let op_json = serde_json::to_value(op).expect("serialize");
1287
1288 let content = &op_json["responses"]["400"]["content"]["application/problem+json"];
1289 assert!(
1290 content.is_object(),
1291 "application/problem+json content should exist"
1292 );
1293 assert_eq!(content["example"]["title"], "Bad Request");
1294 assert_eq!(content["example"]["status"], 400);
1295 assert_eq!(
1296 content["example"]["type"],
1297 "https://tools.ietf.org/html/rfc9110"
1298 );
1299 assert_eq!(op_json["responses"]["400"]["description"], "Bad Request");
1300 assert!(op_json["responses"].get("200").is_none());
1302 }
1303
1304 #[test]
1305 fn produces_problem_example_uses_provided_value() {
1306 #[derive(Serialize)]
1307 struct CustomProblem {
1308 r#type: String,
1309 title: String,
1310 status: u16,
1311 detail: String,
1312 }
1313
1314 let cfg = OpenApiRouteConfig::default().produces_problem_example(
1315 422u16,
1316 CustomProblem {
1317 r#type: "https://tools.ietf.org/html/rfc9110".into(),
1318 title: "Unprocessable Content".into(),
1319 status: 422,
1320 detail: "Validation failed".into(),
1321 },
1322 );
1323
1324 let mut op = OpenApiOperation::default();
1325 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1326 let op_json = serde_json::to_value(op).expect("serialize");
1327
1328 let content = &op_json["responses"]["422"]["content"]["application/problem+json"];
1329 assert!(
1330 content.is_object(),
1331 "application/problem+json content should exist"
1332 );
1333 assert_eq!(content["example"]["detail"], "Validation failed");
1334 assert_eq!(content["example"]["status"], 422);
1335 assert_eq!(
1336 op_json["responses"]["422"]["description"],
1337 "Unprocessable Entity"
1338 );
1339 }
1340
1341 #[test]
1342 fn produces_problem_composes_with_other_status_responses() {
1343 let cfg = OpenApiRouteConfig::default()
1344 .produces_json::<ResponsePayload>(200)
1345 .produces_problem(400)
1346 .produces_problem(500);
1347
1348 let mut op = OpenApiOperation::default();
1349 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1350 let op_json = serde_json::to_value(op).expect("serialize");
1351
1352 assert!(
1353 op_json["responses"]["200"]["content"]
1354 .get("application/json")
1355 .is_some()
1356 );
1357 assert!(
1358 op_json["responses"]["400"]["content"]
1359 .get("application/problem+json")
1360 .is_some()
1361 );
1362 assert!(
1363 op_json["responses"]["500"]["content"]
1364 .get("application/problem+json")
1365 .is_some()
1366 );
1367 assert_eq!(op_json["responses"].as_object().unwrap().len(), 3);
1368 }
1369 }
1370
1371 #[test]
1372 fn produces_multipart_sets_response_content_type() {
1373 let cfg = OpenApiRouteConfig::default().produces_multipart(200u16);
1374 let resp = cfg.responses.get(&200u16).expect("200 response defined");
1375 match resp {
1376 ResponseBody::Content { content_type, .. } => {
1377 assert_eq!(content_type, "multipart/form-data");
1378 }
1379 _ => panic!("expected Content variant"),
1380 }
1381 }
1382}