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 stream response schema.
250    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    /// Generates SSE stream response schema.
264    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    /// Generates JSON response schema and example.
278    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    /// Generates form response schema and example.
297    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    /// Generates an `application/problem+json` response schema for the given status code (RFC 7807).
322    ///
323    /// The example is derived from the status code using its canonical reason phrase.
324    #[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    /// Generates an `application/problem+json` response schema and example (RFC 7807).
348    ///
349    /// The schema is inferred from the provided example value.
350    #[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    /// Generates default Multipart request schema
370    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    /// Generates default stream request schema
378    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    /// Generates request schema and example from `T`.
406    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    /// Merges configurations.
444    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        // Both statuses should appear
948        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        // Default 200 is replaced by explicit responses
959        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        // The default "200" must be gone — only "201" remains
977        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        // 200 should keep JSON (from base), not be overridden by text (from other)
1000        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        // 404 should come from other
1011        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            // Default "200" must be gone
1051            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}