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 the configuration of an enclosing scope - a route group - into this one.
543    ///
544    /// Whatever the route describes for itself wins, exactly as in [`merge`](Self::merge);
545    /// the difference is the order of the tags, which read outermost first no matter when
546    /// the group's configuration was registered.
547    pub fn merge_outer(mut self, outer: &Self) -> Self {
548        let own_tags = std::mem::take(&mut self.tags);
549        let own_parameters = std::mem::take(&mut self.extra_parameters);
550
551        let mut merged = self.merge(outer);
552
553        merged.tags.extend(own_tags);
554        // Same-name parameters replace one another in the order they reach the
555        // operation, so the route's have to come last to win
556        merged.extra_parameters.extend(own_parameters);
557        merged
558    }
559
560    /// Merges configurations.
561    pub fn merge(mut self, other: &Self) -> Self {
562        if self.tags.is_empty() {
563            self.tags = other.tags.clone();
564        } else {
565            self.tags.extend(other.tags.clone());
566        }
567        if self.summary.is_none() {
568            self.summary = other.summary.clone();
569        }
570        if self.description.is_none() {
571            self.description = other.description.clone();
572        }
573        if self.operation_id.is_none() {
574            self.operation_id = other.operation_id.clone();
575        }
576        if self.request_schema.is_none() {
577            self.request_schema = other.request_schema.clone();
578        }
579        if self.request_example.is_none() {
580            self.request_example = other.request_example.clone();
581        }
582        if self.request_content_type.is_none() {
583            self.request_content_type = other.request_content_type.clone();
584        }
585        for (status, body) in &other.responses {
586            self.responses
587                .entry(*status)
588                .or_insert_with(|| body.clone());
589        }
590        if !other.extra_parameters.is_empty() {
591            self.extra_parameters.extend(other.extra_parameters.clone());
592        }
593        match (&mut self.docs, &other.docs) {
594            (None, Some(d)) => self.docs = Some(d.clone()),
595            (Some(dst), Some(src)) => {
596                for s in src {
597                    if !dst.iter().any(|x| x == s) {
598                        dst.push(s.clone());
599                    }
600                }
601            }
602            _ => {}
603        }
604        self
605    }
606
607    pub(super) fn apply_to_operation(
608        &self,
609        operation: &mut OpenApiOperation,
610        schemas: &mut BTreeMap<String, OpenApiSchema>,
611    ) {
612        if let Some(summary) = &self.summary {
613            operation.summary = Some(summary.clone());
614        }
615        if let Some(description) = &self.description {
616            operation.description = Some(description.clone());
617        }
618        if let Some(operation_id) = &self.operation_id {
619            operation.operation_id = Some(operation_id.clone());
620        }
621
622        if !self.tags.is_empty() {
623            operation.tags = Some(self.tags.clone());
624        }
625
626        if self.request_schema.is_some() || self.request_example.is_some() {
627            let mut schema = self
628                .request_schema
629                .clone()
630                .unwrap_or_else(OpenApiSchema::object);
631            let example = self.request_example.clone();
632            let content_type = self
633                .request_content_type
634                .as_deref()
635                .unwrap_or(APPLICATION_JSON.as_ref());
636
637            schema = intern_schema_if_object_named(schema, schemas);
638            operation.set_request_body(schema, example, content_type);
639        }
640
641        if !self.responses.is_empty() {
642            operation.clear_all_responses();
643            for (status, body) in &self.responses {
644                match body {
645                    ResponseBody::NoContent => {
646                        operation.clear_response_body(*status);
647                    }
648                    ResponseBody::Content {
649                        schema,
650                        example,
651                        content_type,
652                    } => {
653                        let schema = intern_schema_if_object_named((**schema).clone(), schemas);
654                        operation.set_response_body(*status, schema, example.clone(), content_type);
655                    }
656                }
657            }
658        }
659
660        if !self.extra_parameters.is_empty() {
661            let params = operation.parameters.get_or_insert_with(Vec::new);
662
663            for p in &self.extra_parameters {
664                if let Some(existing) = params
665                    .iter_mut()
666                    .find(|x| x.name == p.name && x.location == p.location)
667                {
668                    *existing = p.clone();
669                } else {
670                    params.push(p.clone());
671                }
672            }
673        }
674    }
675}
676
677fn intern_schema_if_object_named(
678    mut schema: OpenApiSchema,
679    schemas: &mut BTreeMap<String, OpenApiSchema>,
680) -> OpenApiSchema {
681    if schema.schema_ref.is_some() {
682        return schema;
683    }
684
685    let base = match schema.title.clone() {
686        Some(t) if !t.is_empty() => t,
687        _ => return schema,
688    };
689
690    if schema.properties.is_none() {
691        return schema;
692    }
693
694    let mut name = base.clone();
695    if schemas.contains_key(&name) {
696        let mut i = 2;
697        while schemas.contains_key(&format!("{base}_{i}")) {
698            i += 1;
699        }
700        name = format!("{base}_{i}");
701    }
702
703    schema.title = None;
704
705    schemas.entry(name.clone()).or_insert(schema);
706
707    OpenApiSchema::reference(&name)
708}
709
710fn schema_and_example_from_deserialize<T: DeserializeOwned>() -> Option<(OpenApiSchema, Value)> {
711    let mut probe = Probe::new();
712    let _ = T::deserialize(&mut probe);
713    probe.finish()
714}
715
716fn type_display_name<T>() -> String {
717    std::any::type_name::<T>()
718        .rsplit("::")
719        .next()
720        .unwrap_or("Model")
721        .to_string()
722}
723
724#[cfg(test)]
725#[allow(unused)]
726mod tests {
727    use super::{ConstraintTarget, IntoStatusCode, OpenApiRouteConfig, ResponseBody};
728    use crate::{
729        op::OpenApiOperation,
730        schema::{FieldConstraint, OpenApiSchema, SchemaConstraint},
731    };
732    use serde::{Deserialize, Serialize};
733    use serde_json::json;
734    use std::collections::BTreeMap;
735
736    #[derive(Deserialize)]
737    struct Payload {
738        name: String,
739        age: u64,
740    }
741
742    #[derive(Serialize, Default)]
743    struct ResponsePayload {
744        message: String,
745    }
746
747    #[derive(Deserialize)]
748    struct OptionalQuery {
749        required_name: String,
750        optional_age: Option<()>,
751    }
752
753    #[derive(Deserialize)]
754    #[allow(dead_code)]
755    struct Page {
756        page: u32,
757    }
758
759    #[test]
760    fn with_constraints_touches_only_the_parameter_its_extractor_added() {
761        // Two extractors describing a query parameter of the same name: each is described
762        // in turn, appending its own parameter, and neither may reach into the other's
763        let cfg = OpenApiRouteConfig::default();
764
765        let described_before = cfg.described_parameters();
766        let cfg = cfg.consumes_query::<Page>().with_constraints(
767            ConstraintTarget::QueryParameter { described_before },
768            &[FieldConstraint::new(
769                "page",
770                SchemaConstraint::Minimum(1.into()),
771            )],
772        );
773
774        let described_before = cfg.described_parameters();
775        let cfg = cfg.consumes_query::<Page>().with_constraints(
776            ConstraintTarget::QueryParameter { described_before },
777            &[FieldConstraint::new(
778                "page",
779                SchemaConstraint::Minimum(100.into()),
780            )],
781        );
782
783        let bounds = cfg
784            .extra_parameters
785            .iter()
786            .map(|parameter| parameter.schema.minimum.clone())
787            .collect::<Vec<_>>();
788
789        assert_eq!(bounds, vec![Some(1.into()), Some(100.into())]);
790    }
791
792    #[test]
793    fn with_constraints_of_an_extractor_that_described_nothing_reach_nobody() {
794        // A struct whose schema cannot be inferred - an enum field is enough - adds no
795        // parameters, so its bounds have no target and must not fall back to someone else's
796        let cfg = OpenApiRouteConfig::default();
797        let described_before = cfg.described_parameters();
798        let cfg = cfg.consumes_query::<Page>().with_constraints(
799            ConstraintTarget::QueryParameter { described_before },
800            &[FieldConstraint::new(
801                "page",
802                SchemaConstraint::Minimum(1.into()),
803            )],
804        );
805
806        let described_before = cfg.described_parameters();
807        let cfg = cfg.with_constraints(
808            ConstraintTarget::QueryParameter { described_before },
809            &[FieldConstraint::new(
810                "page",
811                SchemaConstraint::Minimum(100.into()),
812            )],
813        );
814
815        assert_eq!(cfg.extra_parameters.len(), 1);
816        assert_eq!(cfg.extra_parameters[0].schema.minimum, Some(1.into()));
817    }
818
819    #[test]
820    fn with_constraints_intersects_a_bound_declared_twice() {
821        // Both rules run at runtime, so the published bound is the one that survives both
822        let cfg = OpenApiRouteConfig::default()
823            .consumes_query::<Page>()
824            .with_constraints(
825                ConstraintTarget::QueryParameter {
826                    described_before: 0,
827                },
828                &[
829                    FieldConstraint::new("page", SchemaConstraint::Minimum(10.into())),
830                    FieldConstraint::new("page", SchemaConstraint::Minimum(1.into())),
831                    FieldConstraint::new("page", SchemaConstraint::Maximum(50.into())),
832                    FieldConstraint::new("page", SchemaConstraint::Maximum(500.into())),
833                ],
834            );
835
836        let schema = &cfg.extra_parameters[0].schema;
837
838        assert_eq!(schema.minimum, Some(10.into()));
839        assert_eq!(schema.maximum, Some(50.into()));
840    }
841
842    #[test]
843    fn consumes_query_keeps_parameters_of_a_typed_optional_field() {
844        #[derive(serde::Deserialize)]
845        #[allow(dead_code)]
846        struct OptionalString {
847            per_page: u32,
848            sort: Option<String>,
849        }
850
851        let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalString>();
852        let names = cfg
853            .extra_parameters
854            .iter()
855            .map(|p| p.name.as_str())
856            .collect::<Vec<_>>();
857
858        // A typed `Option` used to make the whole probe fail, dropping every parameter
859        assert_eq!(names, vec!["per_page", "sort"]);
860        assert!(!cfg.extra_parameters[1].required);
861    }
862
863    #[test]
864    fn consumes_query_marks_non_optional_fields_as_required() {
865        let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalQuery>();
866
867        let required_name = cfg
868            .extra_parameters
869            .iter()
870            .find(|p| p.name == "required_name")
871            .expect("required_name param");
872        let optional_age = cfg
873            .extra_parameters
874            .iter()
875            .find(|p| p.name == "optional_age")
876            .expect("optional_age param");
877
878        assert!(required_name.required);
879        assert!(!optional_age.required);
880    }
881
882    #[test]
883    fn infer_request_schema_from_deserialize_type() {
884        let cfg = OpenApiRouteConfig::default()
885            .with_request_type_from_deserialize::<Payload>("application/json");
886
887        let schema = cfg
888            .request_schema
889            .expect("request schema should be inferred");
890        let props = schema.properties.expect("object properties should exist");
891
892        assert!(props.contains_key("name"));
893        assert!(props.contains_key("age"));
894        assert_eq!(
895            cfg.request_content_type.as_deref(),
896            Some("application/json")
897        );
898    }
899
900    #[test]
901    fn infer_query_parameters_from_deserialize_type() {
902        let cfg = OpenApiRouteConfig::default().with_query_parameters_from_deserialize::<Payload>();
903
904        assert_eq!(cfg.extra_parameters.len(), 2);
905        assert!(cfg.extra_parameters.iter().any(|p| p.name == "name"));
906        assert!(cfg.extra_parameters.iter().any(|p| p.name == "age"));
907        assert!(cfg.extra_parameters.iter().all(|p| p.location == "query"));
908    }
909
910    #[test]
911    fn merge_outer_keeps_the_route_ahead_of_the_scope_it_is_in() {
912        let group = OpenApiRouteConfig::default()
913            .with_tag("group")
914            .with_summary("group summary")
915            .with_query_parameter("id".into(), OpenApiSchema::string(), false);
916        let route = OpenApiRouteConfig::default()
917            .with_tag("route")
918            .with_query_parameter("id".into(), OpenApiSchema::integer(), true);
919
920        let merged = route.merge_outer(&group);
921
922        // The scope reads outermost first, and the route's own values win: a parameter
923        // replaces a same-name one applied before it, so the route's has to come last
924        assert_eq!(merged.tags, vec!["group".to_string(), "route".to_string()]);
925        assert_eq!(merged.summary.as_deref(), Some("group summary"));
926        assert_eq!(merged.extra_parameters.len(), 2);
927        assert!(merged.extra_parameters[1].required);
928    }
929
930    #[test]
931    fn merge_combines_docs_and_preserves_existing_values() {
932        let base = OpenApiRouteConfig::default()
933            .with_tag("base")
934            .with_summary("base summary")
935            .with_doc("v1");
936        let other = OpenApiRouteConfig::default()
937            .with_tag("extra")
938            .with_summary("other summary")
939            .with_doc("v1")
940            .with_doc("admin");
941
942        let merged = base.merge(&other);
943
944        assert_eq!(merged.summary.as_deref(), Some("base summary"));
945        assert_eq!(merged.tags.as_slice(), ["base", "extra"]);
946        assert_eq!(merged.docs.expect("docs"), vec!["v1", "admin"]);
947    }
948
949    #[test]
950    fn apply_to_operation_adds_content_and_interns_named_schema() {
951        let cfg = OpenApiRouteConfig::default()
952            .with_request_schema(
953                OpenApiSchema::object()
954                    .with_title("CreateUser")
955                    .with_property("name", OpenApiSchema::string()),
956            )
957            .with_response_schema(
958                200u16,
959                OpenApiSchema::object()
960                    .with_title("User")
961                    .with_property("id", OpenApiSchema::integer()),
962            )
963            .produces_json_example(
964                200u16,
965                ResponsePayload {
966                    message: "ok".to_string(),
967                },
968            )
969            .with_summary("create user")
970            .with_operation_id("createUser");
971
972        let mut op = OpenApiOperation::default();
973        let mut schemas = BTreeMap::new();
974
975        cfg.apply_to_operation(&mut op, &mut schemas);
976
977        let op_json = serde_json::to_value(op).expect("serialize operation");
978        assert_eq!(op_json["summary"], "create user");
979        assert_eq!(op_json["operationId"], "createUser");
980        assert!(
981            op_json["requestBody"]["content"]
982                .get("application/json")
983                .is_some()
984        );
985        assert_eq!(
986            op_json["responses"]["200"]["content"]["application/json"]["example"],
987            json!({"message":"ok"})
988        );
989
990        assert!(schemas.contains_key("CreateUser"));
991    }
992
993    #[test]
994    fn produces_no_schema_clears_content() {
995        let cfg = OpenApiRouteConfig::default()
996            .produces_json::<ResponsePayload>(200)
997            .produces_no_schema(200);
998
999        let mut op = OpenApiOperation::default();
1000        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1001
1002        let op_json = serde_json::to_value(op).expect("serialize operation");
1003        assert!(op_json["responses"]["200"].get("content").is_none());
1004    }
1005
1006    #[test]
1007    fn with_tags_extends_existing_tags() {
1008        let cfg = OpenApiRouteConfig::default()
1009            .with_tag("base")
1010            .with_tags(["one", "two"]);
1011
1012        assert_eq!(cfg.tags, vec!["base", "one", "two"]);
1013    }
1014
1015    #[test]
1016    fn with_docs_initializes_and_extends_docs() {
1017        let cfg = OpenApiRouteConfig::default()
1018            .with_docs(["v1", "admin"])
1019            .with_doc("internal");
1020
1021        assert_eq!(cfg.docs.expect("docs"), vec!["v1", "admin", "internal"]);
1022    }
1023
1024    #[test]
1025    fn with_description_sets_operation_description() {
1026        let cfg = OpenApiRouteConfig::default().with_description("desc");
1027        assert_eq!(cfg.description.as_deref(), Some("desc"));
1028    }
1029
1030    #[test]
1031    fn consumes_methods_set_request_content_type() {
1032        let query_cfg = OpenApiRouteConfig::default().consumes_query::<Payload>();
1033        assert_eq!(query_cfg.extra_parameters.len(), 2);
1034
1035        let json_cfg = OpenApiRouteConfig::default().consumes_json::<Payload>();
1036        assert_eq!(
1037            json_cfg.request_content_type.as_deref(),
1038            Some("application/json")
1039        );
1040        assert!(json_cfg.request_schema.is_some());
1041
1042        let form_cfg = OpenApiRouteConfig::default().consumes_form::<Payload>();
1043        assert_eq!(
1044            form_cfg.request_content_type.as_deref(),
1045            Some("application/x-www-form-urlencoded")
1046        );
1047        assert!(form_cfg.request_schema.is_some());
1048
1049        let multipart_cfg = OpenApiRouteConfig::default().consumes_multipart();
1050        assert_eq!(
1051            multipart_cfg.request_content_type.as_deref(),
1052            Some("multipart/form-data")
1053        );
1054
1055        let stream_cfg = OpenApiRouteConfig::default().consumes_stream();
1056        assert_eq!(
1057            stream_cfg.request_content_type.as_deref(),
1058            Some("application/octet-stream")
1059        );
1060        assert_eq!(
1061            stream_cfg.request_schema.expect("schema").format.as_deref(),
1062            Some("binary")
1063        );
1064    }
1065
1066    #[test]
1067    fn produces_helpers_set_expected_response_schema_and_content_type() {
1068        fn apply(cfg: OpenApiRouteConfig) -> serde_json::Value {
1069            let mut op = OpenApiOperation::default();
1070            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1071            serde_json::to_value(op).expect("serialize")
1072        }
1073
1074        let text_json = apply(OpenApiRouteConfig::default().produces_text(200));
1075        assert!(
1076            text_json["responses"]["200"]["content"]
1077                .get("text/plain; charset=utf-8")
1078                .is_some()
1079        );
1080        assert_eq!(
1081            text_json["responses"]["200"]["content"]["text/plain; charset=utf-8"]["schema"]["type"],
1082            "string"
1083        );
1084
1085        let empty_json_json = apply(OpenApiRouteConfig::default().produces_empty_json(200));
1086        assert!(
1087            empty_json_json["responses"]["200"]["content"]
1088                .get("application/json")
1089                .is_some()
1090        );
1091        assert_eq!(
1092            empty_json_json["responses"]["200"]["content"]["application/json"]["schema"]["type"],
1093            "object"
1094        );
1095
1096        let form_json = apply(OpenApiRouteConfig::default().produces_form::<ResponsePayload>(200));
1097        assert!(
1098            form_json["responses"]["200"]["content"]
1099                .get("application/x-www-form-urlencoded")
1100                .is_some()
1101        );
1102        assert_eq!(
1103            form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1104                ["type"],
1105            "string"
1106        );
1107
1108        let empty_form_json = apply(OpenApiRouteConfig::default().produces_empty_form(200));
1109        assert!(
1110            empty_form_json["responses"]["200"]["content"]
1111                .get("application/x-www-form-urlencoded")
1112                .is_some()
1113        );
1114        assert_eq!(
1115            empty_form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1116                ["type"],
1117            "object"
1118        );
1119
1120        let stream_json = apply(OpenApiRouteConfig::default().produces_stream(200));
1121        assert!(
1122            stream_json["responses"]["200"]["content"]
1123                .get("application/octet-stream")
1124                .is_some()
1125        );
1126        assert_eq!(
1127            stream_json["responses"]["200"]["content"]["application/octet-stream"]["schema"]["format"],
1128            "binary"
1129        );
1130
1131        let sse_json = apply(OpenApiRouteConfig::default().produces_sse(200));
1132        assert!(
1133            sse_json["responses"]["200"]["content"]
1134                .get("text/event-stream")
1135                .is_some()
1136        );
1137        assert_eq!(
1138            sse_json["responses"]["200"]["content"]["text/event-stream"]["schema"]["title"],
1139            "SSE stream"
1140        );
1141    }
1142
1143    #[test]
1144    fn produces_form_example_encodes_object_into_string_example() {
1145        #[derive(Serialize)]
1146        struct FormOut {
1147            first: String,
1148            second: i32,
1149        }
1150
1151        let cfg = OpenApiRouteConfig::default().produces_form_example(
1152            200u16,
1153            FormOut {
1154                first: "hello".to_string(),
1155                second: 7,
1156            },
1157        );
1158
1159        let mut op = OpenApiOperation::default();
1160        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1161        let op_json = serde_json::to_value(op).expect("serialize");
1162
1163        assert!(
1164            op_json["responses"]["200"]["content"]
1165                .get("application/x-www-form-urlencoded")
1166                .is_some()
1167        );
1168        assert_eq!(
1169            op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]["type"],
1170            "string"
1171        );
1172        assert_eq!(
1173            op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["example"],
1174            "first=hello&second=7"
1175        );
1176    }
1177
1178    #[test]
1179    fn produces_multiple_status_codes() {
1180        #[derive(Serialize, Default)]
1181        struct UserDto {
1182            id: u64,
1183        }
1184        #[derive(Serialize, Default)]
1185        struct ErrorDto {
1186            message: String,
1187        }
1188
1189        let cfg = OpenApiRouteConfig::default()
1190            .produces_json::<UserDto>(200)
1191            .produces_json::<ErrorDto>(404);
1192
1193        let mut op = OpenApiOperation::default();
1194        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1195        let op_json = serde_json::to_value(op).expect("serialize");
1196
1197        // Both statuses should appear
1198        assert!(
1199            op_json["responses"]["200"]["content"]
1200                .get("application/json")
1201                .is_some()
1202        );
1203        assert!(
1204            op_json["responses"]["404"]["content"]
1205                .get("application/json")
1206                .is_some()
1207        );
1208        // Default 200 is replaced by explicit responses
1209        assert_eq!(
1210            op_json["responses"]
1211                .as_object()
1212                .expect("responses object")
1213                .len(),
1214            2
1215        );
1216    }
1217
1218    #[test]
1219    fn explicit_responses_replace_default_200() {
1220        let cfg = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(201);
1221
1222        let mut op = OpenApiOperation::default();
1223        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1224        let op_json = serde_json::to_value(op).expect("serialize");
1225
1226        // The default "200" must be gone - only "201" remains
1227        assert!(op_json["responses"].get("200").is_none());
1228        assert!(
1229            op_json["responses"]["201"]["content"]
1230                .get("application/json")
1231                .is_some()
1232        );
1233        assert_eq!(op_json["responses"]["201"]["description"], "Created");
1234    }
1235
1236    #[test]
1237    fn merge_does_not_override_existing_responses() {
1238        let base = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(200);
1239        let other = OpenApiRouteConfig::default()
1240            .produces_text(200)
1241            .produces_json::<ResponsePayload>(404);
1242
1243        let merged = base.merge(&other);
1244
1245        let mut op = OpenApiOperation::default();
1246        merged.apply_to_operation(&mut op, &mut BTreeMap::new());
1247        let op_json = serde_json::to_value(op).expect("serialize");
1248
1249        // 200 should keep JSON (from base), not be overridden by text (from other)
1250        assert!(
1251            op_json["responses"]["200"]["content"]
1252                .get("application/json")
1253                .is_some()
1254        );
1255        assert!(
1256            op_json["responses"]["200"]["content"]
1257                .get("text/plain; charset=utf-8")
1258                .is_none()
1259        );
1260        // 404 should come from other
1261        assert!(
1262            op_json["responses"]["404"]["content"]
1263                .get("application/json")
1264                .is_some()
1265        );
1266    }
1267
1268    #[test]
1269    fn into_status_code_works_for_u16_and_http_status_code() {
1270        assert_eq!(200u16.into_status_code(), 200);
1271        assert_eq!(201.into_status_code(), 201);
1272        assert_eq!(202u32.into_status_code(), 202);
1273        assert_eq!(http::StatusCode::NOT_FOUND.into_status_code(), 404);
1274        assert_eq!(http::StatusCode::CREATED.into_status_code(), 201);
1275    }
1276
1277    #[cfg(feature = "problem-details")]
1278    mod problem_tests {
1279        use super::*;
1280
1281        #[test]
1282        fn produces_problem_generates_problem_json_schema_for_status() {
1283            let cfg = OpenApiRouteConfig::default().produces_problem(400u16);
1284            let mut op = OpenApiOperation::default();
1285            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1286            let op_json = serde_json::to_value(op).expect("serialize");
1287
1288            let content = &op_json["responses"]["400"]["content"]["application/problem+json"];
1289            assert!(
1290                content.is_object(),
1291                "application/problem+json content should exist"
1292            );
1293            assert_eq!(content["example"]["title"], "Bad Request");
1294            assert_eq!(content["example"]["status"], 400);
1295            assert_eq!(
1296                content["example"]["type"],
1297                "https://tools.ietf.org/html/rfc9110"
1298            );
1299            assert_eq!(op_json["responses"]["400"]["description"], "Bad Request");
1300            // Default "200" must be gone
1301            assert!(op_json["responses"].get("200").is_none());
1302        }
1303
1304        #[test]
1305        fn produces_problem_example_uses_provided_value() {
1306            #[derive(Serialize)]
1307            struct CustomProblem {
1308                r#type: String,
1309                title: String,
1310                status: u16,
1311                detail: String,
1312            }
1313
1314            let cfg = OpenApiRouteConfig::default().produces_problem_example(
1315                422u16,
1316                CustomProblem {
1317                    r#type: "https://tools.ietf.org/html/rfc9110".into(),
1318                    title: "Unprocessable Content".into(),
1319                    status: 422,
1320                    detail: "Validation failed".into(),
1321                },
1322            );
1323
1324            let mut op = OpenApiOperation::default();
1325            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1326            let op_json = serde_json::to_value(op).expect("serialize");
1327
1328            let content = &op_json["responses"]["422"]["content"]["application/problem+json"];
1329            assert!(
1330                content.is_object(),
1331                "application/problem+json content should exist"
1332            );
1333            assert_eq!(content["example"]["detail"], "Validation failed");
1334            assert_eq!(content["example"]["status"], 422);
1335            assert_eq!(
1336                op_json["responses"]["422"]["description"],
1337                "Unprocessable Entity"
1338            );
1339        }
1340
1341        #[test]
1342        fn produces_problem_composes_with_other_status_responses() {
1343            let cfg = OpenApiRouteConfig::default()
1344                .produces_json::<ResponsePayload>(200)
1345                .produces_problem(400)
1346                .produces_problem(500);
1347
1348            let mut op = OpenApiOperation::default();
1349            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1350            let op_json = serde_json::to_value(op).expect("serialize");
1351
1352            assert!(
1353                op_json["responses"]["200"]["content"]
1354                    .get("application/json")
1355                    .is_some()
1356            );
1357            assert!(
1358                op_json["responses"]["400"]["content"]
1359                    .get("application/problem+json")
1360                    .is_some()
1361            );
1362            assert!(
1363                op_json["responses"]["500"]["content"]
1364                    .get("application/problem+json")
1365                    .is_some()
1366            );
1367            assert_eq!(op_json["responses"].as_object().unwrap().len(), 3);
1368        }
1369    }
1370
1371    #[test]
1372    fn produces_multipart_sets_response_content_type() {
1373        let cfg = OpenApiRouteConfig::default().produces_multipart(200u16);
1374        let resp = cfg.responses.get(&200u16).expect("200 response defined");
1375        match resp {
1376            ResponseBody::Content { content_type, .. } => {
1377                assert_eq!(content_type, "multipart/form-data");
1378            }
1379            _ => panic!("expected Content variant"),
1380        }
1381    }
1382}