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::{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, Debug, Default)]
65pub struct OpenApiRouteConfig {
66 tags: Vec<String>,
67 docs: Option<Vec<String>>,
68 summary: Option<String>,
69 description: Option<String>,
70 operation_id: Option<String>,
71 request_schema: Option<OpenApiSchema>,
72 request_example: Option<Value>,
73 request_content_type: Option<String>,
74 responses: BTreeMap<u16, ResponseBody>,
75 extra_parameters: Vec<OpenApiParameter>,
76}
77
78impl OpenApiRouteConfig {
79 pub(crate) fn docs(&self) -> Option<&[String]> {
81 self.docs.as_deref()
82 }
83
84 pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
86 self.tags.push(tag.into());
87 self
88 }
89
90 pub fn with_tags<I, T>(mut self, tags: I) -> Self
92 where
93 I: IntoIterator<Item = T>,
94 T: Into<String>,
95 {
96 self.tags.extend(tags.into_iter().map(Into::into));
97 self
98 }
99
100 pub fn with_doc(mut self, doc: impl Into<String>) -> Self {
102 self.docs.get_or_insert_with(Vec::new).push(doc.into());
103 self
104 }
105
106 pub fn with_docs<I, S>(mut self, docs: I) -> Self
108 where
109 I: IntoIterator<Item = S>,
110 S: Into<String>,
111 {
112 self.docs
113 .get_or_insert_with(Vec::new)
114 .extend(docs.into_iter().map(Into::into));
115 self
116 }
117
118 pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
120 self.summary = Some(summary.into());
121 self
122 }
123
124 pub fn with_description(mut self, description: impl Into<String>) -> Self {
126 self.description = Some(description.into());
127 self
128 }
129
130 pub fn with_operation_id(mut self, operation_id: impl Into<String>) -> Self {
132 self.operation_id = Some(operation_id.into());
133 self
134 }
135
136 pub fn with_request_schema(mut self, schema: OpenApiSchema) -> Self {
138 self.request_schema = Some(schema);
139 self
140 }
141
142 pub fn with_response_schema(
144 mut self,
145 status: impl IntoStatusCode,
146 schema: OpenApiSchema,
147 ) -> Self {
148 let status = status.into_status_code();
149 self.responses.insert(
150 status,
151 ResponseBody::Content {
152 schema: Box::new(schema),
153 example: None,
154 content_type: APPLICATION_JSON.to_string(),
155 },
156 );
157 self
158 }
159
160 pub fn consumes_named_path<T: DeserializeOwned>(self) -> Self {
162 self.with_named_path_parameters_from_deserialize::<T>()
163 }
164
165 pub fn consumes_query<T: DeserializeOwned>(self) -> Self {
167 self.with_query_parameters_from_deserialize::<T>()
168 }
169
170 pub fn consumes_json<T: DeserializeOwned>(self) -> Self {
172 self.with_request_type_from_deserialize::<T>(APPLICATION_JSON.as_ref())
173 }
174
175 pub fn consumes_form<T: DeserializeOwned>(self) -> Self {
177 self.with_request_type_from_deserialize::<T>(APPLICATION_WWW_FORM_URLENCODED.as_ref())
178 }
179
180 pub fn consumes_multipart(self) -> Self {
182 self.with_multipart_request()
183 }
184
185 pub fn consumes_stream(self) -> Self {
187 self.with_stream_request()
188 }
189
190 pub fn produces_text(mut self, status: impl IntoStatusCode) -> Self {
192 let status = status.into_status_code();
193 self.responses.insert(
194 status,
195 ResponseBody::Content {
196 schema: Box::new(OpenApiSchema::string()),
197 example: None,
198 content_type: TEXT_PLAIN_UTF_8.to_string(),
199 },
200 );
201 self
202 }
203
204 pub fn produces_no_schema(mut self, status: impl IntoStatusCode) -> Self {
206 let status = status.into_status_code();
207 self.responses.insert(status, ResponseBody::NoContent);
208 self
209 }
210
211 pub fn produces_json<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
213 self.produces_json_example(status, T::default())
214 }
215
216 pub fn produces_empty_json(mut self, status: impl IntoStatusCode) -> Self {
218 let status = status.into_status_code();
219 self.responses.insert(
220 status,
221 ResponseBody::Content {
222 schema: Box::new(OpenApiSchema::object()),
223 example: None,
224 content_type: APPLICATION_JSON.to_string(),
225 },
226 );
227 self
228 }
229
230 pub fn produces_form<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
232 self.produces_form_example(status, T::default())
233 }
234
235 pub fn produces_empty_form(mut self, status: impl IntoStatusCode) -> Self {
237 let status = status.into_status_code();
238 self.responses.insert(
239 status,
240 ResponseBody::Content {
241 schema: Box::new(OpenApiSchema::object()),
242 example: None,
243 content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
244 },
245 );
246 self
247 }
248
249 pub fn produces_multipart(mut self, status: impl IntoStatusCode) -> Self {
251 let status = status.into_status_code();
252 self.responses.insert(
253 status,
254 ResponseBody::Content {
255 schema: Box::new(OpenApiSchema::multipart()),
256 example: None,
257 content_type: MULTIPART_FORM_DATA.to_string(),
258 },
259 );
260 self
261 }
262
263 pub fn produces_stream(mut self, status: impl IntoStatusCode) -> Self {
265 let status = status.into_status_code();
266 self.responses.insert(
267 status,
268 ResponseBody::Content {
269 schema: Box::new(OpenApiSchema::binary()),
270 example: None,
271 content_type: APPLICATION_OCTET_STREAM.to_string(),
272 },
273 );
274 self
275 }
276
277 pub fn produces_sse(mut self, status: impl IntoStatusCode) -> Self {
279 let status = status.into_status_code();
280 self.responses.insert(
281 status,
282 ResponseBody::Content {
283 schema: Box::new(OpenApiSchema::string().with_title("SSE stream")),
284 example: None,
285 content_type: TEXT_EVENT_STREAM.to_string(),
286 },
287 );
288 self
289 }
290
291 pub fn produces_json_example<T: Serialize>(
293 mut self,
294 status: impl IntoStatusCode,
295 example: T,
296 ) -> Self {
297 let status = status.into_status_code();
298 let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
299 self.responses.insert(
300 status,
301 ResponseBody::Content {
302 schema: Box::new(OpenApiSchema::from_example(&example)),
303 example: Some(example),
304 content_type: APPLICATION_JSON.to_string(),
305 },
306 );
307 self
308 }
309
310 pub fn produces_form_example<T: Serialize>(
312 mut self,
313 status: impl IntoStatusCode,
314 example: T,
315 ) -> Self {
316 let status = status.into_status_code();
317 let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
318
319 let encoded = match &example {
320 Value::Object(map) => serde_urlencoded::to_string(map).unwrap_or_default(),
321 _ => String::new(),
322 };
323
324 self.responses.insert(
325 status,
326 ResponseBody::Content {
327 schema: Box::new(OpenApiSchema::string()),
328 example: Some(Value::String(encoded)),
329 content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
330 },
331 );
332 self
333 }
334
335 #[cfg(feature = "problem-details")]
339 pub fn produces_problem(mut self, status: impl IntoStatusCode) -> Self {
340 let status = status.into_status_code();
341 let title = http::StatusCode::from_u16(status)
342 .ok()
343 .and_then(|s| s.canonical_reason())
344 .unwrap_or("Error");
345 let example = json!({
346 "type": "https://tools.ietf.org/html/rfc9110",
347 "title": title,
348 "status": status
349 });
350 self.responses.insert(
351 status,
352 ResponseBody::Content {
353 schema: Box::new(OpenApiSchema::from_example(&example)),
354 example: Some(example),
355 content_type: "application/problem+json".to_string(),
356 },
357 );
358 self
359 }
360
361 #[cfg(feature = "problem-details")]
365 pub fn produces_problem_example<T: Serialize>(
366 mut self,
367 status: impl IntoStatusCode,
368 example: T,
369 ) -> Self {
370 let status = status.into_status_code();
371 let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
372 self.responses.insert(
373 status,
374 ResponseBody::Content {
375 schema: Box::new(OpenApiSchema::from_example(&example)),
376 example: Some(example),
377 content_type: "application/problem+json".to_string(),
378 },
379 );
380 self
381 }
382
383 fn with_multipart_request(mut self) -> Self {
385 self.request_schema
386 .get_or_insert_with(OpenApiSchema::multipart);
387 self.request_content_type = Some(MULTIPART_FORM_DATA.to_string());
388 self
389 }
390
391 fn with_stream_request(mut self) -> Self {
393 self.request_schema
394 .get_or_insert_with(OpenApiSchema::binary);
395 self.request_content_type = Some(APPLICATION_OCTET_STREAM.to_string());
396 self
397 }
398
399 fn with_query_parameter(mut self, name: String, schema: OpenApiSchema, required: bool) -> Self {
400 self.extra_parameters.push(OpenApiParameter {
401 name,
402 location: "query".to_string(),
403 required,
404 schema,
405 });
406 self
407 }
408
409 fn with_path_parameter(mut self, name: String, schema: OpenApiSchema) -> Self {
410 self.extra_parameters.push(OpenApiParameter {
411 name,
412 location: "path".to_string(),
413 required: true,
414 schema,
415 });
416 self
417 }
418
419 fn with_request_type_from_deserialize<T: DeserializeOwned>(
421 mut self,
422 content_type: &str,
423 ) -> Self {
424 if let Some((schema, example)) = schema_and_example_from_deserialize::<T>() {
425 self.request_schema = Some(schema.with_title(type_display_name::<T>()));
426 self.request_example = Some(example);
427 }
428 self.request_content_type = Some(content_type.to_string());
429 self
430 }
431
432 fn with_query_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
433 if let Some((schema, _)) = schema_and_example_from_deserialize::<T>()
434 && let Some(properties) = schema.properties
435 {
436 let required = schema.required.unwrap_or_default();
437
438 for (name, property_schema) in properties {
439 let is_required = required.iter().any(|f| f == &name);
440 self = self.with_query_parameter(name, property_schema, is_required);
441 }
442 }
443 self
444 }
445
446 fn with_named_path_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
447 if let Some((schema, _)) = schema_and_example_from_deserialize::<T>()
448 && let Some(properties) = schema.properties
449 {
450 for (name, property_schema) in properties {
451 self = self.with_path_parameter(name, property_schema);
452 }
453 }
454 self
455 }
456
457 pub fn merge(mut self, other: &Self) -> Self {
459 if self.tags.is_empty() {
460 self.tags = other.tags.clone();
461 } else {
462 self.tags.extend(other.tags.clone());
463 }
464 if self.summary.is_none() {
465 self.summary = other.summary.clone();
466 }
467 if self.description.is_none() {
468 self.description = other.description.clone();
469 }
470 if self.operation_id.is_none() {
471 self.operation_id = other.operation_id.clone();
472 }
473 if self.request_schema.is_none() {
474 self.request_schema = other.request_schema.clone();
475 }
476 if self.request_example.is_none() {
477 self.request_example = other.request_example.clone();
478 }
479 if self.request_content_type.is_none() {
480 self.request_content_type = other.request_content_type.clone();
481 }
482 for (status, body) in &other.responses {
483 self.responses
484 .entry(*status)
485 .or_insert_with(|| body.clone());
486 }
487 if !other.extra_parameters.is_empty() {
488 self.extra_parameters.extend(other.extra_parameters.clone());
489 }
490 match (&mut self.docs, &other.docs) {
491 (None, Some(d)) => self.docs = Some(d.clone()),
492 (Some(dst), Some(src)) => {
493 for s in src {
494 if !dst.iter().any(|x| x == s) {
495 dst.push(s.clone());
496 }
497 }
498 }
499 _ => {}
500 }
501 self
502 }
503
504 pub(super) fn apply_to_operation(
505 &self,
506 operation: &mut OpenApiOperation,
507 schemas: &mut BTreeMap<String, OpenApiSchema>,
508 ) {
509 if let Some(summary) = &self.summary {
510 operation.summary = Some(summary.clone());
511 }
512 if let Some(description) = &self.description {
513 operation.description = Some(description.clone());
514 }
515 if let Some(operation_id) = &self.operation_id {
516 operation.operation_id = Some(operation_id.clone());
517 }
518
519 if !self.tags.is_empty() {
520 operation.tags = Some(self.tags.clone());
521 }
522
523 if self.request_schema.is_some() || self.request_example.is_some() {
524 let mut schema = self
525 .request_schema
526 .clone()
527 .unwrap_or_else(OpenApiSchema::object);
528 let example = self.request_example.clone();
529 let content_type = self
530 .request_content_type
531 .as_deref()
532 .unwrap_or(APPLICATION_JSON.as_ref());
533
534 schema = intern_schema_if_object_named(schema, schemas);
535 operation.set_request_body(schema, example, content_type);
536 }
537
538 if !self.responses.is_empty() {
539 operation.clear_all_responses();
540 for (status, body) in &self.responses {
541 match body {
542 ResponseBody::NoContent => {
543 operation.clear_response_body(*status);
544 }
545 ResponseBody::Content {
546 schema,
547 example,
548 content_type,
549 } => {
550 let schema = intern_schema_if_object_named((**schema).clone(), schemas);
551 operation.set_response_body(*status, schema, example.clone(), content_type);
552 }
553 }
554 }
555 }
556
557 if !self.extra_parameters.is_empty() {
558 let params = operation.parameters.get_or_insert_with(Vec::new);
559
560 for p in &self.extra_parameters {
561 if let Some(existing) = params
562 .iter_mut()
563 .find(|x| x.name == p.name && x.location == p.location)
564 {
565 *existing = p.clone();
566 } else {
567 params.push(p.clone());
568 }
569 }
570 }
571 }
572}
573
574fn intern_schema_if_object_named(
575 mut schema: OpenApiSchema,
576 schemas: &mut BTreeMap<String, OpenApiSchema>,
577) -> OpenApiSchema {
578 if schema.schema_ref.is_some() {
579 return schema;
580 }
581
582 let base = match schema.title.clone() {
583 Some(t) if !t.is_empty() => t,
584 _ => return schema,
585 };
586
587 if schema.properties.is_none() {
588 return schema;
589 }
590
591 let mut name = base.clone();
592 if schemas.contains_key(&name) {
593 let mut i = 2;
594 while schemas.contains_key(&format!("{base}_{i}")) {
595 i += 1;
596 }
597 name = format!("{base}_{i}");
598 }
599
600 schema.title = None;
601
602 schemas.entry(name.clone()).or_insert(schema);
603
604 OpenApiSchema::reference(&name)
605}
606
607fn schema_and_example_from_deserialize<T: DeserializeOwned>() -> Option<(OpenApiSchema, Value)> {
608 let mut probe = Probe::new();
609 let _ = T::deserialize(&mut probe);
610 probe.finish()
611}
612
613fn type_display_name<T>() -> String {
614 std::any::type_name::<T>()
615 .rsplit("::")
616 .next()
617 .unwrap_or("Model")
618 .to_string()
619}
620
621#[cfg(test)]
622#[allow(unused)]
623mod tests {
624 use super::{IntoStatusCode, OpenApiRouteConfig, ResponseBody};
625 use crate::{op::OpenApiOperation, schema::OpenApiSchema};
626 use serde::{Deserialize, Serialize};
627 use serde_json::json;
628 use std::collections::BTreeMap;
629
630 #[derive(Deserialize)]
631 struct Payload {
632 name: String,
633 age: u64,
634 }
635
636 #[derive(Serialize, Default)]
637 struct ResponsePayload {
638 message: String,
639 }
640
641 #[derive(Deserialize)]
642 struct OptionalQuery {
643 required_name: String,
644 optional_age: Option<()>,
645 }
646
647 #[test]
648 fn consumes_query_marks_non_optional_fields_as_required() {
649 let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalQuery>();
650
651 let required_name = cfg
652 .extra_parameters
653 .iter()
654 .find(|p| p.name == "required_name")
655 .expect("required_name param");
656 let optional_age = cfg
657 .extra_parameters
658 .iter()
659 .find(|p| p.name == "optional_age")
660 .expect("optional_age param");
661
662 assert!(required_name.required);
663 assert!(!optional_age.required);
664 }
665
666 #[test]
667 fn infer_request_schema_from_deserialize_type() {
668 let cfg = OpenApiRouteConfig::default()
669 .with_request_type_from_deserialize::<Payload>("application/json");
670
671 let schema = cfg
672 .request_schema
673 .expect("request schema should be inferred");
674 let props = schema.properties.expect("object properties should exist");
675
676 assert!(props.contains_key("name"));
677 assert!(props.contains_key("age"));
678 assert_eq!(
679 cfg.request_content_type.as_deref(),
680 Some("application/json")
681 );
682 }
683
684 #[test]
685 fn infer_query_parameters_from_deserialize_type() {
686 let cfg = OpenApiRouteConfig::default().with_query_parameters_from_deserialize::<Payload>();
687
688 assert_eq!(cfg.extra_parameters.len(), 2);
689 assert!(cfg.extra_parameters.iter().any(|p| p.name == "name"));
690 assert!(cfg.extra_parameters.iter().any(|p| p.name == "age"));
691 assert!(cfg.extra_parameters.iter().all(|p| p.location == "query"));
692 }
693
694 #[test]
695 fn merge_combines_docs_and_preserves_existing_values() {
696 let base = OpenApiRouteConfig::default()
697 .with_tag("base")
698 .with_summary("base summary")
699 .with_doc("v1");
700 let other = OpenApiRouteConfig::default()
701 .with_tag("extra")
702 .with_summary("other summary")
703 .with_doc("v1")
704 .with_doc("admin");
705
706 let merged = base.merge(&other);
707
708 assert_eq!(merged.summary.as_deref(), Some("base summary"));
709 assert_eq!(merged.tags.as_slice(), ["base", "extra"]);
710 assert_eq!(merged.docs.expect("docs"), vec!["v1", "admin"]);
711 }
712
713 #[test]
714 fn apply_to_operation_adds_content_and_interns_named_schema() {
715 let cfg = OpenApiRouteConfig::default()
716 .with_request_schema(
717 OpenApiSchema::object()
718 .with_title("CreateUser")
719 .with_property("name", OpenApiSchema::string()),
720 )
721 .with_response_schema(
722 200u16,
723 OpenApiSchema::object()
724 .with_title("User")
725 .with_property("id", OpenApiSchema::integer()),
726 )
727 .produces_json_example(
728 200u16,
729 ResponsePayload {
730 message: "ok".to_string(),
731 },
732 )
733 .with_summary("create user")
734 .with_operation_id("createUser");
735
736 let mut op = OpenApiOperation::default();
737 let mut schemas = BTreeMap::new();
738
739 cfg.apply_to_operation(&mut op, &mut schemas);
740
741 let op_json = serde_json::to_value(op).expect("serialize operation");
742 assert_eq!(op_json["summary"], "create user");
743 assert_eq!(op_json["operationId"], "createUser");
744 assert!(
745 op_json["requestBody"]["content"]
746 .get("application/json")
747 .is_some()
748 );
749 assert_eq!(
750 op_json["responses"]["200"]["content"]["application/json"]["example"],
751 json!({"message":"ok"})
752 );
753
754 assert!(schemas.contains_key("CreateUser"));
755 }
756
757 #[test]
758 fn produces_no_schema_clears_content() {
759 let cfg = OpenApiRouteConfig::default()
760 .produces_json::<ResponsePayload>(200)
761 .produces_no_schema(200);
762
763 let mut op = OpenApiOperation::default();
764 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
765
766 let op_json = serde_json::to_value(op).expect("serialize operation");
767 assert!(op_json["responses"]["200"].get("content").is_none());
768 }
769
770 #[test]
771 fn with_tags_extends_existing_tags() {
772 let cfg = OpenApiRouteConfig::default()
773 .with_tag("base")
774 .with_tags(["one", "two"]);
775
776 assert_eq!(cfg.tags, vec!["base", "one", "two"]);
777 }
778
779 #[test]
780 fn with_docs_initializes_and_extends_docs() {
781 let cfg = OpenApiRouteConfig::default()
782 .with_docs(["v1", "admin"])
783 .with_doc("internal");
784
785 assert_eq!(cfg.docs.expect("docs"), vec!["v1", "admin", "internal"]);
786 }
787
788 #[test]
789 fn with_description_sets_operation_description() {
790 let cfg = OpenApiRouteConfig::default().with_description("desc");
791 assert_eq!(cfg.description.as_deref(), Some("desc"));
792 }
793
794 #[test]
795 fn consumes_methods_set_request_content_type() {
796 let query_cfg = OpenApiRouteConfig::default().consumes_query::<Payload>();
797 assert_eq!(query_cfg.extra_parameters.len(), 2);
798
799 let json_cfg = OpenApiRouteConfig::default().consumes_json::<Payload>();
800 assert_eq!(
801 json_cfg.request_content_type.as_deref(),
802 Some("application/json")
803 );
804 assert!(json_cfg.request_schema.is_some());
805
806 let form_cfg = OpenApiRouteConfig::default().consumes_form::<Payload>();
807 assert_eq!(
808 form_cfg.request_content_type.as_deref(),
809 Some("application/x-www-form-urlencoded")
810 );
811 assert!(form_cfg.request_schema.is_some());
812
813 let multipart_cfg = OpenApiRouteConfig::default().consumes_multipart();
814 assert_eq!(
815 multipart_cfg.request_content_type.as_deref(),
816 Some("multipart/form-data")
817 );
818
819 let stream_cfg = OpenApiRouteConfig::default().consumes_stream();
820 assert_eq!(
821 stream_cfg.request_content_type.as_deref(),
822 Some("application/octet-stream")
823 );
824 assert_eq!(
825 stream_cfg.request_schema.expect("schema").format.as_deref(),
826 Some("binary")
827 );
828 }
829
830 #[test]
831 fn produces_helpers_set_expected_response_schema_and_content_type() {
832 fn apply(cfg: OpenApiRouteConfig) -> serde_json::Value {
833 let mut op = OpenApiOperation::default();
834 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
835 serde_json::to_value(op).expect("serialize")
836 }
837
838 let text_json = apply(OpenApiRouteConfig::default().produces_text(200));
839 assert!(
840 text_json["responses"]["200"]["content"]
841 .get("text/plain; charset=utf-8")
842 .is_some()
843 );
844 assert_eq!(
845 text_json["responses"]["200"]["content"]["text/plain; charset=utf-8"]["schema"]["type"],
846 "string"
847 );
848
849 let empty_json_json = apply(OpenApiRouteConfig::default().produces_empty_json(200));
850 assert!(
851 empty_json_json["responses"]["200"]["content"]
852 .get("application/json")
853 .is_some()
854 );
855 assert_eq!(
856 empty_json_json["responses"]["200"]["content"]["application/json"]["schema"]["type"],
857 "object"
858 );
859
860 let form_json = apply(OpenApiRouteConfig::default().produces_form::<ResponsePayload>(200));
861 assert!(
862 form_json["responses"]["200"]["content"]
863 .get("application/x-www-form-urlencoded")
864 .is_some()
865 );
866 assert_eq!(
867 form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
868 ["type"],
869 "string"
870 );
871
872 let empty_form_json = apply(OpenApiRouteConfig::default().produces_empty_form(200));
873 assert!(
874 empty_form_json["responses"]["200"]["content"]
875 .get("application/x-www-form-urlencoded")
876 .is_some()
877 );
878 assert_eq!(
879 empty_form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
880 ["type"],
881 "object"
882 );
883
884 let stream_json = apply(OpenApiRouteConfig::default().produces_stream(200));
885 assert!(
886 stream_json["responses"]["200"]["content"]
887 .get("application/octet-stream")
888 .is_some()
889 );
890 assert_eq!(
891 stream_json["responses"]["200"]["content"]["application/octet-stream"]["schema"]["format"],
892 "binary"
893 );
894
895 let sse_json = apply(OpenApiRouteConfig::default().produces_sse(200));
896 assert!(
897 sse_json["responses"]["200"]["content"]
898 .get("text/event-stream")
899 .is_some()
900 );
901 assert_eq!(
902 sse_json["responses"]["200"]["content"]["text/event-stream"]["schema"]["title"],
903 "SSE stream"
904 );
905 }
906
907 #[test]
908 fn produces_form_example_encodes_object_into_string_example() {
909 #[derive(Serialize)]
910 struct FormOut {
911 first: String,
912 second: i32,
913 }
914
915 let cfg = OpenApiRouteConfig::default().produces_form_example(
916 200u16,
917 FormOut {
918 first: "hello".to_string(),
919 second: 7,
920 },
921 );
922
923 let mut op = OpenApiOperation::default();
924 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
925 let op_json = serde_json::to_value(op).expect("serialize");
926
927 assert!(
928 op_json["responses"]["200"]["content"]
929 .get("application/x-www-form-urlencoded")
930 .is_some()
931 );
932 assert_eq!(
933 op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]["type"],
934 "string"
935 );
936 assert_eq!(
937 op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["example"],
938 "first=hello&second=7"
939 );
940 }
941
942 #[test]
943 fn produces_multiple_status_codes() {
944 #[derive(Serialize, Default)]
945 struct UserDto {
946 id: u64,
947 }
948 #[derive(Serialize, Default)]
949 struct ErrorDto {
950 message: String,
951 }
952
953 let cfg = OpenApiRouteConfig::default()
954 .produces_json::<UserDto>(200)
955 .produces_json::<ErrorDto>(404);
956
957 let mut op = OpenApiOperation::default();
958 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
959 let op_json = serde_json::to_value(op).expect("serialize");
960
961 assert!(
963 op_json["responses"]["200"]["content"]
964 .get("application/json")
965 .is_some()
966 );
967 assert!(
968 op_json["responses"]["404"]["content"]
969 .get("application/json")
970 .is_some()
971 );
972 assert_eq!(
974 op_json["responses"]
975 .as_object()
976 .expect("responses object")
977 .len(),
978 2
979 );
980 }
981
982 #[test]
983 fn explicit_responses_replace_default_200() {
984 let cfg = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(201);
985
986 let mut op = OpenApiOperation::default();
987 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
988 let op_json = serde_json::to_value(op).expect("serialize");
989
990 assert!(op_json["responses"].get("200").is_none());
992 assert!(
993 op_json["responses"]["201"]["content"]
994 .get("application/json")
995 .is_some()
996 );
997 assert_eq!(op_json["responses"]["201"]["description"], "Created");
998 }
999
1000 #[test]
1001 fn merge_does_not_override_existing_responses() {
1002 let base = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(200);
1003 let other = OpenApiRouteConfig::default()
1004 .produces_text(200)
1005 .produces_json::<ResponsePayload>(404);
1006
1007 let merged = base.merge(&other);
1008
1009 let mut op = OpenApiOperation::default();
1010 merged.apply_to_operation(&mut op, &mut BTreeMap::new());
1011 let op_json = serde_json::to_value(op).expect("serialize");
1012
1013 assert!(
1015 op_json["responses"]["200"]["content"]
1016 .get("application/json")
1017 .is_some()
1018 );
1019 assert!(
1020 op_json["responses"]["200"]["content"]
1021 .get("text/plain; charset=utf-8")
1022 .is_none()
1023 );
1024 assert!(
1026 op_json["responses"]["404"]["content"]
1027 .get("application/json")
1028 .is_some()
1029 );
1030 }
1031
1032 #[test]
1033 fn into_status_code_works_for_u16_and_http_status_code() {
1034 assert_eq!(200u16.into_status_code(), 200);
1035 assert_eq!(201.into_status_code(), 201);
1036 assert_eq!(202u32.into_status_code(), 202);
1037 assert_eq!(http::StatusCode::NOT_FOUND.into_status_code(), 404);
1038 assert_eq!(http::StatusCode::CREATED.into_status_code(), 201);
1039 }
1040
1041 #[cfg(feature = "problem-details")]
1042 mod problem_tests {
1043 use super::*;
1044
1045 #[test]
1046 fn produces_problem_generates_problem_json_schema_for_status() {
1047 let cfg = OpenApiRouteConfig::default().produces_problem(400u16);
1048 let mut op = OpenApiOperation::default();
1049 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1050 let op_json = serde_json::to_value(op).expect("serialize");
1051
1052 let content = &op_json["responses"]["400"]["content"]["application/problem+json"];
1053 assert!(
1054 content.is_object(),
1055 "application/problem+json content should exist"
1056 );
1057 assert_eq!(content["example"]["title"], "Bad Request");
1058 assert_eq!(content["example"]["status"], 400);
1059 assert_eq!(
1060 content["example"]["type"],
1061 "https://tools.ietf.org/html/rfc9110"
1062 );
1063 assert_eq!(op_json["responses"]["400"]["description"], "Bad Request");
1064 assert!(op_json["responses"].get("200").is_none());
1066 }
1067
1068 #[test]
1069 fn produces_problem_example_uses_provided_value() {
1070 #[derive(Serialize)]
1071 struct CustomProblem {
1072 r#type: String,
1073 title: String,
1074 status: u16,
1075 detail: String,
1076 }
1077
1078 let cfg = OpenApiRouteConfig::default().produces_problem_example(
1079 422u16,
1080 CustomProblem {
1081 r#type: "https://tools.ietf.org/html/rfc9110".into(),
1082 title: "Unprocessable Content".into(),
1083 status: 422,
1084 detail: "Validation failed".into(),
1085 },
1086 );
1087
1088 let mut op = OpenApiOperation::default();
1089 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1090 let op_json = serde_json::to_value(op).expect("serialize");
1091
1092 let content = &op_json["responses"]["422"]["content"]["application/problem+json"];
1093 assert!(
1094 content.is_object(),
1095 "application/problem+json content should exist"
1096 );
1097 assert_eq!(content["example"]["detail"], "Validation failed");
1098 assert_eq!(content["example"]["status"], 422);
1099 assert_eq!(
1100 op_json["responses"]["422"]["description"],
1101 "Unprocessable Entity"
1102 );
1103 }
1104
1105 #[test]
1106 fn produces_problem_composes_with_other_status_responses() {
1107 let cfg = OpenApiRouteConfig::default()
1108 .produces_json::<ResponsePayload>(200)
1109 .produces_problem(400)
1110 .produces_problem(500);
1111
1112 let mut op = OpenApiOperation::default();
1113 cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1114 let op_json = serde_json::to_value(op).expect("serialize");
1115
1116 assert!(
1117 op_json["responses"]["200"]["content"]
1118 .get("application/json")
1119 .is_some()
1120 );
1121 assert!(
1122 op_json["responses"]["400"]["content"]
1123 .get("application/problem+json")
1124 .is_some()
1125 );
1126 assert!(
1127 op_json["responses"]["500"]["content"]
1128 .get("application/problem+json")
1129 .is_some()
1130 );
1131 assert_eq!(op_json["responses"].as_object().unwrap().len(), 3);
1132 }
1133 }
1134
1135 #[test]
1136 fn produces_multipart_sets_response_content_type() {
1137 let cfg = OpenApiRouteConfig::default().produces_multipart(200u16);
1138 let resp = cfg.responses.get(&200u16).expect("200 response defined");
1139 match resp {
1140 ResponseBody::Content { content_type, .. } => {
1141 assert_eq!(content_type, "multipart/form-data");
1142 }
1143 _ => panic!("expected Content variant"),
1144 }
1145 }
1146}