Skip to main content

volga_open_api/
route.rs

1//! Types and utils for OpenAPI route config.
2
3use std::collections::BTreeMap;
4use serde::de::DeserializeOwned;
5use serde::Serialize;
6use serde_json::{json, Value};
7
8use mime::{
9    APPLICATION_JSON,
10    APPLICATION_OCTET_STREAM,
11    APPLICATION_WWW_FORM_URLENCODED,
12    MULTIPART_FORM_DATA,
13    TEXT_EVENT_STREAM,
14    TEXT_PLAIN_UTF_8
15};
16
17use super::{
18    schema::{OpenApiSchema, Probe},
19    param::OpenApiParameter,
20    op::OpenApiOperation,
21};
22
23/// Per-route OpenAPI metadata.
24#[derive(Clone, Debug, Default)]
25pub struct OpenApiRouteConfig {
26    tags: Vec<String>,
27    docs: Option<Vec<String>>,
28    summary: Option<String>,
29    description: Option<String>,
30    operation_id: Option<String>,
31    request_schema: Option<OpenApiSchema>,
32    response_schema: Option<Option<OpenApiSchema>>,
33    request_example: Option<Value>,
34    response_example: Option<Option<Value>>,
35    request_content_type: Option<String>,
36    response_content_type: Option<Option<String>>,
37    extra_parameters: Vec<OpenApiParameter>,
38}
39
40impl OpenApiRouteConfig {
41    /// Returns a list of docs that this route is assigned to    
42    pub(crate) fn docs(&self) -> Option<&[String]> {
43        self.docs.as_deref()
44    }
45
46    /// Adds a tag to the operation.
47    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
48        self.tags.push(tag.into());
49        self
50    }
51
52    /// Adds multiple tags to the operation.
53    pub fn with_tags<I, T>(mut self, tags: I) -> Self
54    where
55        I: IntoIterator<Item = T>,
56        T: Into<String>,
57    {
58        self.tags.extend(tags.into_iter().map(Into::into));
59        self
60    }
61
62    /// Binds the operation with a document
63    pub fn with_doc(mut self, doc: impl Into<String>) -> Self {
64        self.docs.get_or_insert_with(Vec::new).push(doc.into());
65        self
66    }
67
68    /// Binds the operation with documents
69    pub fn with_docs<I, S>(mut self, docs: I) -> Self
70    where
71        I: IntoIterator<Item = S>,
72        S: Into<String>,
73    {
74        self.docs
75            .get_or_insert_with(Vec::new)
76            .extend(docs.into_iter().map(Into::into));
77        self
78    }
79
80    /// Sets the operation summary.
81    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
82        self.summary = Some(summary.into());
83        self
84    }
85
86    /// Sets the operation description.
87    pub fn with_description(mut self, description: impl Into<String>) -> Self {
88        self.description = Some(description.into());
89        self
90    }
91
92    /// Sets the operation id.
93    pub fn with_operation_id(mut self, operation_id: impl Into<String>) -> Self {
94        self.operation_id = Some(operation_id.into());
95        self
96    }
97
98    /// Sets the request body schema for this operation.
99    pub fn with_request_schema(mut self, schema: OpenApiSchema) -> Self {
100        self.request_schema = Some(schema);
101        self
102    }
103
104    /// Sets the response schema for this operation.
105    pub fn with_response_schema(mut self, schema: OpenApiSchema) -> Self {
106        self.response_schema = Some(Some(schema));
107        self
108    }
109
110    /// Generates path parameters schema.
111    pub fn consumes_named_path<T: DeserializeOwned>(self) -> Self {
112        self.with_named_path_parameters_from_deserialize::<T>()
113    }
114
115    /// Generates query parameters schema.
116    pub fn consumes_query<T: DeserializeOwned>(self) -> Self {
117        self.with_query_parameters_from_deserialize::<T>()
118    }
119
120    /// Generates JSON request schema and example.
121    pub fn consumes_json<T: DeserializeOwned>(self) -> Self {
122        self.with_request_type_from_deserialize::<T>(APPLICATION_JSON.as_ref())
123    }
124
125    /// Generates form request schema and example.
126    pub fn consumes_form<T: DeserializeOwned>(self) -> Self {
127        self.with_request_type_from_deserialize::<T>(APPLICATION_WWW_FORM_URLENCODED.as_ref())
128    }
129
130    /// Generates multipart request schema.
131    pub fn consumes_multipart(self) -> Self {
132        self.with_multipart_request()
133    }
134
135    /// Generates stream request schema.
136    pub fn consumes_stream(self) -> Self {
137        self.with_stream_request()
138    }
139    
140    /// Generate text/plain response schema.
141    pub fn produces_text(self) -> Self {
142        self.with_text_response()
143    }
144
145    /// Generate a response without a schema.
146    pub fn produces_no_schema(self) -> Self {
147        self.with_empty_response()
148    }
149
150    /// Generates JSON response schema and example from `T::default()`.
151    pub fn produces_json<T: Serialize + Default>(self) -> Self {
152        self.produces_json_example(T::default())
153    }
154
155    /// Generates empty JSON response schema.
156    pub fn produces_empty_json(self) -> Self {
157        self.with_json_response()
158    }
159
160    /// Generates form response schema and example from `T::default()`.
161    pub fn produces_form<T: Serialize + Default>(self) -> Self {
162        self.produces_form_example(T::default())
163    }
164
165    /// Generates empty form response schema.
166    pub fn produces_empty_form(self) -> Self {
167        self.with_form_response()
168    }
169
170    /// Generates stream response schema.
171    pub fn produces_stream(self) -> Self {
172        self.with_stream_response()
173    }
174
175    /// Generates SSE stream response schema.
176    pub fn produces_sse(self) -> Self {
177        self.with_sse_response()
178    }
179
180    /// Generates JSON response schema and example.
181    pub fn produces_json_example<T: Serialize>(mut self, example: T) -> Self {
182        let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
183
184        self.response_schema = Some(Some(OpenApiSchema::from_example(&example)));
185        self.response_example = Some(Some(example));
186        self.response_content_type = Some(Some(APPLICATION_JSON.to_string()));
187
188        self
189    }
190
191    /// Generates form response schema and example.
192    pub fn produces_form_example<T: Serialize>(mut self, example: T) -> Self {
193        let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
194
195        let encoded = match &example {
196            Value::Object(map) => serde_urlencoded::to_string(map).unwrap_or_default(),
197            _ => String::new(),
198        };
199
200        self.response_schema = Some(Some(OpenApiSchema::string()));
201        self.response_example = Some(Some(Value::String(encoded)));
202
203        self.response_content_type = Some(Some(APPLICATION_WWW_FORM_URLENCODED.to_string()));
204
205        self
206    }
207
208    /// Generates default Multipart request schema
209    fn with_multipart_request(mut self) -> Self {
210        self.request_schema
211            .get_or_insert_with(OpenApiSchema::multipart);
212        self.request_content_type = Some(MULTIPART_FORM_DATA.to_string());
213        self
214    }
215
216    /// Generates default stream request schema
217    fn with_stream_request(mut self) -> Self {
218        self.request_schema
219            .get_or_insert_with(OpenApiSchema::binary);
220        self.request_content_type = Some(APPLICATION_OCTET_STREAM.to_string());
221        self
222    }
223
224    /// Generates default JSON response schema
225    fn with_json_response(mut self) -> Self {
226        self.response_schema
227            .get_or_insert_with(|| Some(OpenApiSchema::object()));
228        self.response_content_type = Some(Some(APPLICATION_JSON.to_string()));
229        self
230    }
231
232    /// Generates default form response schema
233    fn with_form_response(mut self) -> Self {
234        self.response_schema
235            .get_or_insert_with(|| Some(OpenApiSchema::object()));
236        self.response_content_type = Some(Some(APPLICATION_WWW_FORM_URLENCODED.to_string()));
237        self
238    }
239
240    /// Generates default text response schema
241    fn with_text_response(mut self) -> Self {
242        self.response_schema
243            .get_or_insert_with(|| Some(OpenApiSchema::string()));
244        self.response_content_type = Some(Some(TEXT_PLAIN_UTF_8.to_string()));
245        self
246    }
247
248    /// Generates empty response schema and content type
249    fn with_empty_response(mut self) -> Self {
250        self.response_schema = Some(None);
251        self.response_content_type = Some(None);
252        self.response_example = Some(None);
253        self
254    }
255
256    /// Generates SSE stream response schema
257    fn with_sse_response(mut self) -> Self {
258        self.response_schema
259            .get_or_insert_with(|| Some(OpenApiSchema::string().with_title("SSE stream")));
260        self.response_content_type = Some(Some(TEXT_EVENT_STREAM.to_string()));
261        self
262    }
263
264    /// Generates default stream response schema
265    fn with_stream_response(mut self) -> Self {
266        self.response_schema
267            .get_or_insert_with(|| Some(OpenApiSchema::binary()));
268        self.response_content_type = Some(Some(APPLICATION_OCTET_STREAM.to_string()));
269        self
270    }
271
272    fn with_query_parameter(mut self, name: String, schema: OpenApiSchema, required: bool) -> Self {
273        self.extra_parameters.push(OpenApiParameter {
274            name,
275            location: "query".to_string(),
276            required,
277            schema,
278        });
279        self
280    }
281
282    fn with_path_parameter(mut self, name: String, schema: OpenApiSchema) -> Self {
283        self.extra_parameters.push(OpenApiParameter {
284            name,
285            location: "path".to_string(),
286            required: true,
287            schema,
288        });
289        self
290    }
291
292    /// Generates request schema and example from `T`.
293    fn with_request_type_from_deserialize<T: DeserializeOwned>(
294        mut self,
295        content_type: &str,
296    ) -> Self {
297        if let Some((schema, example)) = schema_and_example_from_deserialize::<T>() {
298            self.request_schema = Some(schema.with_title(type_display_name::<T>()));
299            self.request_example = Some(example);
300        }
301        self.request_content_type = Some(content_type.to_string());
302        self
303    }
304
305    fn with_query_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
306        if let Some((schema, _)) = schema_and_example_from_deserialize::<T>()
307            && let Some(properties) = schema.properties
308        {
309            let required = schema.required.unwrap_or_default();
310            
311            for (name, property_schema) in properties {
312                let is_required = required.iter().any(|f| f == &name);
313                self = self.with_query_parameter(name, property_schema, is_required);
314            }
315        }
316        self
317    }
318
319    fn with_named_path_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
320        if let Some((schema, _)) = schema_and_example_from_deserialize::<T>()
321            && let Some(properties) = schema.properties
322        {
323            for (name, property_schema) in properties {
324                self = self.with_path_parameter(name, property_schema);
325            }
326        }
327        self
328    }
329
330    /// Merges configurations.
331    pub fn merge(mut self, other: &Self) -> Self {
332        if self.tags.is_empty() {
333            self.tags = other.tags.clone();
334        } else {
335            self.tags.extend(other.tags.clone());
336        }
337        if self.summary.is_none() {
338            self.summary = other.summary.clone();
339        }
340        if self.description.is_none() {
341            self.description = other.description.clone();
342        }
343        if self.operation_id.is_none() {
344            self.operation_id = other.operation_id.clone();
345        }
346        if self.request_schema.is_none() {
347            self.request_schema = other.request_schema.clone();
348        }
349        if self.response_schema.is_none() {
350            self.response_schema = other.response_schema.clone();
351        }
352        if self.request_example.is_none() {
353            self.request_example = other.request_example.clone();
354        }
355        if self.response_example.is_none() {
356            self.response_example = other.response_example.clone();
357        }
358        if self.request_content_type.is_none() {
359            self.request_content_type = other.request_content_type.clone();
360        }
361        if self.response_content_type.is_none() {
362            self.response_content_type = other.response_content_type.clone();
363        }
364        if !other.extra_parameters.is_empty() {
365            self.extra_parameters.extend(other.extra_parameters.clone());
366        }
367        match (&mut self.docs, &other.docs) {
368            (None, Some(d)) => self.docs = Some(d.clone()),
369            (Some(dst), Some(src)) => {
370                for s in src {
371                    if !dst.iter().any(|x| x == s) {
372                        dst.push(s.clone());
373                    }
374                }
375            }
376            _ => {}
377        }
378        self
379    }
380
381    pub(super) fn apply_to_operation(
382        &self,
383        operation: &mut OpenApiOperation,
384        schemas: &mut BTreeMap<String, OpenApiSchema>
385    ) {
386        if let Some(summary) = &self.summary {
387            operation.summary = Some(summary.clone());
388        }
389        if let Some(description) = &self.description {
390            operation.description = Some(description.clone());
391        }
392        if let Some(operation_id) = &self.operation_id {
393            operation.operation_id = Some(operation_id.clone());
394        }
395
396        if !self.tags.is_empty() {
397            operation.tags = Some(self.tags.clone());
398        }
399
400        if self.request_schema.is_some() || self.request_example.is_some() {
401            let mut schema = self.request_schema.clone().unwrap_or_else(OpenApiSchema::object);
402            let example = self.request_example.clone();
403            let content_type = self.request_content_type.as_deref().unwrap_or(APPLICATION_JSON.as_ref());
404
405            schema = intern_schema_if_object_named(schema, schemas);
406            operation.set_request_body(schema, example, content_type);
407        }
408
409        let touched = self.response_schema.is_some()
410            || self.response_example.is_some()
411            || self.response_content_type.is_some();
412
413        if touched {
414            let schema_clear = matches!(self.response_schema, Some(None));
415            let ct_clear = matches!(self.response_content_type, Some(None));
416            let example_clear = matches!(self.response_example, Some(None));
417        
418            if schema_clear || ct_clear || example_clear {
419                operation.clear_response_body();
420            } else {
421                let mut schema = match &self.response_schema {
422                    Some(Some(s)) => s.clone(),
423                    _ => OpenApiSchema::object(),
424                };
425            
426                let example: Option<Value> = match &self.response_example {
427                    Some(Some(v)) => Some(v.clone()),
428                    _ => None,
429                };
430            
431                let content_type: &str = match &self.response_content_type {
432                    Some(Some(ct)) => ct.as_str(),
433                    _ => APPLICATION_JSON.as_ref(),
434                };
435            
436                schema = intern_schema_if_object_named(schema, schemas);
437                operation.set_response_body(schema, example, content_type);
438            }
439        }
440
441        if !self.extra_parameters.is_empty() {
442            let params = operation.parameters.get_or_insert_with(Vec::new);
443
444            for p in &self.extra_parameters {
445                if let Some(existing) = params
446                    .iter_mut()
447                    .find(|x| x.name == p.name && x.location == p.location)
448                {
449                    *existing = p.clone();
450                } else {
451                    params.push(p.clone());
452                }
453            }
454        }
455    }
456}
457
458fn intern_schema_if_object_named(
459    mut schema: OpenApiSchema,
460    schemas: &mut BTreeMap<String, OpenApiSchema>
461) -> OpenApiSchema {
462    if schema.schema_ref.is_some() {
463        return schema;
464    }
465
466    let base = match schema.title.clone() {
467        Some(t) if !t.is_empty() => t,
468        _ => return schema,
469    };
470
471    if schema.properties.is_none() {
472        return schema;
473    }
474
475    let mut name = base.clone();
476    if schemas.contains_key(&name) {
477        let mut i = 2;
478        while schemas.contains_key(&format!("{base}_{i}")) {
479            i += 1;
480        }
481        name = format!("{base}_{i}");
482    }
483
484    schema.title = None;
485
486    schemas
487        .entry(name.clone())
488        .or_insert(schema);
489
490    OpenApiSchema::reference(&name)
491}
492
493fn schema_and_example_from_deserialize<T: DeserializeOwned>() -> Option<(OpenApiSchema, Value)> {
494    let mut probe = Probe::new();
495    let _ = T::deserialize(&mut probe);
496    probe.finish()
497}
498
499fn type_display_name<T>() -> String {
500    std::any::type_name::<T>()
501        .rsplit("::")
502        .next()
503        .unwrap_or("Model")
504        .to_string()
505}
506
507#[cfg(test)]
508#[allow(unused)]
509mod tests {
510    use super::OpenApiRouteConfig;
511    use crate::{op::OpenApiOperation, schema::OpenApiSchema};
512    use serde::{Deserialize, Serialize};
513    use serde_json::json;
514    use std::collections::BTreeMap;
515
516    #[derive(Deserialize)]
517    struct Payload {
518        name: String,
519        age: u64,
520    }
521
522    #[derive(Serialize, Default)]
523    struct ResponsePayload {
524        message: String,
525    }
526
527    #[derive(Deserialize)]
528    struct OptionalQuery {
529        required_name: String,
530        optional_age: Option<()>,
531    }
532
533    #[test]
534    fn consumes_query_marks_non_optional_fields_as_required() {
535        let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalQuery>();
536
537        let required_name = cfg
538            .extra_parameters
539            .iter()
540            .find(|p| p.name == "required_name")
541            .expect("required_name param");
542        let optional_age = cfg
543            .extra_parameters
544            .iter()
545            .find(|p| p.name == "optional_age")
546            .expect("optional_age param");
547
548        assert!(required_name.required);
549        assert!(!optional_age.required);
550    }
551
552    #[test]
553    fn infer_request_schema_from_deserialize_type() {
554        let cfg = OpenApiRouteConfig::default()
555            .with_request_type_from_deserialize::<Payload>("application/json");
556
557        let schema = cfg
558            .request_schema
559            .expect("request schema should be inferred");
560        let props = schema.properties.expect("object properties should exist");
561
562        assert!(props.contains_key("name"));
563        assert!(props.contains_key("age"));
564        assert_eq!(
565            cfg.request_content_type.as_deref(),
566            Some("application/json")
567        );
568    }
569
570    #[test]
571    fn infer_query_parameters_from_deserialize_type() {
572        let cfg = OpenApiRouteConfig::default().with_query_parameters_from_deserialize::<Payload>();
573
574        assert_eq!(cfg.extra_parameters.len(), 2);
575        assert!(cfg.extra_parameters.iter().any(|p| p.name == "name"));
576        assert!(cfg.extra_parameters.iter().any(|p| p.name == "age"));
577        assert!(cfg.extra_parameters.iter().all(|p| p.location == "query"));
578    }
579
580    #[test]
581    fn merge_combines_docs_and_preserves_existing_values() {
582        let base = OpenApiRouteConfig::default()
583            .with_tag("base")
584            .with_summary("base summary")
585            .with_doc("v1");
586        let other = OpenApiRouteConfig::default()
587            .with_tag("extra")
588            .with_summary("other summary")
589            .with_doc("v1")
590            .with_doc("admin");
591
592        let merged = base.merge(&other);
593
594        assert_eq!(merged.summary.as_deref(), Some("base summary"));
595        assert_eq!(merged.tags.as_slice(), ["base", "extra"]);
596        assert_eq!(merged.docs.expect("docs"), vec!["v1", "admin"]);
597    }
598
599    #[test]
600    fn apply_to_operation_adds_content_and_interns_named_schema() {
601        let cfg = OpenApiRouteConfig::default()
602            .with_request_schema(
603                OpenApiSchema::object()
604                    .with_title("CreateUser")
605                    .with_property("name", OpenApiSchema::string()),
606            )
607            .with_response_schema(
608                OpenApiSchema::object()
609                    .with_title("User")
610                    .with_property("id", OpenApiSchema::integer()),
611            )
612            .produces_json_example(ResponsePayload {
613                message: "ok".to_string(),
614            })
615            .with_summary("create user")
616            .with_operation_id("createUser");
617
618        let mut op = OpenApiOperation::default();
619        let mut schemas = BTreeMap::new();
620
621        cfg.apply_to_operation(&mut op, &mut schemas);
622
623        let op_json = serde_json::to_value(op).expect("serialize operation");
624        assert_eq!(op_json["summary"], "create user");
625        assert_eq!(op_json["operationId"], "createUser");
626        assert!(
627            op_json["requestBody"]["content"]
628                .get("application/json")
629                .is_some()
630        );
631        assert_eq!(
632            op_json["responses"]["200"]["content"]["application/json"]["example"],
633            json!({"message":"ok"})
634        );
635
636        assert!(schemas.contains_key("CreateUser"));
637    }
638
639    #[test]
640    fn produces_no_schema_clears_content() {
641        let cfg = OpenApiRouteConfig::default()
642            .produces_json::<ResponsePayload>()
643            .produces_no_schema();
644
645        let mut op = OpenApiOperation::default();
646        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
647
648        let op_json = serde_json::to_value(op).expect("serialize operation");
649        assert!(
650            op_json["responses"]["200"]
651                .get("content")
652                .is_none()
653        );
654    }
655
656    #[test]
657    fn with_tags_extends_existing_tags() {
658        let cfg = OpenApiRouteConfig::default()
659            .with_tag("base")
660            .with_tags(["one", "two"]);
661
662        assert_eq!(cfg.tags, vec!["base", "one", "two"]);
663    }
664
665    #[test]
666    fn with_docs_initializes_and_extends_docs() {
667        let cfg = OpenApiRouteConfig::default()
668            .with_docs(["v1", "admin"])
669            .with_doc("internal");
670
671        assert_eq!(cfg.docs.expect("docs"), vec!["v1", "admin", "internal"]);
672    }
673
674    #[test]
675    fn with_description_sets_operation_description() {
676        let cfg = OpenApiRouteConfig::default().with_description("desc");
677        assert_eq!(cfg.description.as_deref(), Some("desc"));
678    }
679
680    #[test]
681    fn consumes_methods_set_request_content_type() {
682        let query_cfg = OpenApiRouteConfig::default().consumes_query::<Payload>();
683        assert_eq!(query_cfg.extra_parameters.len(), 2);
684
685        let json_cfg = OpenApiRouteConfig::default().consumes_json::<Payload>();
686        assert_eq!(
687            json_cfg.request_content_type.as_deref(),
688            Some("application/json")
689        );
690        assert!(json_cfg.request_schema.is_some());
691
692        let form_cfg = OpenApiRouteConfig::default().consumes_form::<Payload>();
693        assert_eq!(
694            form_cfg.request_content_type.as_deref(),
695            Some("application/x-www-form-urlencoded")
696        );
697        assert!(form_cfg.request_schema.is_some());
698
699        let multipart_cfg = OpenApiRouteConfig::default().consumes_multipart();
700        assert_eq!(
701            multipart_cfg.request_content_type.as_deref(),
702            Some("multipart/form-data")
703        );
704
705        let stream_cfg = OpenApiRouteConfig::default().consumes_stream();
706        assert_eq!(
707            stream_cfg.request_content_type.as_deref(),
708            Some("application/octet-stream")
709        );
710        assert_eq!(
711            stream_cfg.request_schema.expect("schema").format.as_deref(),
712            Some("binary")
713        );
714    }
715
716    #[test]
717    fn produces_helpers_set_expected_response_schema_and_content_type() {
718        let text_cfg = OpenApiRouteConfig::default().produces_text();
719        assert_eq!(
720            text_cfg
721                .response_content_type
722                .as_ref()
723                .and_then(|x| x.as_deref()),
724            Some("text/plain; charset=utf-8")
725        );
726        assert_eq!(
727            text_cfg
728                .response_schema
729                .expect("schema")
730                .expect("schema")
731                .schema_type
732                .as_deref(),
733            Some("string")
734        );
735
736        let empty_json_cfg = OpenApiRouteConfig::default().produces_empty_json();
737        assert_eq!(
738            empty_json_cfg
739                .response_content_type
740                .as_ref()
741                .and_then(|x| x.as_deref()),
742            Some("application/json")
743        );
744        assert_eq!(
745            empty_json_cfg
746                .response_schema
747                .expect("schema")
748                .expect("schema")
749                .schema_type
750                .as_deref(),
751            Some("object")
752        );
753
754        let form_cfg = OpenApiRouteConfig::default().produces_form::<ResponsePayload>();
755        assert_eq!(
756            form_cfg                
757                .response_content_type
758                .as_ref()
759                .and_then(|x| x.as_deref()),
760            Some("application/x-www-form-urlencoded")
761        );
762        assert_eq!(
763            form_cfg
764                .response_schema
765                .expect("schema")
766                .expect("schema")
767                .schema_type
768                .as_deref(),
769            Some("string")
770        );
771
772        let empty_form_cfg = OpenApiRouteConfig::default().produces_empty_form();
773        assert_eq!(
774            empty_form_cfg                
775                .response_content_type
776                .as_ref()
777                .and_then(|x| x.as_deref()),
778            Some("application/x-www-form-urlencoded")
779        );
780        assert_eq!(
781            empty_form_cfg
782                .response_schema
783                .expect("schema")
784                .expect("schema")
785                .schema_type
786                .as_deref(),
787            Some("object")
788        );
789
790        let stream_cfg = OpenApiRouteConfig::default().produces_stream();
791        assert_eq!(
792            stream_cfg                
793                .response_content_type
794                .as_ref()
795                .and_then(|x| x.as_deref()),
796            Some("application/octet-stream")
797        );
798        assert_eq!(
799            stream_cfg
800                .response_schema
801                .expect("schema")
802                .expect("schema")
803                .format
804                .as_deref(),
805            Some("binary")
806        );
807
808        let sse_cfg = OpenApiRouteConfig::default().produces_sse();
809        assert_eq!(
810            sse_cfg                
811                .response_content_type
812                .as_ref()
813                .and_then(|x| x.as_deref()),
814            Some("text/event-stream")
815        );
816        assert_eq!(
817            sse_cfg.response_schema
818                .expect("schema")
819                .expect("schema")
820                .title
821                .as_deref(),
822            Some("SSE stream")
823        );
824    }
825
826    #[test]
827    fn produces_form_example_encodes_object_into_string_example() {
828        #[derive(Serialize)]
829        struct FormOut {
830            first: String,
831            second: i32,
832        }
833
834        let cfg = OpenApiRouteConfig::default().produces_form_example(FormOut {
835            first: "hello".to_string(),
836            second: 7,
837        });
838
839        assert_eq!(
840            cfg.response_content_type
841                .as_ref()
842                .and_then(|x| x.as_deref()),
843            Some("application/x-www-form-urlencoded")
844        );
845        assert_eq!(
846            cfg.response_schema
847                .expect("schema")
848                .expect("schema")
849                .schema_type
850                .as_deref(),
851            Some("string")
852        );
853        assert_eq!(
854            cfg.response_example,
855            Some(Some(serde_json::Value::String(
856                "first=hello&second=7".to_string()
857            )))
858        );
859    }
860}