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(mut self, other: &Self) -> Self {
544 if self.tags.is_empty() {
545 self.tags = other.tags.clone();
546 } else {
547 self.tags.extend(other.tags.clone());
548 }
549 if self.summary.is_none() {
550 self.summary = other.summary.clone();
551 }
552 if self.description.is_none() {
553 self.description = other.description.clone();
554 }
555 if self.operation_id.is_none() {
556 self.operation_id = other.operation_id.clone();
557 }
558 if self.request_schema.is_none() {
559 self.request_schema = other.request_schema.clone();
560 }
561 if self.request_example.is_none() {
562 self.request_example = other.request_example.clone();
563 }
564 if self.request_content_type.is_none() {
565 self.request_content_type = other.request_content_type.clone();
566 }
567 for (status, body) in &other.responses {
568 self.responses
569 .entry(*status)
570 .or_insert_with(|| body.clone());
571 }
572 if !other.extra_parameters.is_empty() {
573 self.extra_parameters.extend(other.extra_parameters.clone());
574 }
575 match (&mut self.docs, &other.docs) {
576 (None, Some(d)) => self.docs = Some(d.clone()),
577 (Some(dst), Some(src)) => {
578 for s in src {
579 if !dst.iter().any(|x| x == s) {
580 dst.push(s.clone());
581 }
582 }
583 }
584 _ => {}
585 }
586 self
587 }
588
589 pub(super) fn apply_to_operation(
590 &self,
591 operation: &mut OpenApiOperation,
592 schemas: &mut BTreeMap<String, OpenApiSchema>,
593 ) {
594 if let Some(summary) = &self.summary {
595 operation.summary = Some(summary.clone());
596 }
597 if let Some(description) = &self.description {
598 operation.description = Some(description.clone());
599 }
600 if let Some(operation_id) = &self.operation_id {
601 operation.operation_id = Some(operation_id.clone());
602 }
603
604 if !self.tags.is_empty() {
605 operation.tags = Some(self.tags.clone());
606 }
607
608 if self.request_schema.is_some() || self.request_example.is_some() {
609 let mut schema = self
610 .request_schema
611 .clone()
612 .unwrap_or_else(OpenApiSchema::object);
613 let example = self.request_example.clone();
614 let content_type = self
615 .request_content_type
616 .as_deref()
617 .unwrap_or(APPLICATION_JSON.as_ref());
618
619 schema = intern_schema_if_object_named(schema, schemas);
620 operation.set_request_body(schema, example, content_type);
621 }
622
623 if !self.responses.is_empty() {
624 operation.clear_all_responses();
625 for (status, body) in &self.responses {
626 match body {
627 ResponseBody::NoContent => {
628 operation.clear_response_body(*status);
629 }
630 ResponseBody::Content {
631 schema,
632 example,
633 content_type,
634 } => {
635 let schema = intern_schema_if_object_named((**schema).clone(), schemas);
636 operation.set_response_body(*status, schema, example.clone(), content_type);
637 }
638 }
639 }
640 }
641
642 if !self.extra_parameters.is_empty() {
643 let params = operation.parameters.get_or_insert_with(Vec::new);
644
645 for p in &self.extra_parameters {
646 if let Some(existing) = params
647 .iter_mut()
648 .find(|x| x.name == p.name && x.location == p.location)
649 {
650 *existing = p.clone();
651 } else {
652 params.push(p.clone());
653 }
654 }
655 }
656 }
657}
658
659fn intern_schema_if_object_named(
660 mut schema: OpenApiSchema,
661 schemas: &mut BTreeMap<String, OpenApiSchema>,
662) -> OpenApiSchema {
663 if schema.schema_ref.is_some() {
664 return schema;
665 }
666
667 let base = match schema.title.clone() {
668 Some(t) if !t.is_empty() => t,
669 _ => return schema,
670 };
671
672 if schema.properties.is_none() {
673 return schema;
674 }
675
676 let mut name = base.clone();
677 if schemas.contains_key(&name) {
678 let mut i = 2;
679 while schemas.contains_key(&format!("{base}_{i}")) {
680 i += 1;
681 }
682 name = format!("{base}_{i}");
683 }
684
685 schema.title = None;
686
687 schemas.entry(name.clone()).or_insert(schema);
688
689 OpenApiSchema::reference(&name)
690}
691
692fn schema_and_example_from_deserialize<T: DeserializeOwned>() -> Option<(OpenApiSchema, Value)> {
693 let mut probe = Probe::new();
694 let _ = T::deserialize(&mut probe);
695 probe.finish()
696}
697
698fn type_display_name<T>() -> String {
699 std::any::type_name::<T>()
700 .rsplit("::")
701 .next()
702 .unwrap_or("Model")
703 .to_string()
704}
705
706#[cfg(test)]
707#[allow(unused)]
708mod tests {
709 use super::{ConstraintTarget, IntoStatusCode, OpenApiRouteConfig, ResponseBody};
710 use crate::{
711 op::OpenApiOperation,
712 schema::{FieldConstraint, OpenApiSchema, SchemaConstraint},
713 };
714 use serde::{Deserialize, Serialize};
715 use serde_json::json;
716 use std::collections::BTreeMap;
717
718 #[derive(Deserialize)]
719 struct Payload {
720 name: String,
721 age: u64,
722 }
723
724 #[derive(Serialize, Default)]
725 struct ResponsePayload {
726 message: String,
727 }
728
729 #[derive(Deserialize)]
730 struct OptionalQuery {
731 required_name: String,
732 optional_age: Option<()>,
733 }
734
735 #[derive(Deserialize)]
736 #[allow(dead_code)]
737 struct Page {
738 page: u32,
739 }
740
741 #[test]
742 fn with_constraints_touches_only_the_parameter_its_extractor_added() {
743 let cfg = OpenApiRouteConfig::default();
746
747 let described_before = cfg.described_parameters();
748 let cfg = cfg.consumes_query::<Page>().with_constraints(
749 ConstraintTarget::QueryParameter { described_before },
750 &[FieldConstraint::new(
751 "page",
752 SchemaConstraint::Minimum(1.into()),
753 )],
754 );
755
756 let described_before = cfg.described_parameters();
757 let cfg = cfg.consumes_query::<Page>().with_constraints(
758 ConstraintTarget::QueryParameter { described_before },
759 &[FieldConstraint::new(
760 "page",
761 SchemaConstraint::Minimum(100.into()),
762 )],
763 );
764
765 let bounds = cfg
766 .extra_parameters
767 .iter()
768 .map(|parameter| parameter.schema.minimum.clone())
769 .collect::<Vec<_>>();
770
771 assert_eq!(bounds, vec![Some(1.into()), Some(100.into())]);
772 }
773
774 #[test]
775 fn with_constraints_of_an_extractor_that_described_nothing_reach_nobody() {
776 let cfg = OpenApiRouteConfig::default();
779 let described_before = cfg.described_parameters();
780 let cfg = cfg.consumes_query::<Page>().with_constraints(
781 ConstraintTarget::QueryParameter { described_before },
782 &[FieldConstraint::new(
783 "page",
784 SchemaConstraint::Minimum(1.into()),
785 )],
786 );
787
788 let described_before = cfg.described_parameters();
789 let cfg = cfg.with_constraints(
790 ConstraintTarget::QueryParameter { described_before },
791 &[FieldConstraint::new(
792 "page",
793 SchemaConstraint::Minimum(100.into()),
794 )],
795 );
796
797 assert_eq!(cfg.extra_parameters.len(), 1);
798 assert_eq!(cfg.extra_parameters[0].schema.minimum, Some(1.into()));
799 }
800
801 #[test]
802 fn with_constraints_intersects_a_bound_declared_twice() {
803 let cfg = OpenApiRouteConfig::default()
805 .consumes_query::<Page>()
806 .with_constraints(
807 ConstraintTarget::QueryParameter {
808 described_before: 0,
809 },
810 &[
811 FieldConstraint::new("page", SchemaConstraint::Minimum(10.into())),
812 FieldConstraint::new("page", SchemaConstraint::Minimum(1.into())),
813 FieldConstraint::new("page", SchemaConstraint::Maximum(50.into())),
814 FieldConstraint::new("page", SchemaConstraint::Maximum(500.into())),
815 ],
816 );
817
818 let schema = &cfg.extra_parameters[0].schema;
819
820 assert_eq!(schema.minimum, Some(10.into()));
821 assert_eq!(schema.maximum, Some(50.into()));
822 }
823
824 #[test]
825 fn consumes_query_keeps_parameters_of_a_typed_optional_field() {
826 #[derive(serde::Deserialize)]
827 #[allow(dead_code)]
828 struct OptionalString {
829 per_page: u32,
830 sort: Option<String>,
831 }
832
833 let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalString>();
834 let names = cfg
835 .extra_parameters
836 .iter()
837 .map(|p| p.name.as_str())
838 .collect::<Vec<_>>();
839
840 assert_eq!(names, vec!["per_page", "sort"]);
842 assert!(!cfg.extra_parameters[1].required);
843 }
844
845 #[test]
846 fn consumes_query_marks_non_optional_fields_as_required() {
847 let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalQuery>();
848
849 let required_name = cfg
850 .extra_parameters
851 .iter()
852 .find(|p| p.name == "required_name")
853 .expect("required_name param");
854 let optional_age = cfg
855 .extra_parameters
856 .iter()
857 .find(|p| p.name == "optional_age")
858 .expect("optional_age param");
859
860 assert!(required_name.required);
861 assert!(!optional_age.required);
862 }
863
864 #[test]
865 fn infer_request_schema_from_deserialize_type() {
866 let cfg = OpenApiRouteConfig::default()
867 .with_request_type_from_deserialize::<Payload>("application/json");
868
869 let schema = cfg
870 .request_schema
871 .expect("request schema should be inferred");
872 let props = schema.properties.expect("object properties should exist");
873
874 assert!(props.contains_key("name"));
875 assert!(props.contains_key("age"));
876 assert_eq!(
877 cfg.request_content_type.as_deref(),
878 Some("application/json")
879 );
880 }
881
882 #[test]
883 fn infer_query_parameters_from_deserialize_type() {
884 let cfg = OpenApiRouteConfig::default().with_query_parameters_from_deserialize::<Payload>();
885
886 assert_eq!(cfg.extra_parameters.len(), 2);
887 assert!(cfg.extra_parameters.iter().any(|p| p.name == "name"));
888 assert!(cfg.extra_parameters.iter().any(|p| p.name == "age"));
889 assert!(cfg.extra_parameters.iter().all(|p| p.location == "query"));
890 }
891
892 #[test]
893 fn merge_combines_docs_and_preserves_existing_values() {
894 let base = OpenApiRouteConfig::default()
895 .with_tag("base")
896 .with_summary("base summary")
897 .with_doc("v1");
898 let other = OpenApiRouteConfig::default()
899 .with_tag("extra")
900 .with_summary("other summary")
901 .with_doc("v1")
902 .with_doc("admin");
903
904 let merged = base.merge(&other);
905
906 assert_eq!(merged.summary.as_deref(), Some("base summary"));
907 assert_eq!(merged.tags.as_slice(), ["base", "extra"]);
908 assert_eq!(merged.docs.expect("docs"), vec!["v1", "admin"]);
909 }
910
911 #[test]
912 fn apply_to_operation_adds_content_and_interns_named_schema() {
913 let cfg = OpenApiRouteConfig::default()
914 .with_request_schema(
915 OpenApiSchema::object()
916 .with_title("CreateUser")
917 .with_property("name", OpenApiSchema::string()),
918 )
919 .with_response_schema(
920 200u16,
921 OpenApiSchema::object()
922 .with_title("User")
923 .with_property("id", OpenApiSchema::integer()),
924 )
925 .produces_json_example(
926 200u16,
927 ResponsePayload {
928 message: "ok".to_string(),
929 },
930 )
931 .with_summary("create user")
932 .with_operation_id("createUser");
933
934 let mut op = OpenApiOperation::default();
935 let mut schemas = BTreeMap::new();
936
937 cfg.apply_to_operation(&mut op, &mut schemas);
938
939 let op_json = serde_json::to_value(op).expect("serialize operation");
940 assert_eq!(op_json["summary"], "create user");
941 assert_eq!(op_json["operationId"], "createUser");
942 assert!(
943 op_json["requestBody"]["content"]
944 .get("application/json")
945 .is_some()
946 );
947 assert_eq!(
948 op_json["responses"]["200"]["content"]["application/json"]["example"],
949 json!({"message":"ok"})
950 );
951
952 assert!(schemas.contains_key("CreateUser"));
953 }
954
955 #[test]
956 fn produces_no_schema_clears_content() {
957 let cfg = OpenApiRouteConfig::default()
958 .produces_json::<ResponsePayload>(200)
959 .produces_no_schema(200);
960
961 let mut op = OpenApiOperation::default();
962 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
963
964 let op_json = serde_json::to_value(op).expect("serialize operation");
965 assert!(op_json["responses"]["200"].get("content").is_none());
966 }
967
968 #[test]
969 fn with_tags_extends_existing_tags() {
970 let cfg = OpenApiRouteConfig::default()
971 .with_tag("base")
972 .with_tags(["one", "two"]);
973
974 assert_eq!(cfg.tags, vec!["base", "one", "two"]);
975 }
976
977 #[test]
978 fn with_docs_initializes_and_extends_docs() {
979 let cfg = OpenApiRouteConfig::default()
980 .with_docs(["v1", "admin"])
981 .with_doc("internal");
982
983 assert_eq!(cfg.docs.expect("docs"), vec!["v1", "admin", "internal"]);
984 }
985
986 #[test]
987 fn with_description_sets_operation_description() {
988 let cfg = OpenApiRouteConfig::default().with_description("desc");
989 assert_eq!(cfg.description.as_deref(), Some("desc"));
990 }
991
992 #[test]
993 fn consumes_methods_set_request_content_type() {
994 let query_cfg = OpenApiRouteConfig::default().consumes_query::<Payload>();
995 assert_eq!(query_cfg.extra_parameters.len(), 2);
996
997 let json_cfg = OpenApiRouteConfig::default().consumes_json::<Payload>();
998 assert_eq!(
999 json_cfg.request_content_type.as_deref(),
1000 Some("application/json")
1001 );
1002 assert!(json_cfg.request_schema.is_some());
1003
1004 let form_cfg = OpenApiRouteConfig::default().consumes_form::<Payload>();
1005 assert_eq!(
1006 form_cfg.request_content_type.as_deref(),
1007 Some("application/x-www-form-urlencoded")
1008 );
1009 assert!(form_cfg.request_schema.is_some());
1010
1011 let multipart_cfg = OpenApiRouteConfig::default().consumes_multipart();
1012 assert_eq!(
1013 multipart_cfg.request_content_type.as_deref(),
1014 Some("multipart/form-data")
1015 );
1016
1017 let stream_cfg = OpenApiRouteConfig::default().consumes_stream();
1018 assert_eq!(
1019 stream_cfg.request_content_type.as_deref(),
1020 Some("application/octet-stream")
1021 );
1022 assert_eq!(
1023 stream_cfg.request_schema.expect("schema").format.as_deref(),
1024 Some("binary")
1025 );
1026 }
1027
1028 #[test]
1029 fn produces_helpers_set_expected_response_schema_and_content_type() {
1030 fn apply(cfg: OpenApiRouteConfig) -> serde_json::Value {
1031 let mut op = OpenApiOperation::default();
1032 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1033 serde_json::to_value(op).expect("serialize")
1034 }
1035
1036 let text_json = apply(OpenApiRouteConfig::default().produces_text(200));
1037 assert!(
1038 text_json["responses"]["200"]["content"]
1039 .get("text/plain; charset=utf-8")
1040 .is_some()
1041 );
1042 assert_eq!(
1043 text_json["responses"]["200"]["content"]["text/plain; charset=utf-8"]["schema"]["type"],
1044 "string"
1045 );
1046
1047 let empty_json_json = apply(OpenApiRouteConfig::default().produces_empty_json(200));
1048 assert!(
1049 empty_json_json["responses"]["200"]["content"]
1050 .get("application/json")
1051 .is_some()
1052 );
1053 assert_eq!(
1054 empty_json_json["responses"]["200"]["content"]["application/json"]["schema"]["type"],
1055 "object"
1056 );
1057
1058 let form_json = apply(OpenApiRouteConfig::default().produces_form::<ResponsePayload>(200));
1059 assert!(
1060 form_json["responses"]["200"]["content"]
1061 .get("application/x-www-form-urlencoded")
1062 .is_some()
1063 );
1064 assert_eq!(
1065 form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1066 ["type"],
1067 "string"
1068 );
1069
1070 let empty_form_json = apply(OpenApiRouteConfig::default().produces_empty_form(200));
1071 assert!(
1072 empty_form_json["responses"]["200"]["content"]
1073 .get("application/x-www-form-urlencoded")
1074 .is_some()
1075 );
1076 assert_eq!(
1077 empty_form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1078 ["type"],
1079 "object"
1080 );
1081
1082 let stream_json = apply(OpenApiRouteConfig::default().produces_stream(200));
1083 assert!(
1084 stream_json["responses"]["200"]["content"]
1085 .get("application/octet-stream")
1086 .is_some()
1087 );
1088 assert_eq!(
1089 stream_json["responses"]["200"]["content"]["application/octet-stream"]["schema"]["format"],
1090 "binary"
1091 );
1092
1093 let sse_json = apply(OpenApiRouteConfig::default().produces_sse(200));
1094 assert!(
1095 sse_json["responses"]["200"]["content"]
1096 .get("text/event-stream")
1097 .is_some()
1098 );
1099 assert_eq!(
1100 sse_json["responses"]["200"]["content"]["text/event-stream"]["schema"]["title"],
1101 "SSE stream"
1102 );
1103 }
1104
1105 #[test]
1106 fn produces_form_example_encodes_object_into_string_example() {
1107 #[derive(Serialize)]
1108 struct FormOut {
1109 first: String,
1110 second: i32,
1111 }
1112
1113 let cfg = OpenApiRouteConfig::default().produces_form_example(
1114 200u16,
1115 FormOut {
1116 first: "hello".to_string(),
1117 second: 7,
1118 },
1119 );
1120
1121 let mut op = OpenApiOperation::default();
1122 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1123 let op_json = serde_json::to_value(op).expect("serialize");
1124
1125 assert!(
1126 op_json["responses"]["200"]["content"]
1127 .get("application/x-www-form-urlencoded")
1128 .is_some()
1129 );
1130 assert_eq!(
1131 op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]["type"],
1132 "string"
1133 );
1134 assert_eq!(
1135 op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["example"],
1136 "first=hello&second=7"
1137 );
1138 }
1139
1140 #[test]
1141 fn produces_multiple_status_codes() {
1142 #[derive(Serialize, Default)]
1143 struct UserDto {
1144 id: u64,
1145 }
1146 #[derive(Serialize, Default)]
1147 struct ErrorDto {
1148 message: String,
1149 }
1150
1151 let cfg = OpenApiRouteConfig::default()
1152 .produces_json::<UserDto>(200)
1153 .produces_json::<ErrorDto>(404);
1154
1155 let mut op = OpenApiOperation::default();
1156 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1157 let op_json = serde_json::to_value(op).expect("serialize");
1158
1159 assert!(
1161 op_json["responses"]["200"]["content"]
1162 .get("application/json")
1163 .is_some()
1164 );
1165 assert!(
1166 op_json["responses"]["404"]["content"]
1167 .get("application/json")
1168 .is_some()
1169 );
1170 assert_eq!(
1172 op_json["responses"]
1173 .as_object()
1174 .expect("responses object")
1175 .len(),
1176 2
1177 );
1178 }
1179
1180 #[test]
1181 fn explicit_responses_replace_default_200() {
1182 let cfg = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(201);
1183
1184 let mut op = OpenApiOperation::default();
1185 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1186 let op_json = serde_json::to_value(op).expect("serialize");
1187
1188 assert!(op_json["responses"].get("200").is_none());
1190 assert!(
1191 op_json["responses"]["201"]["content"]
1192 .get("application/json")
1193 .is_some()
1194 );
1195 assert_eq!(op_json["responses"]["201"]["description"], "Created");
1196 }
1197
1198 #[test]
1199 fn merge_does_not_override_existing_responses() {
1200 let base = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(200);
1201 let other = OpenApiRouteConfig::default()
1202 .produces_text(200)
1203 .produces_json::<ResponsePayload>(404);
1204
1205 let merged = base.merge(&other);
1206
1207 let mut op = OpenApiOperation::default();
1208 merged.apply_to_operation(&mut op, &mut BTreeMap::new());
1209 let op_json = serde_json::to_value(op).expect("serialize");
1210
1211 assert!(
1213 op_json["responses"]["200"]["content"]
1214 .get("application/json")
1215 .is_some()
1216 );
1217 assert!(
1218 op_json["responses"]["200"]["content"]
1219 .get("text/plain; charset=utf-8")
1220 .is_none()
1221 );
1222 assert!(
1224 op_json["responses"]["404"]["content"]
1225 .get("application/json")
1226 .is_some()
1227 );
1228 }
1229
1230 #[test]
1231 fn into_status_code_works_for_u16_and_http_status_code() {
1232 assert_eq!(200u16.into_status_code(), 200);
1233 assert_eq!(201.into_status_code(), 201);
1234 assert_eq!(202u32.into_status_code(), 202);
1235 assert_eq!(http::StatusCode::NOT_FOUND.into_status_code(), 404);
1236 assert_eq!(http::StatusCode::CREATED.into_status_code(), 201);
1237 }
1238
1239 #[cfg(feature = "problem-details")]
1240 mod problem_tests {
1241 use super::*;
1242
1243 #[test]
1244 fn produces_problem_generates_problem_json_schema_for_status() {
1245 let cfg = OpenApiRouteConfig::default().produces_problem(400u16);
1246 let mut op = OpenApiOperation::default();
1247 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1248 let op_json = serde_json::to_value(op).expect("serialize");
1249
1250 let content = &op_json["responses"]["400"]["content"]["application/problem+json"];
1251 assert!(
1252 content.is_object(),
1253 "application/problem+json content should exist"
1254 );
1255 assert_eq!(content["example"]["title"], "Bad Request");
1256 assert_eq!(content["example"]["status"], 400);
1257 assert_eq!(
1258 content["example"]["type"],
1259 "https://tools.ietf.org/html/rfc9110"
1260 );
1261 assert_eq!(op_json["responses"]["400"]["description"], "Bad Request");
1262 assert!(op_json["responses"].get("200").is_none());
1264 }
1265
1266 #[test]
1267 fn produces_problem_example_uses_provided_value() {
1268 #[derive(Serialize)]
1269 struct CustomProblem {
1270 r#type: String,
1271 title: String,
1272 status: u16,
1273 detail: String,
1274 }
1275
1276 let cfg = OpenApiRouteConfig::default().produces_problem_example(
1277 422u16,
1278 CustomProblem {
1279 r#type: "https://tools.ietf.org/html/rfc9110".into(),
1280 title: "Unprocessable Content".into(),
1281 status: 422,
1282 detail: "Validation failed".into(),
1283 },
1284 );
1285
1286 let mut op = OpenApiOperation::default();
1287 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1288 let op_json = serde_json::to_value(op).expect("serialize");
1289
1290 let content = &op_json["responses"]["422"]["content"]["application/problem+json"];
1291 assert!(
1292 content.is_object(),
1293 "application/problem+json content should exist"
1294 );
1295 assert_eq!(content["example"]["detail"], "Validation failed");
1296 assert_eq!(content["example"]["status"], 422);
1297 assert_eq!(
1298 op_json["responses"]["422"]["description"],
1299 "Unprocessable Entity"
1300 );
1301 }
1302
1303 #[test]
1304 fn produces_problem_composes_with_other_status_responses() {
1305 let cfg = OpenApiRouteConfig::default()
1306 .produces_json::<ResponsePayload>(200)
1307 .produces_problem(400)
1308 .produces_problem(500);
1309
1310 let mut op = OpenApiOperation::default();
1311 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1312 let op_json = serde_json::to_value(op).expect("serialize");
1313
1314 assert!(
1315 op_json["responses"]["200"]["content"]
1316 .get("application/json")
1317 .is_some()
1318 );
1319 assert!(
1320 op_json["responses"]["400"]["content"]
1321 .get("application/problem+json")
1322 .is_some()
1323 );
1324 assert!(
1325 op_json["responses"]["500"]["content"]
1326 .get("application/problem+json")
1327 .is_some()
1328 );
1329 assert_eq!(op_json["responses"].as_object().unwrap().len(), 3);
1330 }
1331 }
1332
1333 #[test]
1334 fn produces_multipart_sets_response_content_type() {
1335 let cfg = OpenApiRouteConfig::default().produces_multipart(200u16);
1336 let resp = cfg.responses.get(&200u16).expect("200 response defined");
1337 match resp {
1338 ResponseBody::Content { content_type, .. } => {
1339 assert_eq!(content_type, "multipart/form-data");
1340 }
1341 _ => panic!("expected Content variant"),
1342 }
1343 }
1344}