Skip to main content

volga_open_api/
route.rs

1//! Types and utils for OpenAPI route config.
2
3use 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
19/// Converts a value into an HTTP status code.
20pub trait IntoStatusCode {
21    /// Converts `self` into a `u16` HTTP status code.
22    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/// Per-route OpenAPI metadata.
64#[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    /// Returns a list of docs that this route is assigned to
80    pub(crate) fn docs(&self) -> Option<&[String]> {
81        self.docs.as_deref()
82    }
83
84    /// Adds a tag to the operation.
85    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
86        self.tags.push(tag.into());
87        self
88    }
89
90    /// Adds multiple tags to the operation.
91    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    /// Binds the operation with a document
101    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    /// Binds the operation with documents
107    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    /// Sets the operation summary.
119    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
120        self.summary = Some(summary.into());
121        self
122    }
123
124    /// Sets the operation description.
125    pub fn with_description(mut self, description: impl Into<String>) -> Self {
126        self.description = Some(description.into());
127        self
128    }
129
130    /// Sets the operation id.
131    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    /// Sets the request body schema for this operation.
137    pub fn with_request_schema(mut self, schema: OpenApiSchema) -> Self {
138        self.request_schema = Some(schema);
139        self
140    }
141
142    /// Sets the response schema for this operation.
143    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    /// Generates path parameters schema.
161    pub fn consumes_named_path<T: DeserializeOwned>(self) -> Self {
162        self.with_named_path_parameters_from_deserialize::<T>()
163    }
164
165    /// Generates query parameters schema.
166    pub fn consumes_query<T: DeserializeOwned>(self) -> Self {
167        self.with_query_parameters_from_deserialize::<T>()
168    }
169
170    /// Generates JSON request schema and example.
171    pub fn consumes_json<T: DeserializeOwned>(self) -> Self {
172        self.with_request_type_from_deserialize::<T>(APPLICATION_JSON.as_ref())
173    }
174
175    /// Generates form request schema and example.
176    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    /// Generates multipart request schema.
181    pub fn consumes_multipart(self) -> Self {
182        self.with_multipart_request()
183    }
184
185    /// Generates stream request schema.
186    pub fn consumes_stream(self) -> Self {
187        self.with_stream_request()
188    }
189
190    /// Generates text/plain response schema.
191    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    /// Generates a response without a schema.
205    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    /// Generates JSON response schema and example from `T::default()`.
212    pub fn produces_json<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
213        self.produces_json_example(status, T::default())
214    }
215
216    /// Generates empty JSON response schema.
217    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    /// Generates form response schema and example from `T::default()`.
231    pub fn produces_form<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
232        self.produces_form_example(status, T::default())
233    }
234
235    /// Generates empty form response schema.
236    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    /// Generates default `multipart/form-data` response schema.
250    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    /// Generates stream response schema.
264    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    /// Generates SSE stream response schema.
278    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    /// Generates JSON response schema and example.
292    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    /// Generates form response schema and example.
311    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    /// Generates an `application/problem+json` response schema for the given status code (RFC 7807).
336    ///
337    /// The example is derived from the status code using its canonical reason phrase.
338    #[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    /// Generates an `application/problem+json` response schema and example (RFC 7807).
362    ///
363    /// The schema is inferred from the provided example value.
364    #[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    /// Generates default Multipart request schema
384    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    /// Generates default stream request schema
392    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    /// Generates request schema and example from `T`.
420    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    /// Merges configurations.
458    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        // Both statuses should appear
962        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        // Default 200 is replaced by explicit responses
973        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        // The default "200" must be gone — only "201" remains
991        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        // 200 should keep JSON (from base), not be overridden by text (from other)
1014        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        // 404 should come from other
1025        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            // Default "200" must be gone
1065            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}