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::{FieldConstraint, 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/// What an extractor describes, and therefore what its constraints apply to
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum ConstraintTarget {
67    /// The request body
68    RequestBody,
69    /// The query parameters this extractor added, which are the ones from
70    /// `described_before` onward
71    QueryParameter {
72        /// How many parameters the operation already described
73        described_before: usize,
74    },
75    /// The path parameters this extractor added; see [`ConstraintTarget::QueryParameter`]
76    PathParameter {
77        /// How many parameters the operation already described
78        described_before: usize,
79    },
80}
81
82/// Per-route OpenAPI metadata.
83#[derive(Clone, Debug, Default)]
84pub struct OpenApiRouteConfig {
85    tags: Vec<String>,
86    docs: Option<Vec<String>>,
87    summary: Option<String>,
88    description: Option<String>,
89    operation_id: Option<String>,
90    request_schema: Option<OpenApiSchema>,
91    request_example: Option<Value>,
92    request_content_type: Option<String>,
93    responses: BTreeMap<u16, ResponseBody>,
94    extra_parameters: Vec<OpenApiParameter>,
95}
96
97impl OpenApiRouteConfig {
98    /// Returns how many parameters this operation already describes.
99    ///
100    /// Taken before an extractor is described and handed back through [`ConstraintTarget`],
101    /// so its constraints reach its own parameters and no others.
102    pub fn described_parameters(&self) -> usize {
103        self.extra_parameters.len()
104    }
105
106    /// Returns a list of docs that this route is assigned to
107    pub(crate) fn docs(&self) -> Option<&[String]> {
108        self.docs.as_deref()
109    }
110
111    /// Adds a tag to the operation.
112    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
113        self.tags.push(tag.into());
114        self
115    }
116
117    /// Adds multiple tags to the operation.
118    pub fn with_tags<I, T>(mut self, tags: I) -> Self
119    where
120        I: IntoIterator<Item = T>,
121        T: Into<String>,
122    {
123        self.tags.extend(tags.into_iter().map(Into::into));
124        self
125    }
126
127    /// Binds the operation with a document
128    pub fn with_doc(mut self, doc: impl Into<String>) -> Self {
129        self.docs.get_or_insert_with(Vec::new).push(doc.into());
130        self
131    }
132
133    /// Binds the operation with documents
134    pub fn with_docs<I, S>(mut self, docs: I) -> Self
135    where
136        I: IntoIterator<Item = S>,
137        S: Into<String>,
138    {
139        self.docs
140            .get_or_insert_with(Vec::new)
141            .extend(docs.into_iter().map(Into::into));
142        self
143    }
144
145    /// Sets the operation summary.
146    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
147        self.summary = Some(summary.into());
148        self
149    }
150
151    /// Sets the operation description.
152    pub fn with_description(mut self, description: impl Into<String>) -> Self {
153        self.description = Some(description.into());
154        self
155    }
156
157    /// Sets the operation id.
158    pub fn with_operation_id(mut self, operation_id: impl Into<String>) -> Self {
159        self.operation_id = Some(operation_id.into());
160        self
161    }
162
163    /// Sets the request body schema for this operation.
164    pub fn with_request_schema(mut self, schema: OpenApiSchema) -> Self {
165        self.request_schema = Some(schema);
166        self
167    }
168
169    /// Sets the response schema for this operation.
170    pub fn with_response_schema(
171        mut self,
172        status: impl IntoStatusCode,
173        schema: OpenApiSchema,
174    ) -> Self {
175        let status = status.into_status_code();
176        self.responses.insert(
177            status,
178            ResponseBody::Content {
179                schema: Box::new(schema),
180                example: None,
181                content_type: APPLICATION_JSON.to_string(),
182            },
183        );
184        self
185    }
186
187    /// Generates path parameters schema.
188    pub fn consumes_named_path<T: DeserializeOwned>(self) -> Self {
189        self.with_named_path_parameters_from_deserialize::<T>()
190    }
191
192    /// Generates query parameters schema.
193    pub fn consumes_query<T: DeserializeOwned>(self) -> Self {
194        self.with_query_parameters_from_deserialize::<T>()
195    }
196
197    /// Generates JSON request schema and example.
198    pub fn consumes_json<T: DeserializeOwned>(self) -> Self {
199        self.with_request_type_from_deserialize::<T>(APPLICATION_JSON.as_ref())
200    }
201
202    /// Generates form request schema and example.
203    pub fn consumes_form<T: DeserializeOwned>(self) -> Self {
204        self.with_request_type_from_deserialize::<T>(APPLICATION_WWW_FORM_URLENCODED.as_ref())
205    }
206
207    /// Generates multipart request schema.
208    pub fn consumes_multipart(self) -> Self {
209        self.with_multipart_request()
210    }
211
212    /// Generates stream request schema.
213    pub fn consumes_stream(self) -> Self {
214        self.with_stream_request()
215    }
216
217    /// Generates text/plain response schema.
218    pub fn produces_text(mut self, status: impl IntoStatusCode) -> Self {
219        let status = status.into_status_code();
220        self.responses.insert(
221            status,
222            ResponseBody::Content {
223                schema: Box::new(OpenApiSchema::string()),
224                example: None,
225                content_type: TEXT_PLAIN_UTF_8.to_string(),
226            },
227        );
228        self
229    }
230
231    /// Generates a response without a schema.
232    pub fn produces_no_schema(mut self, status: impl IntoStatusCode) -> Self {
233        let status = status.into_status_code();
234        self.responses.insert(status, ResponseBody::NoContent);
235        self
236    }
237
238    /// Generates JSON response schema and example from `T::default()`.
239    pub fn produces_json<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
240        self.produces_json_example(status, T::default())
241    }
242
243    /// Generates empty JSON response schema.
244    pub fn produces_empty_json(mut self, status: impl IntoStatusCode) -> Self {
245        let status = status.into_status_code();
246        self.responses.insert(
247            status,
248            ResponseBody::Content {
249                schema: Box::new(OpenApiSchema::object()),
250                example: None,
251                content_type: APPLICATION_JSON.to_string(),
252            },
253        );
254        self
255    }
256
257    /// Generates form response schema and example from `T::default()`.
258    pub fn produces_form<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
259        self.produces_form_example(status, T::default())
260    }
261
262    /// Generates empty form response schema.
263    pub fn produces_empty_form(mut self, status: impl IntoStatusCode) -> Self {
264        let status = status.into_status_code();
265        self.responses.insert(
266            status,
267            ResponseBody::Content {
268                schema: Box::new(OpenApiSchema::object()),
269                example: None,
270                content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
271            },
272        );
273        self
274    }
275
276    /// Generates default `multipart/form-data` response schema.
277    pub fn produces_multipart(mut self, status: impl IntoStatusCode) -> Self {
278        let status = status.into_status_code();
279        self.responses.insert(
280            status,
281            ResponseBody::Content {
282                schema: Box::new(OpenApiSchema::multipart()),
283                example: None,
284                content_type: MULTIPART_FORM_DATA.to_string(),
285            },
286        );
287        self
288    }
289
290    /// Generates stream response schema.
291    pub fn produces_stream(mut self, status: impl IntoStatusCode) -> Self {
292        let status = status.into_status_code();
293        self.responses.insert(
294            status,
295            ResponseBody::Content {
296                schema: Box::new(OpenApiSchema::binary()),
297                example: None,
298                content_type: APPLICATION_OCTET_STREAM.to_string(),
299            },
300        );
301        self
302    }
303
304    /// Generates SSE stream response schema.
305    pub fn produces_sse(mut self, status: impl IntoStatusCode) -> Self {
306        let status = status.into_status_code();
307        self.responses.insert(
308            status,
309            ResponseBody::Content {
310                schema: Box::new(OpenApiSchema::string().with_title("SSE stream")),
311                example: None,
312                content_type: TEXT_EVENT_STREAM.to_string(),
313            },
314        );
315        self
316    }
317
318    /// Generates JSON response schema and example.
319    pub fn produces_json_example<T: Serialize>(
320        mut self,
321        status: impl IntoStatusCode,
322        example: T,
323    ) -> Self {
324        let status = status.into_status_code();
325        let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
326        self.responses.insert(
327            status,
328            ResponseBody::Content {
329                schema: Box::new(OpenApiSchema::from_example(&example)),
330                example: Some(example),
331                content_type: APPLICATION_JSON.to_string(),
332            },
333        );
334        self
335    }
336
337    /// Generates form response schema and example.
338    pub fn produces_form_example<T: Serialize>(
339        mut self,
340        status: impl IntoStatusCode,
341        example: T,
342    ) -> Self {
343        let status = status.into_status_code();
344        let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
345
346        let encoded = match &example {
347            Value::Object(map) => serde_urlencoded::to_string(map).unwrap_or_default(),
348            _ => String::new(),
349        };
350
351        self.responses.insert(
352            status,
353            ResponseBody::Content {
354                schema: Box::new(OpenApiSchema::string()),
355                example: Some(Value::String(encoded)),
356                content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
357            },
358        );
359        self
360    }
361
362    /// Generates an `application/problem+json` response schema for the given status code (RFC 7807).
363    ///
364    /// The example is derived from the status code using its canonical reason phrase.
365    #[cfg(feature = "problem-details")]
366    pub fn produces_problem(mut self, status: impl IntoStatusCode) -> Self {
367        let status = status.into_status_code();
368        let title = http::StatusCode::from_u16(status)
369            .ok()
370            .and_then(|s| s.canonical_reason())
371            .unwrap_or("Error");
372        let example = json!({
373            "type": "https://tools.ietf.org/html/rfc9110",
374            "title": title,
375            "status": status
376        });
377        self.responses.insert(
378            status,
379            ResponseBody::Content {
380                schema: Box::new(OpenApiSchema::from_example(&example)),
381                example: Some(example),
382                content_type: "application/problem+json".to_string(),
383            },
384        );
385        self
386    }
387
388    /// Generates an `application/problem+json` response schema and example (RFC 7807).
389    ///
390    /// The schema is inferred from the provided example value.
391    #[cfg(feature = "problem-details")]
392    pub fn produces_problem_example<T: Serialize>(
393        mut self,
394        status: impl IntoStatusCode,
395        example: T,
396    ) -> Self {
397        let status = status.into_status_code();
398        let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
399        self.responses.insert(
400            status,
401            ResponseBody::Content {
402                schema: Box::new(OpenApiSchema::from_example(&example)),
403                example: Some(example),
404                content_type: "application/problem+json".to_string(),
405            },
406        );
407        self
408    }
409
410    /// Generates default Multipart request schema
411    fn with_multipart_request(mut self) -> Self {
412        self.request_schema
413            .get_or_insert_with(OpenApiSchema::multipart);
414        self.request_content_type = Some(MULTIPART_FORM_DATA.to_string());
415        self
416    }
417
418    /// Generates default stream request schema
419    fn with_stream_request(mut self) -> Self {
420        self.request_schema
421            .get_or_insert_with(OpenApiSchema::binary);
422        self.request_content_type = Some(APPLICATION_OCTET_STREAM.to_string());
423        self
424    }
425
426    fn with_query_parameter(mut self, name: String, schema: OpenApiSchema, required: bool) -> Self {
427        self.extra_parameters.push(OpenApiParameter {
428            name,
429            location: "query".to_string(),
430            required,
431            schema,
432        });
433        self
434    }
435
436    fn with_path_parameter(mut self, name: String, schema: OpenApiSchema) -> Self {
437        self.extra_parameters.push(OpenApiParameter {
438            name,
439            location: "path".to_string(),
440            required: true,
441            schema,
442        });
443        self
444    }
445
446    /// Generates request schema and example from `T`.
447    fn with_request_type_from_deserialize<T: DeserializeOwned>(
448        mut self,
449        content_type: &str,
450    ) -> Self {
451        if let Some((schema, example)) = schema_and_example_from_deserialize::<T>() {
452            self.request_schema = Some(schema.with_title(type_display_name::<T>()));
453            self.request_example = Some(example);
454        }
455        self.request_content_type = Some(content_type.to_string());
456        self
457    }
458
459    /// Publishes the constraints of the extractor that declared them, on the one thing in
460    /// this operation that extractor describes.
461    ///
462    /// The target matters: handler arguments are described one after another, so a body and
463    /// a query struct sharing a field name would otherwise hand each other their bounds.
464    /// A field the target does not describe is ignored.
465    pub fn with_constraints(
466        mut self,
467        target: ConstraintTarget,
468        constraints: &[FieldConstraint],
469    ) -> Self {
470        match target {
471            ConstraintTarget::RequestBody => {
472                if let Some(schema) = self.request_schema.as_mut() {
473                    schema.apply_field_constraints(constraints);
474                }
475            }
476            ConstraintTarget::QueryParameter { described_before } => {
477                self.apply_to_parameters("query", described_before, constraints);
478            }
479            ConstraintTarget::PathParameter { described_before } => {
480                self.apply_to_parameters("path", described_before, constraints);
481            }
482        }
483        self
484    }
485
486    /// Applies the constraints to the parameters one extractor added, by name.
487    ///
488    /// Parameters are appended as each handler argument is described, so the ones belonging
489    /// to the extractor that declared these constraints are exactly those past
490    /// `described_before`. Searching further back would hand an earlier extractor a bound
491    /// that is not its own - and an extractor whose schema could not be inferred adds no
492    /// parameters at all, so there would be nothing else for the search to find.
493    fn apply_to_parameters(
494        &mut self,
495        location: &str,
496        described_before: usize,
497        constraints: &[FieldConstraint],
498    ) {
499        let Some(described) = self.extra_parameters.get_mut(described_before..) else {
500            return;
501        };
502
503        for parameter in described
504            .iter_mut()
505            .filter(|parameter| parameter.location == location)
506        {
507            // A field carries more than one constraint - a minimum and a maximum are two
508            for constraint in constraints
509                .iter()
510                .filter(|constraint| constraint.field == parameter.name)
511            {
512                parameter.schema.apply_constraint(&constraint.constraint);
513            }
514        }
515    }
516
517    fn with_query_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
518        if let Some((schema, _)) = schema_and_example_from_deserialize::<T>()
519            && let Some(properties) = schema.properties
520        {
521            let required = schema.required.unwrap_or_default();
522
523            for (name, property_schema) in properties {
524                let is_required = required.iter().any(|f| f == &name);
525                self = self.with_query_parameter(name, property_schema, is_required);
526            }
527        }
528        self
529    }
530
531    fn with_named_path_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
532        if let Some((schema, _)) = schema_and_example_from_deserialize::<T>()
533            && let Some(properties) = schema.properties
534        {
535            for (name, property_schema) in properties {
536                self = self.with_path_parameter(name, property_schema);
537            }
538        }
539        self
540    }
541
542    /// Merges configurations.
543    pub fn merge(mut self, other: &Self) -> Self {
544        if self.tags.is_empty() {
545            self.tags = other.tags.clone();
546        } else {
547            self.tags.extend(other.tags.clone());
548        }
549        if self.summary.is_none() {
550            self.summary = other.summary.clone();
551        }
552        if self.description.is_none() {
553            self.description = other.description.clone();
554        }
555        if self.operation_id.is_none() {
556            self.operation_id = other.operation_id.clone();
557        }
558        if self.request_schema.is_none() {
559            self.request_schema = other.request_schema.clone();
560        }
561        if self.request_example.is_none() {
562            self.request_example = other.request_example.clone();
563        }
564        if self.request_content_type.is_none() {
565            self.request_content_type = other.request_content_type.clone();
566        }
567        for (status, body) in &other.responses {
568            self.responses
569                .entry(*status)
570                .or_insert_with(|| body.clone());
571        }
572        if !other.extra_parameters.is_empty() {
573            self.extra_parameters.extend(other.extra_parameters.clone());
574        }
575        match (&mut self.docs, &other.docs) {
576            (None, Some(d)) => self.docs = Some(d.clone()),
577            (Some(dst), Some(src)) => {
578                for s in src {
579                    if !dst.iter().any(|x| x == s) {
580                        dst.push(s.clone());
581                    }
582                }
583            }
584            _ => {}
585        }
586        self
587    }
588
589    pub(super) fn apply_to_operation(
590        &self,
591        operation: &mut OpenApiOperation,
592        schemas: &mut BTreeMap<String, OpenApiSchema>,
593    ) {
594        if let Some(summary) = &self.summary {
595            operation.summary = Some(summary.clone());
596        }
597        if let Some(description) = &self.description {
598            operation.description = Some(description.clone());
599        }
600        if let Some(operation_id) = &self.operation_id {
601            operation.operation_id = Some(operation_id.clone());
602        }
603
604        if !self.tags.is_empty() {
605            operation.tags = Some(self.tags.clone());
606        }
607
608        if self.request_schema.is_some() || self.request_example.is_some() {
609            let mut schema = self
610                .request_schema
611                .clone()
612                .unwrap_or_else(OpenApiSchema::object);
613            let example = self.request_example.clone();
614            let content_type = self
615                .request_content_type
616                .as_deref()
617                .unwrap_or(APPLICATION_JSON.as_ref());
618
619            schema = intern_schema_if_object_named(schema, schemas);
620            operation.set_request_body(schema, example, content_type);
621        }
622
623        if !self.responses.is_empty() {
624            operation.clear_all_responses();
625            for (status, body) in &self.responses {
626                match body {
627                    ResponseBody::NoContent => {
628                        operation.clear_response_body(*status);
629                    }
630                    ResponseBody::Content {
631                        schema,
632                        example,
633                        content_type,
634                    } => {
635                        let schema = intern_schema_if_object_named((**schema).clone(), schemas);
636                        operation.set_response_body(*status, schema, example.clone(), content_type);
637                    }
638                }
639            }
640        }
641
642        if !self.extra_parameters.is_empty() {
643            let params = operation.parameters.get_or_insert_with(Vec::new);
644
645            for p in &self.extra_parameters {
646                if let Some(existing) = params
647                    .iter_mut()
648                    .find(|x| x.name == p.name && x.location == p.location)
649                {
650                    *existing = p.clone();
651                } else {
652                    params.push(p.clone());
653                }
654            }
655        }
656    }
657}
658
659fn intern_schema_if_object_named(
660    mut schema: OpenApiSchema,
661    schemas: &mut BTreeMap<String, OpenApiSchema>,
662) -> OpenApiSchema {
663    if schema.schema_ref.is_some() {
664        return schema;
665    }
666
667    let base = match schema.title.clone() {
668        Some(t) if !t.is_empty() => t,
669        _ => return schema,
670    };
671
672    if schema.properties.is_none() {
673        return schema;
674    }
675
676    let mut name = base.clone();
677    if schemas.contains_key(&name) {
678        let mut i = 2;
679        while schemas.contains_key(&format!("{base}_{i}")) {
680            i += 1;
681        }
682        name = format!("{base}_{i}");
683    }
684
685    schema.title = None;
686
687    schemas.entry(name.clone()).or_insert(schema);
688
689    OpenApiSchema::reference(&name)
690}
691
692fn schema_and_example_from_deserialize<T: DeserializeOwned>() -> Option<(OpenApiSchema, Value)> {
693    let mut probe = Probe::new();
694    let _ = T::deserialize(&mut probe);
695    probe.finish()
696}
697
698fn type_display_name<T>() -> String {
699    std::any::type_name::<T>()
700        .rsplit("::")
701        .next()
702        .unwrap_or("Model")
703        .to_string()
704}
705
706#[cfg(test)]
707#[allow(unused)]
708mod tests {
709    use super::{ConstraintTarget, IntoStatusCode, OpenApiRouteConfig, ResponseBody};
710    use crate::{
711        op::OpenApiOperation,
712        schema::{FieldConstraint, OpenApiSchema, SchemaConstraint},
713    };
714    use serde::{Deserialize, Serialize};
715    use serde_json::json;
716    use std::collections::BTreeMap;
717
718    #[derive(Deserialize)]
719    struct Payload {
720        name: String,
721        age: u64,
722    }
723
724    #[derive(Serialize, Default)]
725    struct ResponsePayload {
726        message: String,
727    }
728
729    #[derive(Deserialize)]
730    struct OptionalQuery {
731        required_name: String,
732        optional_age: Option<()>,
733    }
734
735    #[derive(Deserialize)]
736    #[allow(dead_code)]
737    struct Page {
738        page: u32,
739    }
740
741    #[test]
742    fn with_constraints_touches_only_the_parameter_its_extractor_added() {
743        // Two extractors describing a query parameter of the same name: each is described
744        // in turn, appending its own parameter, and neither may reach into the other's
745        let cfg = OpenApiRouteConfig::default();
746
747        let described_before = cfg.described_parameters();
748        let cfg = cfg.consumes_query::<Page>().with_constraints(
749            ConstraintTarget::QueryParameter { described_before },
750            &[FieldConstraint::new(
751                "page",
752                SchemaConstraint::Minimum(1.into()),
753            )],
754        );
755
756        let described_before = cfg.described_parameters();
757        let cfg = cfg.consumes_query::<Page>().with_constraints(
758            ConstraintTarget::QueryParameter { described_before },
759            &[FieldConstraint::new(
760                "page",
761                SchemaConstraint::Minimum(100.into()),
762            )],
763        );
764
765        let bounds = cfg
766            .extra_parameters
767            .iter()
768            .map(|parameter| parameter.schema.minimum.clone())
769            .collect::<Vec<_>>();
770
771        assert_eq!(bounds, vec![Some(1.into()), Some(100.into())]);
772    }
773
774    #[test]
775    fn with_constraints_of_an_extractor_that_described_nothing_reach_nobody() {
776        // A struct whose schema cannot be inferred - an enum field is enough - adds no
777        // parameters, so its bounds have no target and must not fall back to someone else's
778        let cfg = OpenApiRouteConfig::default();
779        let described_before = cfg.described_parameters();
780        let cfg = cfg.consumes_query::<Page>().with_constraints(
781            ConstraintTarget::QueryParameter { described_before },
782            &[FieldConstraint::new(
783                "page",
784                SchemaConstraint::Minimum(1.into()),
785            )],
786        );
787
788        let described_before = cfg.described_parameters();
789        let cfg = cfg.with_constraints(
790            ConstraintTarget::QueryParameter { described_before },
791            &[FieldConstraint::new(
792                "page",
793                SchemaConstraint::Minimum(100.into()),
794            )],
795        );
796
797        assert_eq!(cfg.extra_parameters.len(), 1);
798        assert_eq!(cfg.extra_parameters[0].schema.minimum, Some(1.into()));
799    }
800
801    #[test]
802    fn with_constraints_intersects_a_bound_declared_twice() {
803        // Both rules run at runtime, so the published bound is the one that survives both
804        let cfg = OpenApiRouteConfig::default()
805            .consumes_query::<Page>()
806            .with_constraints(
807                ConstraintTarget::QueryParameter {
808                    described_before: 0,
809                },
810                &[
811                    FieldConstraint::new("page", SchemaConstraint::Minimum(10.into())),
812                    FieldConstraint::new("page", SchemaConstraint::Minimum(1.into())),
813                    FieldConstraint::new("page", SchemaConstraint::Maximum(50.into())),
814                    FieldConstraint::new("page", SchemaConstraint::Maximum(500.into())),
815                ],
816            );
817
818        let schema = &cfg.extra_parameters[0].schema;
819
820        assert_eq!(schema.minimum, Some(10.into()));
821        assert_eq!(schema.maximum, Some(50.into()));
822    }
823
824    #[test]
825    fn consumes_query_keeps_parameters_of_a_typed_optional_field() {
826        #[derive(serde::Deserialize)]
827        #[allow(dead_code)]
828        struct OptionalString {
829            per_page: u32,
830            sort: Option<String>,
831        }
832
833        let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalString>();
834        let names = cfg
835            .extra_parameters
836            .iter()
837            .map(|p| p.name.as_str())
838            .collect::<Vec<_>>();
839
840        // A typed `Option` used to make the whole probe fail, dropping every parameter
841        assert_eq!(names, vec!["per_page", "sort"]);
842        assert!(!cfg.extra_parameters[1].required);
843    }
844
845    #[test]
846    fn consumes_query_marks_non_optional_fields_as_required() {
847        let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalQuery>();
848
849        let required_name = cfg
850            .extra_parameters
851            .iter()
852            .find(|p| p.name == "required_name")
853            .expect("required_name param");
854        let optional_age = cfg
855            .extra_parameters
856            .iter()
857            .find(|p| p.name == "optional_age")
858            .expect("optional_age param");
859
860        assert!(required_name.required);
861        assert!(!optional_age.required);
862    }
863
864    #[test]
865    fn infer_request_schema_from_deserialize_type() {
866        let cfg = OpenApiRouteConfig::default()
867            .with_request_type_from_deserialize::<Payload>("application/json");
868
869        let schema = cfg
870            .request_schema
871            .expect("request schema should be inferred");
872        let props = schema.properties.expect("object properties should exist");
873
874        assert!(props.contains_key("name"));
875        assert!(props.contains_key("age"));
876        assert_eq!(
877            cfg.request_content_type.as_deref(),
878            Some("application/json")
879        );
880    }
881
882    #[test]
883    fn infer_query_parameters_from_deserialize_type() {
884        let cfg = OpenApiRouteConfig::default().with_query_parameters_from_deserialize::<Payload>();
885
886        assert_eq!(cfg.extra_parameters.len(), 2);
887        assert!(cfg.extra_parameters.iter().any(|p| p.name == "name"));
888        assert!(cfg.extra_parameters.iter().any(|p| p.name == "age"));
889        assert!(cfg.extra_parameters.iter().all(|p| p.location == "query"));
890    }
891
892    #[test]
893    fn merge_combines_docs_and_preserves_existing_values() {
894        let base = OpenApiRouteConfig::default()
895            .with_tag("base")
896            .with_summary("base summary")
897            .with_doc("v1");
898        let other = OpenApiRouteConfig::default()
899            .with_tag("extra")
900            .with_summary("other summary")
901            .with_doc("v1")
902            .with_doc("admin");
903
904        let merged = base.merge(&other);
905
906        assert_eq!(merged.summary.as_deref(), Some("base summary"));
907        assert_eq!(merged.tags.as_slice(), ["base", "extra"]);
908        assert_eq!(merged.docs.expect("docs"), vec!["v1", "admin"]);
909    }
910
911    #[test]
912    fn apply_to_operation_adds_content_and_interns_named_schema() {
913        let cfg = OpenApiRouteConfig::default()
914            .with_request_schema(
915                OpenApiSchema::object()
916                    .with_title("CreateUser")
917                    .with_property("name", OpenApiSchema::string()),
918            )
919            .with_response_schema(
920                200u16,
921                OpenApiSchema::object()
922                    .with_title("User")
923                    .with_property("id", OpenApiSchema::integer()),
924            )
925            .produces_json_example(
926                200u16,
927                ResponsePayload {
928                    message: "ok".to_string(),
929                },
930            )
931            .with_summary("create user")
932            .with_operation_id("createUser");
933
934        let mut op = OpenApiOperation::default();
935        let mut schemas = BTreeMap::new();
936
937        cfg.apply_to_operation(&mut op, &mut schemas);
938
939        let op_json = serde_json::to_value(op).expect("serialize operation");
940        assert_eq!(op_json["summary"], "create user");
941        assert_eq!(op_json["operationId"], "createUser");
942        assert!(
943            op_json["requestBody"]["content"]
944                .get("application/json")
945                .is_some()
946        );
947        assert_eq!(
948            op_json["responses"]["200"]["content"]["application/json"]["example"],
949            json!({"message":"ok"})
950        );
951
952        assert!(schemas.contains_key("CreateUser"));
953    }
954
955    #[test]
956    fn produces_no_schema_clears_content() {
957        let cfg = OpenApiRouteConfig::default()
958            .produces_json::<ResponsePayload>(200)
959            .produces_no_schema(200);
960
961        let mut op = OpenApiOperation::default();
962        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
963
964        let op_json = serde_json::to_value(op).expect("serialize operation");
965        assert!(op_json["responses"]["200"].get("content").is_none());
966    }
967
968    #[test]
969    fn with_tags_extends_existing_tags() {
970        let cfg = OpenApiRouteConfig::default()
971            .with_tag("base")
972            .with_tags(["one", "two"]);
973
974        assert_eq!(cfg.tags, vec!["base", "one", "two"]);
975    }
976
977    #[test]
978    fn with_docs_initializes_and_extends_docs() {
979        let cfg = OpenApiRouteConfig::default()
980            .with_docs(["v1", "admin"])
981            .with_doc("internal");
982
983        assert_eq!(cfg.docs.expect("docs"), vec!["v1", "admin", "internal"]);
984    }
985
986    #[test]
987    fn with_description_sets_operation_description() {
988        let cfg = OpenApiRouteConfig::default().with_description("desc");
989        assert_eq!(cfg.description.as_deref(), Some("desc"));
990    }
991
992    #[test]
993    fn consumes_methods_set_request_content_type() {
994        let query_cfg = OpenApiRouteConfig::default().consumes_query::<Payload>();
995        assert_eq!(query_cfg.extra_parameters.len(), 2);
996
997        let json_cfg = OpenApiRouteConfig::default().consumes_json::<Payload>();
998        assert_eq!(
999            json_cfg.request_content_type.as_deref(),
1000            Some("application/json")
1001        );
1002        assert!(json_cfg.request_schema.is_some());
1003
1004        let form_cfg = OpenApiRouteConfig::default().consumes_form::<Payload>();
1005        assert_eq!(
1006            form_cfg.request_content_type.as_deref(),
1007            Some("application/x-www-form-urlencoded")
1008        );
1009        assert!(form_cfg.request_schema.is_some());
1010
1011        let multipart_cfg = OpenApiRouteConfig::default().consumes_multipart();
1012        assert_eq!(
1013            multipart_cfg.request_content_type.as_deref(),
1014            Some("multipart/form-data")
1015        );
1016
1017        let stream_cfg = OpenApiRouteConfig::default().consumes_stream();
1018        assert_eq!(
1019            stream_cfg.request_content_type.as_deref(),
1020            Some("application/octet-stream")
1021        );
1022        assert_eq!(
1023            stream_cfg.request_schema.expect("schema").format.as_deref(),
1024            Some("binary")
1025        );
1026    }
1027
1028    #[test]
1029    fn produces_helpers_set_expected_response_schema_and_content_type() {
1030        fn apply(cfg: OpenApiRouteConfig) -> serde_json::Value {
1031            let mut op = OpenApiOperation::default();
1032            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1033            serde_json::to_value(op).expect("serialize")
1034        }
1035
1036        let text_json = apply(OpenApiRouteConfig::default().produces_text(200));
1037        assert!(
1038            text_json["responses"]["200"]["content"]
1039                .get("text/plain; charset=utf-8")
1040                .is_some()
1041        );
1042        assert_eq!(
1043            text_json["responses"]["200"]["content"]["text/plain; charset=utf-8"]["schema"]["type"],
1044            "string"
1045        );
1046
1047        let empty_json_json = apply(OpenApiRouteConfig::default().produces_empty_json(200));
1048        assert!(
1049            empty_json_json["responses"]["200"]["content"]
1050                .get("application/json")
1051                .is_some()
1052        );
1053        assert_eq!(
1054            empty_json_json["responses"]["200"]["content"]["application/json"]["schema"]["type"],
1055            "object"
1056        );
1057
1058        let form_json = apply(OpenApiRouteConfig::default().produces_form::<ResponsePayload>(200));
1059        assert!(
1060            form_json["responses"]["200"]["content"]
1061                .get("application/x-www-form-urlencoded")
1062                .is_some()
1063        );
1064        assert_eq!(
1065            form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1066                ["type"],
1067            "string"
1068        );
1069
1070        let empty_form_json = apply(OpenApiRouteConfig::default().produces_empty_form(200));
1071        assert!(
1072            empty_form_json["responses"]["200"]["content"]
1073                .get("application/x-www-form-urlencoded")
1074                .is_some()
1075        );
1076        assert_eq!(
1077            empty_form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1078                ["type"],
1079            "object"
1080        );
1081
1082        let stream_json = apply(OpenApiRouteConfig::default().produces_stream(200));
1083        assert!(
1084            stream_json["responses"]["200"]["content"]
1085                .get("application/octet-stream")
1086                .is_some()
1087        );
1088        assert_eq!(
1089            stream_json["responses"]["200"]["content"]["application/octet-stream"]["schema"]["format"],
1090            "binary"
1091        );
1092
1093        let sse_json = apply(OpenApiRouteConfig::default().produces_sse(200));
1094        assert!(
1095            sse_json["responses"]["200"]["content"]
1096                .get("text/event-stream")
1097                .is_some()
1098        );
1099        assert_eq!(
1100            sse_json["responses"]["200"]["content"]["text/event-stream"]["schema"]["title"],
1101            "SSE stream"
1102        );
1103    }
1104
1105    #[test]
1106    fn produces_form_example_encodes_object_into_string_example() {
1107        #[derive(Serialize)]
1108        struct FormOut {
1109            first: String,
1110            second: i32,
1111        }
1112
1113        let cfg = OpenApiRouteConfig::default().produces_form_example(
1114            200u16,
1115            FormOut {
1116                first: "hello".to_string(),
1117                second: 7,
1118            },
1119        );
1120
1121        let mut op = OpenApiOperation::default();
1122        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1123        let op_json = serde_json::to_value(op).expect("serialize");
1124
1125        assert!(
1126            op_json["responses"]["200"]["content"]
1127                .get("application/x-www-form-urlencoded")
1128                .is_some()
1129        );
1130        assert_eq!(
1131            op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]["type"],
1132            "string"
1133        );
1134        assert_eq!(
1135            op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["example"],
1136            "first=hello&second=7"
1137        );
1138    }
1139
1140    #[test]
1141    fn produces_multiple_status_codes() {
1142        #[derive(Serialize, Default)]
1143        struct UserDto {
1144            id: u64,
1145        }
1146        #[derive(Serialize, Default)]
1147        struct ErrorDto {
1148            message: String,
1149        }
1150
1151        let cfg = OpenApiRouteConfig::default()
1152            .produces_json::<UserDto>(200)
1153            .produces_json::<ErrorDto>(404);
1154
1155        let mut op = OpenApiOperation::default();
1156        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1157        let op_json = serde_json::to_value(op).expect("serialize");
1158
1159        // Both statuses should appear
1160        assert!(
1161            op_json["responses"]["200"]["content"]
1162                .get("application/json")
1163                .is_some()
1164        );
1165        assert!(
1166            op_json["responses"]["404"]["content"]
1167                .get("application/json")
1168                .is_some()
1169        );
1170        // Default 200 is replaced by explicit responses
1171        assert_eq!(
1172            op_json["responses"]
1173                .as_object()
1174                .expect("responses object")
1175                .len(),
1176            2
1177        );
1178    }
1179
1180    #[test]
1181    fn explicit_responses_replace_default_200() {
1182        let cfg = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(201);
1183
1184        let mut op = OpenApiOperation::default();
1185        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1186        let op_json = serde_json::to_value(op).expect("serialize");
1187
1188        // The default "200" must be gone - only "201" remains
1189        assert!(op_json["responses"].get("200").is_none());
1190        assert!(
1191            op_json["responses"]["201"]["content"]
1192                .get("application/json")
1193                .is_some()
1194        );
1195        assert_eq!(op_json["responses"]["201"]["description"], "Created");
1196    }
1197
1198    #[test]
1199    fn merge_does_not_override_existing_responses() {
1200        let base = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(200);
1201        let other = OpenApiRouteConfig::default()
1202            .produces_text(200)
1203            .produces_json::<ResponsePayload>(404);
1204
1205        let merged = base.merge(&other);
1206
1207        let mut op = OpenApiOperation::default();
1208        merged.apply_to_operation(&mut op, &mut BTreeMap::new());
1209        let op_json = serde_json::to_value(op).expect("serialize");
1210
1211        // 200 should keep JSON (from base), not be overridden by text (from other)
1212        assert!(
1213            op_json["responses"]["200"]["content"]
1214                .get("application/json")
1215                .is_some()
1216        );
1217        assert!(
1218            op_json["responses"]["200"]["content"]
1219                .get("text/plain; charset=utf-8")
1220                .is_none()
1221        );
1222        // 404 should come from other
1223        assert!(
1224            op_json["responses"]["404"]["content"]
1225                .get("application/json")
1226                .is_some()
1227        );
1228    }
1229
1230    #[test]
1231    fn into_status_code_works_for_u16_and_http_status_code() {
1232        assert_eq!(200u16.into_status_code(), 200);
1233        assert_eq!(201.into_status_code(), 201);
1234        assert_eq!(202u32.into_status_code(), 202);
1235        assert_eq!(http::StatusCode::NOT_FOUND.into_status_code(), 404);
1236        assert_eq!(http::StatusCode::CREATED.into_status_code(), 201);
1237    }
1238
1239    #[cfg(feature = "problem-details")]
1240    mod problem_tests {
1241        use super::*;
1242
1243        #[test]
1244        fn produces_problem_generates_problem_json_schema_for_status() {
1245            let cfg = OpenApiRouteConfig::default().produces_problem(400u16);
1246            let mut op = OpenApiOperation::default();
1247            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1248            let op_json = serde_json::to_value(op).expect("serialize");
1249
1250            let content = &op_json["responses"]["400"]["content"]["application/problem+json"];
1251            assert!(
1252                content.is_object(),
1253                "application/problem+json content should exist"
1254            );
1255            assert_eq!(content["example"]["title"], "Bad Request");
1256            assert_eq!(content["example"]["status"], 400);
1257            assert_eq!(
1258                content["example"]["type"],
1259                "https://tools.ietf.org/html/rfc9110"
1260            );
1261            assert_eq!(op_json["responses"]["400"]["description"], "Bad Request");
1262            // Default "200" must be gone
1263            assert!(op_json["responses"].get("200").is_none());
1264        }
1265
1266        #[test]
1267        fn produces_problem_example_uses_provided_value() {
1268            #[derive(Serialize)]
1269            struct CustomProblem {
1270                r#type: String,
1271                title: String,
1272                status: u16,
1273                detail: String,
1274            }
1275
1276            let cfg = OpenApiRouteConfig::default().produces_problem_example(
1277                422u16,
1278                CustomProblem {
1279                    r#type: "https://tools.ietf.org/html/rfc9110".into(),
1280                    title: "Unprocessable Content".into(),
1281                    status: 422,
1282                    detail: "Validation failed".into(),
1283                },
1284            );
1285
1286            let mut op = OpenApiOperation::default();
1287            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1288            let op_json = serde_json::to_value(op).expect("serialize");
1289
1290            let content = &op_json["responses"]["422"]["content"]["application/problem+json"];
1291            assert!(
1292                content.is_object(),
1293                "application/problem+json content should exist"
1294            );
1295            assert_eq!(content["example"]["detail"], "Validation failed");
1296            assert_eq!(content["example"]["status"], 422);
1297            assert_eq!(
1298                op_json["responses"]["422"]["description"],
1299                "Unprocessable Entity"
1300            );
1301        }
1302
1303        #[test]
1304        fn produces_problem_composes_with_other_status_responses() {
1305            let cfg = OpenApiRouteConfig::default()
1306                .produces_json::<ResponsePayload>(200)
1307                .produces_problem(400)
1308                .produces_problem(500);
1309
1310            let mut op = OpenApiOperation::default();
1311            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1312            let op_json = serde_json::to_value(op).expect("serialize");
1313
1314            assert!(
1315                op_json["responses"]["200"]["content"]
1316                    .get("application/json")
1317                    .is_some()
1318            );
1319            assert!(
1320                op_json["responses"]["400"]["content"]
1321                    .get("application/problem+json")
1322                    .is_some()
1323            );
1324            assert!(
1325                op_json["responses"]["500"]["content"]
1326                    .get("application/problem+json")
1327                    .is_some()
1328            );
1329            assert_eq!(op_json["responses"].as_object().unwrap().len(), 3);
1330        }
1331    }
1332
1333    #[test]
1334    fn produces_multipart_sets_response_content_type() {
1335        let cfg = OpenApiRouteConfig::default().produces_multipart(200u16);
1336        let resp = cfg.responses.get(&200u16).expect("200 response defined");
1337        match resp {
1338            ResponseBody::Content { content_type, .. } => {
1339                assert_eq!(content_type, "multipart/form-data");
1340            }
1341            _ => panic!("expected Content variant"),
1342        }
1343    }
1344}