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