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, Probed, ReadAsMap},
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/// A handler input an operation describes without its fields.
83///
84/// Fields are inferred by driving the type's `Deserialize` impl and recording what it asks
85/// for. A struct with a `#[serde(flatten)]` field asks to be read as a map instead - so that
86/// the keys it does not name can be collected for the flattened member - and a map does not
87/// say which keys it takes, so none of the struct's fields can be inferred, the ones declared
88/// beside the flattened member included. Such a request body is described as an object
89/// without properties - or as any value, when the struct sits somewhere inside it - and such
90/// query parameters are not described at all.
91///
92/// Describing the input by hand, with [`OpenApiRouteConfig::with_request_schema`] or
93/// [`OpenApiRouteConfig::with_query_schema`], takes it off
94/// [`OpenApiRouteConfig::undescribed_inputs`].
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct UndescribedInput {
97    kind: InputKind,
98    type_name: &'static str,
99    read_as_map: Option<String>,
100}
101
102impl UndescribedInput {
103    fn new<T>(kind: InputKind, found: ReadAsMap) -> Self {
104        Self {
105            kind,
106            type_name: std::any::type_name::<T>(),
107            read_as_map: (!found.at_root).then_some(found.expecting),
108        }
109    }
110
111    /// Returns what the input is read into
112    pub fn kind(&self) -> InputKind {
113        self.kind
114    }
115
116    /// Returns the type the extractor reads, as [`std::any::type_name`] spells it
117    pub fn type_name(&self) -> &'static str {
118        self.type_name
119    }
120
121    /// Returns what serde expects of the struct it reads as a map, when that struct sits
122    /// inside the type rather than being the type itself: `struct Name`, for a derived one
123    pub fn read_as_map(&self) -> Option<&str> {
124        self.read_as_map.as_deref()
125    }
126}
127
128/// What a handler input is read into
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum InputKind {
132    /// The request body
133    RequestBody,
134    /// The query parameters
135    QueryParameters,
136}
137
138/// Per-route OpenAPI metadata.
139#[derive(Clone, Debug, Default)]
140pub struct OpenApiRouteConfig {
141    tags: Vec<String>,
142    docs: Option<Vec<String>>,
143    summary: Option<String>,
144    description: Option<String>,
145    operation_id: Option<String>,
146    request_schema: Option<OpenApiSchema>,
147    request_example: Option<Value>,
148    request_content_type: Option<String>,
149    responses: BTreeMap<u16, ResponseBody>,
150    extra_parameters: Vec<OpenApiParameter>,
151    undescribed: Vec<UndescribedInput>,
152}
153
154impl OpenApiRouteConfig {
155    /// Returns the handler inputs this operation describes without their fields, because
156    /// they cannot be inferred - see [`UndescribedInput`].
157    pub fn undescribed_inputs(&self) -> &[UndescribedInput] {
158        &self.undescribed
159    }
160
161    /// Returns how many parameters this operation already describes.
162    ///
163    /// Taken before an extractor is described and handed back through [`ConstraintTarget`],
164    /// so its constraints reach its own parameters and no others.
165    pub fn described_parameters(&self) -> usize {
166        self.extra_parameters.len()
167    }
168
169    /// Returns a list of docs that this route is assigned to
170    pub(crate) fn docs(&self) -> Option<&[String]> {
171        self.docs.as_deref()
172    }
173
174    /// Adds a tag to the operation.
175    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
176        self.tags.push(tag.into());
177        self
178    }
179
180    /// Adds multiple tags to the operation.
181    pub fn with_tags<I, T>(mut self, tags: I) -> Self
182    where
183        I: IntoIterator<Item = T>,
184        T: Into<String>,
185    {
186        self.tags.extend(tags.into_iter().map(Into::into));
187        self
188    }
189
190    /// Binds the operation with a document
191    pub fn with_doc(mut self, doc: impl Into<String>) -> Self {
192        self.docs.get_or_insert_with(Vec::new).push(doc.into());
193        self
194    }
195
196    /// Binds the operation with documents
197    pub fn with_docs<I, S>(mut self, docs: I) -> Self
198    where
199        I: IntoIterator<Item = S>,
200        S: Into<String>,
201    {
202        self.docs
203            .get_or_insert_with(Vec::new)
204            .extend(docs.into_iter().map(Into::into));
205        self
206    }
207
208    /// Sets the operation summary.
209    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
210        self.summary = Some(summary.into());
211        self
212    }
213
214    /// Sets the operation description.
215    pub fn with_description(mut self, description: impl Into<String>) -> Self {
216        self.description = Some(description.into());
217        self
218    }
219
220    /// Sets the operation id.
221    pub fn with_operation_id(mut self, operation_id: impl Into<String>) -> Self {
222        self.operation_id = Some(operation_id.into());
223        self
224    }
225
226    /// Sets the request body schema for this operation.
227    ///
228    /// This is how a body whose fields cannot be inferred is described - see
229    /// [`UndescribedInput`].
230    pub fn with_request_schema(mut self, schema: OpenApiSchema) -> Self {
231        self.request_schema = Some(schema);
232        self.undescribed
233            .retain(|input| input.kind != InputKind::RequestBody);
234        self
235    }
236
237    /// Describes the query parameters by hand: one for each property of `schema`, required
238    /// when its `required` list names it - the way a `Query<T>` extractor describes the
239    /// fields of `T`.
240    ///
241    /// This is how a query whose fields cannot be inferred is described - see
242    /// [`UndescribedInput`]. A parameter this operation already describes under the same
243    /// name is replaced.
244    pub fn with_query_schema(mut self, schema: OpenApiSchema) -> Self {
245        self.undescribed
246            .retain(|input| input.kind != InputKind::QueryParameters);
247        self.with_query_parameters(schema)
248    }
249
250    /// Sets the response schema for this operation.
251    pub fn with_response_schema(
252        mut self,
253        status: impl IntoStatusCode,
254        schema: OpenApiSchema,
255    ) -> Self {
256        let status = status.into_status_code();
257        self.responses.insert(
258            status,
259            ResponseBody::Content {
260                schema: Box::new(schema),
261                example: None,
262                content_type: APPLICATION_JSON.to_string(),
263            },
264        );
265        self
266    }
267
268    /// Generates path parameters schema.
269    pub fn consumes_named_path<T: DeserializeOwned>(self) -> Self {
270        self.with_named_path_parameters_from_deserialize::<T>()
271    }
272
273    /// Generates query parameters schema.
274    pub fn consumes_query<T: DeserializeOwned>(self) -> Self {
275        self.with_query_parameters_from_deserialize::<T>()
276    }
277
278    /// Generates JSON request schema and example.
279    pub fn consumes_json<T: DeserializeOwned>(self) -> Self {
280        self.with_request_type_from_deserialize::<T>(APPLICATION_JSON.as_ref())
281    }
282
283    /// Generates form request schema and example.
284    pub fn consumes_form<T: DeserializeOwned>(self) -> Self {
285        self.with_request_type_from_deserialize::<T>(APPLICATION_WWW_FORM_URLENCODED.as_ref())
286    }
287
288    /// Generates multipart request schema.
289    pub fn consumes_multipart(self) -> Self {
290        self.with_multipart_request()
291    }
292
293    /// Generates stream request schema.
294    pub fn consumes_stream(self) -> Self {
295        self.with_stream_request()
296    }
297
298    /// Generates text/plain response schema.
299    pub fn produces_text(mut self, status: impl IntoStatusCode) -> Self {
300        let status = status.into_status_code();
301        self.responses.insert(
302            status,
303            ResponseBody::Content {
304                schema: Box::new(OpenApiSchema::string()),
305                example: None,
306                content_type: TEXT_PLAIN_UTF_8.to_string(),
307            },
308        );
309        self
310    }
311
312    /// Generates a response without a schema.
313    pub fn produces_no_schema(mut self, status: impl IntoStatusCode) -> Self {
314        let status = status.into_status_code();
315        self.responses.insert(status, ResponseBody::NoContent);
316        self
317    }
318
319    /// Generates JSON response schema and example from `T::default()`.
320    pub fn produces_json<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
321        self.produces_json_example(status, T::default())
322    }
323
324    /// Generates empty JSON response schema.
325    pub fn produces_empty_json(mut self, status: impl IntoStatusCode) -> Self {
326        let status = status.into_status_code();
327        self.responses.insert(
328            status,
329            ResponseBody::Content {
330                schema: Box::new(OpenApiSchema::object()),
331                example: None,
332                content_type: APPLICATION_JSON.to_string(),
333            },
334        );
335        self
336    }
337
338    /// Generates form response schema and example from `T::default()`.
339    pub fn produces_form<T: Serialize + Default>(self, status: impl IntoStatusCode) -> Self {
340        self.produces_form_example(status, T::default())
341    }
342
343    /// Generates empty form response schema.
344    pub fn produces_empty_form(mut self, status: impl IntoStatusCode) -> Self {
345        let status = status.into_status_code();
346        self.responses.insert(
347            status,
348            ResponseBody::Content {
349                schema: Box::new(OpenApiSchema::object()),
350                example: None,
351                content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
352            },
353        );
354        self
355    }
356
357    /// Generates default `multipart/form-data` response schema.
358    pub fn produces_multipart(mut self, status: impl IntoStatusCode) -> Self {
359        let status = status.into_status_code();
360        self.responses.insert(
361            status,
362            ResponseBody::Content {
363                schema: Box::new(OpenApiSchema::multipart()),
364                example: None,
365                content_type: MULTIPART_FORM_DATA.to_string(),
366            },
367        );
368        self
369    }
370
371    /// Generates stream response schema.
372    pub fn produces_stream(mut self, status: impl IntoStatusCode) -> Self {
373        let status = status.into_status_code();
374        self.responses.insert(
375            status,
376            ResponseBody::Content {
377                schema: Box::new(OpenApiSchema::binary()),
378                example: None,
379                content_type: APPLICATION_OCTET_STREAM.to_string(),
380            },
381        );
382        self
383    }
384
385    /// Generates SSE stream response schema.
386    pub fn produces_sse(mut self, status: impl IntoStatusCode) -> Self {
387        let status = status.into_status_code();
388        self.responses.insert(
389            status,
390            ResponseBody::Content {
391                schema: Box::new(OpenApiSchema::string().with_title("SSE stream")),
392                example: None,
393                content_type: TEXT_EVENT_STREAM.to_string(),
394            },
395        );
396        self
397    }
398
399    /// Generates JSON response schema and example.
400    pub fn produces_json_example<T: Serialize>(
401        mut self,
402        status: impl IntoStatusCode,
403        example: T,
404    ) -> Self {
405        let status = status.into_status_code();
406        let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
407        self.responses.insert(
408            status,
409            ResponseBody::Content {
410                schema: Box::new(OpenApiSchema::from_example(&example)),
411                example: Some(example),
412                content_type: APPLICATION_JSON.to_string(),
413            },
414        );
415        self
416    }
417
418    /// Generates form response schema and example.
419    pub fn produces_form_example<T: Serialize>(
420        mut self,
421        status: impl IntoStatusCode,
422        example: T,
423    ) -> Self {
424        let status = status.into_status_code();
425        let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
426
427        let encoded = match &example {
428            Value::Object(map) => serde_urlencoded::to_string(map).unwrap_or_default(),
429            _ => String::new(),
430        };
431
432        self.responses.insert(
433            status,
434            ResponseBody::Content {
435                schema: Box::new(OpenApiSchema::string()),
436                example: Some(Value::String(encoded)),
437                content_type: APPLICATION_WWW_FORM_URLENCODED.to_string(),
438            },
439        );
440        self
441    }
442
443    /// Generates an `application/problem+json` response schema for the given status code (RFC 7807).
444    ///
445    /// The example is derived from the status code using its canonical reason phrase.
446    #[cfg(feature = "problem-details")]
447    pub fn produces_problem(mut self, status: impl IntoStatusCode) -> Self {
448        let status = status.into_status_code();
449        let title = http::StatusCode::from_u16(status)
450            .ok()
451            .and_then(|s| s.canonical_reason())
452            .unwrap_or("Error");
453        let example = json!({
454            "type": "https://tools.ietf.org/html/rfc9110",
455            "title": title,
456            "status": status
457        });
458        self.responses.insert(
459            status,
460            ResponseBody::Content {
461                schema: Box::new(OpenApiSchema::from_example(&example)),
462                example: Some(example),
463                content_type: "application/problem+json".to_string(),
464            },
465        );
466        self
467    }
468
469    /// Generates an `application/problem+json` response schema and example (RFC 7807).
470    ///
471    /// The schema is inferred from the provided example value.
472    #[cfg(feature = "problem-details")]
473    pub fn produces_problem_example<T: Serialize>(
474        mut self,
475        status: impl IntoStatusCode,
476        example: T,
477    ) -> Self {
478        let status = status.into_status_code();
479        let example = serde_json::to_value(example).unwrap_or_else(|_| json!({}));
480        self.responses.insert(
481            status,
482            ResponseBody::Content {
483                schema: Box::new(OpenApiSchema::from_example(&example)),
484                example: Some(example),
485                content_type: "application/problem+json".to_string(),
486            },
487        );
488        self
489    }
490
491    /// Generates default Multipart request schema
492    fn with_multipart_request(mut self) -> Self {
493        self.request_schema
494            .get_or_insert_with(OpenApiSchema::multipart);
495        self.request_content_type = Some(MULTIPART_FORM_DATA.to_string());
496        self
497    }
498
499    /// Generates default stream request schema
500    fn with_stream_request(mut self) -> Self {
501        self.request_schema
502            .get_or_insert_with(OpenApiSchema::binary);
503        self.request_content_type = Some(APPLICATION_OCTET_STREAM.to_string());
504        self
505    }
506
507    fn with_query_parameter(mut self, name: String, schema: OpenApiSchema, required: bool) -> Self {
508        self.extra_parameters.push(OpenApiParameter {
509            name,
510            location: "query".to_string(),
511            required,
512            schema,
513        });
514        self
515    }
516
517    fn with_path_parameter(mut self, name: String, schema: OpenApiSchema) -> Self {
518        self.extra_parameters.push(OpenApiParameter {
519            name,
520            location: "path".to_string(),
521            required: true,
522            schema,
523        });
524        self
525    }
526
527    /// Generates request schema and example from `T`.
528    fn with_request_type_from_deserialize<T: DeserializeOwned>(
529        mut self,
530        content_type: &str,
531    ) -> Self {
532        match probe::<T>() {
533            Probed::Shape(schema, example) => {
534                self.request_schema = Some(schema.with_title(type_display_name::<T>()));
535                self.request_example = Some(example);
536            }
537            Probed::ReadAsMap(found) => {
538                // What is known is that a body is sent: an object, when the struct read as a
539                // map is the body itself, and anything at all when it sits inside it - a
540                // `Vec` of them is an array. No example: one without the fields would be
541                // an example of a request the server refuses
542                let schema = if found.at_root {
543                    OpenApiSchema::object()
544                } else {
545                    OpenApiSchema::empty()
546                };
547                self.request_schema = Some(schema.with_title(type_display_name::<T>()));
548                self.undescribed
549                    .push(UndescribedInput::new::<T>(InputKind::RequestBody, found));
550            }
551            Probed::Nothing => {}
552        }
553        self.request_content_type = Some(content_type.to_string());
554        self
555    }
556
557    /// Publishes the constraints of the extractor that declared them, on the one thing in
558    /// this operation that extractor describes.
559    ///
560    /// The target matters: handler arguments are described one after another, so a body and
561    /// a query struct sharing a field name would otherwise hand each other their bounds.
562    /// A field the target does not describe is ignored.
563    pub fn with_constraints(
564        mut self,
565        target: ConstraintTarget,
566        constraints: &[FieldConstraint],
567    ) -> Self {
568        match target {
569            ConstraintTarget::RequestBody => {
570                if let Some(schema) = self.request_schema.as_mut() {
571                    schema.apply_field_constraints(constraints);
572                }
573            }
574            ConstraintTarget::QueryParameter { described_before } => {
575                self.apply_to_parameters("query", described_before, constraints);
576            }
577            ConstraintTarget::PathParameter { described_before } => {
578                self.apply_to_parameters("path", described_before, constraints);
579            }
580        }
581        self
582    }
583
584    /// Applies the constraints to the parameters one extractor added, by name.
585    ///
586    /// Parameters are appended as each handler argument is described, so the ones belonging
587    /// to the extractor that declared these constraints are exactly those past
588    /// `described_before`. Searching further back would hand an earlier extractor a bound
589    /// that is not its own - and an extractor whose schema could not be inferred adds no
590    /// parameters at all, so there would be nothing else for the search to find.
591    fn apply_to_parameters(
592        &mut self,
593        location: &str,
594        described_before: usize,
595        constraints: &[FieldConstraint],
596    ) {
597        let Some(described) = self.extra_parameters.get_mut(described_before..) else {
598            return;
599        };
600
601        for parameter in described
602            .iter_mut()
603            .filter(|parameter| parameter.location == location)
604        {
605            // A field carries more than one constraint - a minimum and a maximum are two
606            for constraint in constraints
607                .iter()
608                .filter(|constraint| constraint.field == parameter.name)
609            {
610                parameter.schema.apply_constraint(&constraint.constraint);
611            }
612        }
613    }
614
615    fn with_query_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
616        match probe::<T>() {
617            Probed::Shape(schema, _) => self.with_query_parameters(schema),
618            Probed::ReadAsMap(found) => {
619                self.undescribed.push(UndescribedInput::new::<T>(
620                    InputKind::QueryParameters,
621                    found,
622                ));
623                self
624            }
625            Probed::Nothing => self,
626        }
627    }
628
629    /// Adds a query parameter for each property of `schema`
630    fn with_query_parameters(mut self, schema: OpenApiSchema) -> Self {
631        if let Some(properties) = schema.properties {
632            let required = schema.required.unwrap_or_default();
633
634            for (name, property_schema) in properties {
635                let is_required = required.iter().any(|f| f == &name);
636                self = self.with_query_parameter(name, property_schema, is_required);
637            }
638        }
639        self
640    }
641
642    /// A struct read as a map is not reported here: the route template names every path
643    /// parameter whatever the extractor describes, and types them when it spells a type -
644    /// `{id:integer}` - which is all a `Path<T>` gets as well
645    fn with_named_path_parameters_from_deserialize<T: DeserializeOwned>(mut self) -> Self {
646        if let Probed::Shape(schema, _) = probe::<T>()
647            && let Some(properties) = schema.properties
648        {
649            for (name, property_schema) in properties {
650                self = self.with_path_parameter(name, property_schema);
651            }
652        }
653        self
654    }
655
656    /// Merges the configuration of an enclosing scope - a route group - into this one.
657    ///
658    /// Whatever the route describes for itself wins, exactly as in [`merge`](Self::merge);
659    /// the difference is the order of the tags, which read outermost first no matter when
660    /// the group's configuration was registered.
661    pub fn merge_outer(mut self, outer: &Self) -> Self {
662        let own_tags = std::mem::take(&mut self.tags);
663        let own_parameters = std::mem::take(&mut self.extra_parameters);
664
665        let mut merged = self.merge(outer);
666
667        merged.tags.extend(own_tags);
668        // Same-name parameters replace one another in the order they reach the
669        // operation, so the route's have to come last to win
670        merged.extra_parameters.extend(own_parameters);
671        merged
672    }
673
674    /// Merges configurations.
675    pub fn merge(mut self, other: &Self) -> Self {
676        if self.tags.is_empty() {
677            self.tags = other.tags.clone();
678        } else {
679            self.tags.extend(other.tags.clone());
680        }
681        if self.summary.is_none() {
682            self.summary = other.summary.clone();
683        }
684        if self.description.is_none() {
685            self.description = other.description.clone();
686        }
687        if self.operation_id.is_none() {
688            self.operation_id = other.operation_id.clone();
689        }
690
691        // An input taken on from `other` comes with what `other` could not describe of it:
692        // its query parameters are added to these, and its body is taken where there is none -
693        // example included, since an example is one of the body it was made for. A body kept
694        // here keeps its own example, or its lack of one
695        let inherits_body = self.request_schema.is_none();
696        self.undescribed.extend(
697            other
698                .undescribed
699                .iter()
700                .filter(|input| match input.kind {
701                    InputKind::RequestBody => inherits_body,
702                    InputKind::QueryParameters => true,
703                })
704                .cloned(),
705        );
706
707        if inherits_body {
708            self.request_schema = other.request_schema.clone();
709            if self.request_example.is_none() {
710                self.request_example = other.request_example.clone();
711            }
712        }
713
714        if self.request_content_type.is_none() {
715            self.request_content_type = other.request_content_type.clone();
716        }
717
718        for (status, body) in &other.responses {
719            self.responses
720                .entry(*status)
721                .or_insert_with(|| body.clone());
722        }
723
724        if !other.extra_parameters.is_empty() {
725            self.extra_parameters.extend(other.extra_parameters.clone());
726        }
727
728        match (&mut self.docs, &other.docs) {
729            (None, Some(d)) => self.docs = Some(d.clone()),
730            (Some(dst), Some(src)) => {
731                for s in src {
732                    if !dst.iter().any(|x| x == s) {
733                        dst.push(s.clone());
734                    }
735                }
736            }
737            _ => {}
738        }
739        self
740    }
741
742    pub(super) fn apply_to_operation(
743        &self,
744        operation: &mut OpenApiOperation,
745        schemas: &mut BTreeMap<String, OpenApiSchema>,
746    ) {
747        if let Some(summary) = &self.summary {
748            operation.summary = Some(summary.clone());
749        }
750        if let Some(description) = &self.description {
751            operation.description = Some(description.clone());
752        }
753        if let Some(operation_id) = &self.operation_id {
754            operation.operation_id = Some(operation_id.clone());
755        }
756
757        if !self.tags.is_empty() {
758            operation.tags = Some(self.tags.clone());
759        }
760
761        if self.request_schema.is_some() || self.request_example.is_some() {
762            let mut schema = self
763                .request_schema
764                .clone()
765                .unwrap_or_else(OpenApiSchema::object);
766            let example = self.request_example.clone();
767            let content_type = self
768                .request_content_type
769                .as_deref()
770                .unwrap_or(APPLICATION_JSON.as_ref());
771
772            schema = intern_schema_if_object_named(schema, schemas);
773            operation.set_request_body(schema, example, content_type);
774        }
775
776        if !self.responses.is_empty() {
777            operation.clear_all_responses();
778            for (status, body) in &self.responses {
779                match body {
780                    ResponseBody::NoContent => {
781                        operation.clear_response_body(*status);
782                    }
783                    ResponseBody::Content {
784                        schema,
785                        example,
786                        content_type,
787                    } => {
788                        let schema = intern_schema_if_object_named((**schema).clone(), schemas);
789                        operation.set_response_body(*status, schema, example.clone(), content_type);
790                    }
791                }
792            }
793        }
794
795        if !self.extra_parameters.is_empty() {
796            let params = operation.parameters.get_or_insert_with(Vec::new);
797
798            for p in &self.extra_parameters {
799                if let Some(existing) = params
800                    .iter_mut()
801                    .find(|x| x.name == p.name && x.location == p.location)
802                {
803                    *existing = p.clone();
804                } else {
805                    params.push(p.clone());
806                }
807            }
808        }
809    }
810}
811
812fn intern_schema_if_object_named(
813    mut schema: OpenApiSchema,
814    schemas: &mut BTreeMap<String, OpenApiSchema>,
815) -> OpenApiSchema {
816    if schema.schema_ref.is_some() {
817        return schema;
818    }
819
820    let base = match schema.title.clone() {
821        Some(t) if !t.is_empty() => t,
822        _ => return schema,
823    };
824
825    if schema.properties.is_none() {
826        return schema;
827    }
828
829    let mut name = base.clone();
830    if schemas.contains_key(&name) {
831        let mut i = 2;
832        while schemas.contains_key(&format!("{base}_{i}")) {
833            i += 1;
834        }
835        name = format!("{base}_{i}");
836    }
837
838    schema.title = None;
839
840    schemas.entry(name.clone()).or_insert(schema);
841
842    OpenApiSchema::reference(&name)
843}
844
845fn probe<T: DeserializeOwned>() -> Probed {
846    let mut probe = Probe::new();
847    let _ = T::deserialize(&mut probe);
848    probe.finish()
849}
850
851fn type_display_name<T>() -> String {
852    std::any::type_name::<T>()
853        .rsplit("::")
854        .next()
855        .unwrap_or("Model")
856        .to_string()
857}
858
859#[cfg(test)]
860#[allow(unused)]
861mod tests {
862    use super::{ConstraintTarget, InputKind, IntoStatusCode, OpenApiRouteConfig, ResponseBody};
863    use crate::{
864        op::OpenApiOperation,
865        schema::{FieldConstraint, OpenApiSchema, SchemaConstraint},
866    };
867    use serde::{Deserialize, Serialize};
868    use serde_json::json;
869    use std::collections::BTreeMap;
870
871    #[derive(Deserialize)]
872    struct Payload {
873        name: String,
874        age: u64,
875    }
876
877    #[derive(Serialize, Default)]
878    struct ResponsePayload {
879        message: String,
880    }
881
882    #[derive(Deserialize)]
883    struct OptionalQuery {
884        required_name: String,
885        optional_age: Option<()>,
886    }
887
888    #[derive(Deserialize)]
889    #[allow(dead_code)]
890    struct Page {
891        page: u32,
892    }
893
894    #[test]
895    fn with_constraints_touches_only_the_parameter_its_extractor_added() {
896        // Two extractors describing a query parameter of the same name: each is described
897        // in turn, appending its own parameter, and neither may reach into the other's
898        let cfg = OpenApiRouteConfig::default();
899
900        let described_before = cfg.described_parameters();
901        let cfg = cfg.consumes_query::<Page>().with_constraints(
902            ConstraintTarget::QueryParameter { described_before },
903            &[FieldConstraint::new(
904                "page",
905                SchemaConstraint::Minimum(1.into()),
906            )],
907        );
908
909        let described_before = cfg.described_parameters();
910        let cfg = cfg.consumes_query::<Page>().with_constraints(
911            ConstraintTarget::QueryParameter { described_before },
912            &[FieldConstraint::new(
913                "page",
914                SchemaConstraint::Minimum(100.into()),
915            )],
916        );
917
918        let bounds = cfg
919            .extra_parameters
920            .iter()
921            .map(|parameter| parameter.schema.minimum.clone())
922            .collect::<Vec<_>>();
923
924        assert_eq!(bounds, vec![Some(1.into()), Some(100.into())]);
925    }
926
927    #[test]
928    fn with_constraints_of_an_extractor_that_described_nothing_reach_nobody() {
929        // A struct whose schema cannot be inferred - an enum field is enough - adds no
930        // parameters, so its bounds have no target and must not fall back to someone else's
931        let cfg = OpenApiRouteConfig::default();
932        let described_before = cfg.described_parameters();
933        let cfg = cfg.consumes_query::<Page>().with_constraints(
934            ConstraintTarget::QueryParameter { described_before },
935            &[FieldConstraint::new(
936                "page",
937                SchemaConstraint::Minimum(1.into()),
938            )],
939        );
940
941        let described_before = cfg.described_parameters();
942        let cfg = cfg.with_constraints(
943            ConstraintTarget::QueryParameter { described_before },
944            &[FieldConstraint::new(
945                "page",
946                SchemaConstraint::Minimum(100.into()),
947            )],
948        );
949
950        assert_eq!(cfg.extra_parameters.len(), 1);
951        assert_eq!(cfg.extra_parameters[0].schema.minimum, Some(1.into()));
952    }
953
954    #[test]
955    fn with_constraints_intersects_a_bound_declared_twice() {
956        // Both rules run at runtime, so the published bound is the one that survives both
957        let cfg = OpenApiRouteConfig::default()
958            .consumes_query::<Page>()
959            .with_constraints(
960                ConstraintTarget::QueryParameter {
961                    described_before: 0,
962                },
963                &[
964                    FieldConstraint::new("page", SchemaConstraint::Minimum(10.into())),
965                    FieldConstraint::new("page", SchemaConstraint::Minimum(1.into())),
966                    FieldConstraint::new("page", SchemaConstraint::Maximum(50.into())),
967                    FieldConstraint::new("page", SchemaConstraint::Maximum(500.into())),
968                ],
969            );
970
971        let schema = &cfg.extra_parameters[0].schema;
972
973        assert_eq!(schema.minimum, Some(10.into()));
974        assert_eq!(schema.maximum, Some(50.into()));
975    }
976
977    #[test]
978    fn consumes_query_keeps_parameters_of_a_typed_optional_field() {
979        #[derive(serde::Deserialize)]
980        #[allow(dead_code)]
981        struct OptionalString {
982            per_page: u32,
983            sort: Option<String>,
984        }
985
986        let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalString>();
987        let names = cfg
988            .extra_parameters
989            .iter()
990            .map(|p| p.name.as_str())
991            .collect::<Vec<_>>();
992
993        // A typed `Option` used to make the whole probe fail, dropping every parameter
994        assert_eq!(names, vec!["per_page", "sort"]);
995        assert!(!cfg.extra_parameters[1].required);
996    }
997
998    #[derive(Deserialize)]
999    #[allow(dead_code)]
1000    struct Inner {
1001        name: String,
1002    }
1003
1004    #[derive(Deserialize)]
1005    #[allow(dead_code)]
1006    struct Flat {
1007        #[serde(flatten)]
1008        inner: Inner,
1009        page: u32,
1010    }
1011
1012    fn body_schema(cfg: &OpenApiRouteConfig) -> serde_json::Value {
1013        let mut operation = OpenApiOperation::default();
1014        let mut schemas = BTreeMap::new();
1015        cfg.apply_to_operation(&mut operation, &mut schemas);
1016
1017        let operation = serde_json::to_value(&operation).expect("operation serializes");
1018        operation["requestBody"]["content"]["application/json"]["schema"].clone()
1019    }
1020
1021    #[test]
1022    fn consumes_json_reports_a_body_read_as_a_map() {
1023        let cfg = OpenApiRouteConfig::default().consumes_json::<Flat>();
1024
1025        let [input] = cfg.undescribed_inputs() else {
1026            panic!("one undescribed input: {:?}", cfg.undescribed_inputs());
1027        };
1028        assert_eq!(input.kind(), InputKind::RequestBody);
1029        assert_eq!(input.type_name(), std::any::type_name::<Flat>());
1030        assert_eq!(input.read_as_map(), None);
1031
1032        // An object is all that is known, and an example without the fields would be one
1033        // the server refuses
1034        assert_eq!(
1035            body_schema(&cfg),
1036            json!({ "type": "object", "title": "Flat" })
1037        );
1038        assert!(cfg.request_example.is_none());
1039    }
1040
1041    #[test]
1042    fn consumes_json_reports_a_struct_read_as_a_map_inside_the_body() {
1043        let cfg = OpenApiRouteConfig::default().consumes_json::<Vec<Flat>>();
1044
1045        let [input] = cfg.undescribed_inputs() else {
1046            panic!("one undescribed input: {:?}", cfg.undescribed_inputs());
1047        };
1048        assert_eq!(input.kind(), InputKind::RequestBody);
1049        assert_eq!(input.read_as_map(), Some("struct Flat"));
1050
1051        // The body is an array here, so publishing the struct's object would be wrong
1052        assert_eq!(body_schema(&cfg)["type"], serde_json::Value::Null);
1053    }
1054
1055    #[test]
1056    fn consumes_form_reports_a_body_read_as_a_map() {
1057        let cfg = OpenApiRouteConfig::default().consumes_form::<Flat>();
1058
1059        let [input] = cfg.undescribed_inputs() else {
1060            panic!("one undescribed input: {:?}", cfg.undescribed_inputs());
1061        };
1062        assert_eq!(input.kind(), InputKind::RequestBody);
1063        assert_eq!(
1064            cfg.request_content_type.as_deref(),
1065            Some("application/x-www-form-urlencoded")
1066        );
1067    }
1068
1069    #[test]
1070    fn consumes_json_does_not_report_a_map() {
1071        let cfg =
1072            OpenApiRouteConfig::default().consumes_json::<std::collections::HashMap<String, u32>>();
1073
1074        assert!(cfg.undescribed_inputs().is_empty());
1075        assert!(cfg.request_schema.is_some());
1076    }
1077
1078    #[test]
1079    fn consumes_query_reports_a_query_read_as_a_map() {
1080        let cfg = OpenApiRouteConfig::default().consumes_query::<Flat>();
1081
1082        let [input] = cfg.undescribed_inputs() else {
1083            panic!("one undescribed input: {:?}", cfg.undescribed_inputs());
1084        };
1085        assert_eq!(input.kind(), InputKind::QueryParameters);
1086        assert!(cfg.extra_parameters.is_empty());
1087    }
1088
1089    #[test]
1090    fn consumes_named_path_leaves_a_struct_read_as_a_map_to_the_template() {
1091        let cfg = OpenApiRouteConfig::default().consumes_named_path::<Flat>();
1092
1093        assert!(cfg.undescribed_inputs().is_empty());
1094        assert!(cfg.extra_parameters.is_empty());
1095    }
1096
1097    #[test]
1098    fn with_request_schema_describes_an_undescribed_body() {
1099        let cfg = OpenApiRouteConfig::default()
1100            .consumes_query::<Flat>()
1101            .consumes_json::<Flat>()
1102            .with_request_schema(
1103                OpenApiSchema::object()
1104                    .with_property("name", OpenApiSchema::string())
1105                    .with_property("page", OpenApiSchema::integer())
1106                    .with_required(["name", "page"]),
1107            );
1108
1109        // The query is still undescribed; only the body was described by hand
1110        let kinds = cfg
1111            .undescribed_inputs()
1112            .iter()
1113            .map(|input| input.kind())
1114            .collect::<Vec<_>>();
1115        assert_eq!(kinds, vec![InputKind::QueryParameters]);
1116        assert_eq!(
1117            body_schema(&cfg)["properties"]["page"]["type"],
1118            json!("integer")
1119        );
1120    }
1121
1122    #[test]
1123    fn with_query_schema_describes_an_undescribed_query() {
1124        let cfg = OpenApiRouteConfig::default()
1125            .consumes_json::<Flat>()
1126            .consumes_query::<Flat>()
1127            .with_query_schema(
1128                OpenApiSchema::object()
1129                    .with_property("name", OpenApiSchema::string())
1130                    .with_property("page", OpenApiSchema::integer())
1131                    .with_required(["page"]),
1132            );
1133
1134        let kinds = cfg
1135            .undescribed_inputs()
1136            .iter()
1137            .map(|input| input.kind())
1138            .collect::<Vec<_>>();
1139        assert_eq!(kinds, vec![InputKind::RequestBody]);
1140
1141        let parameters = cfg
1142            .extra_parameters
1143            .iter()
1144            .map(|p| (p.name.as_str(), p.location.as_str(), p.required))
1145            .collect::<Vec<_>>();
1146        assert_eq!(
1147            parameters,
1148            vec![("name", "query", false), ("page", "query", true)]
1149        );
1150    }
1151
1152    #[test]
1153    fn with_query_schema_replaces_a_parameter_of_the_same_name() {
1154        let cfg = OpenApiRouteConfig::default()
1155            .consumes_query::<Page>()
1156            .with_query_schema(
1157                OpenApiSchema::object().with_property("page", OpenApiSchema::string()),
1158            );
1159
1160        let mut operation = OpenApiOperation::default();
1161        cfg.apply_to_operation(&mut operation, &mut BTreeMap::new());
1162
1163        let parameters = operation.parameters.expect("parameters");
1164        assert_eq!(parameters.len(), 1);
1165        assert_eq!(parameters[0].schema.schema_type.as_deref(), Some("string"));
1166        assert!(!parameters[0].required);
1167    }
1168
1169    fn kinds(cfg: &OpenApiRouteConfig) -> Vec<InputKind> {
1170        cfg.undescribed_inputs()
1171            .iter()
1172            .map(|input| input.kind())
1173            .collect()
1174    }
1175
1176    /// A group's inputs reach a route with what could not be described of them: its query
1177    /// parameters always, since they are added to the route's, and its body only where the
1178    /// route has none of its own to keep
1179    #[test]
1180    fn merge_outer_inherits_the_undescribed_inputs_it_inherits() {
1181        let group = OpenApiRouteConfig::default()
1182            .consumes_query::<Flat>()
1183            .consumes_json::<Flat>();
1184
1185        let bare = OpenApiRouteConfig::default().merge_outer(&group);
1186        assert_eq!(
1187            kinds(&bare),
1188            [InputKind::QueryParameters, InputKind::RequestBody]
1189        );
1190
1191        let own_body = OpenApiRouteConfig::default()
1192            .consumes_json::<Payload>()
1193            .merge_outer(&group);
1194        assert_eq!(kinds(&own_body), [InputKind::QueryParameters]);
1195        assert!(
1196            own_body
1197                .request_schema
1198                .as_ref()
1199                .and_then(|schema| schema.properties.as_ref())
1200                .is_some_and(|properties| properties.contains_key("name")),
1201            "the route keeps its own body"
1202        );
1203
1204        // A route's own undescribed body is the one kept, and reported once
1205        let own_flat_body = OpenApiRouteConfig::default()
1206            .consumes_json::<Vec<Flat>>()
1207            .merge_outer(&group);
1208        let bodies = own_flat_body
1209            .undescribed_inputs()
1210            .iter()
1211            .filter(|input| input.kind() == InputKind::RequestBody)
1212            .map(|input| input.type_name())
1213            .collect::<Vec<_>>();
1214        assert_eq!(bodies, [std::any::type_name::<Vec<Flat>>()]);
1215    }
1216
1217    /// An example is an example of the body it was made for, so it goes with that body: a
1218    /// route with a body of its own keeps its own example, or its lack of one
1219    #[test]
1220    fn merge_outer_takes_an_example_only_with_the_body_it_belongs_to() {
1221        let group = OpenApiRouteConfig::default().consumes_json::<Payload>();
1222        assert!(group.request_example.is_some());
1223
1224        let flat = OpenApiRouteConfig::default()
1225            .consumes_json::<Flat>()
1226            .merge_outer(&group);
1227        assert!(flat.request_example.is_none());
1228
1229        let by_hand = OpenApiRouteConfig::default()
1230            .with_request_schema(OpenApiSchema::string())
1231            .merge_outer(&group);
1232        assert!(by_hand.request_example.is_none());
1233
1234        let bare = OpenApiRouteConfig::default().merge_outer(&group);
1235        assert_eq!(bare.request_example, group.request_example);
1236    }
1237
1238    #[test]
1239    fn merge_outer_inherits_no_input_a_group_describes_by_hand() {
1240        let group = OpenApiRouteConfig::default()
1241            .consumes_query::<Flat>()
1242            .with_query_schema(
1243                OpenApiSchema::object().with_property("name", OpenApiSchema::string()),
1244            );
1245
1246        let merged = OpenApiRouteConfig::default().merge_outer(&group);
1247
1248        assert!(merged.undescribed_inputs().is_empty());
1249    }
1250
1251    #[test]
1252    fn consumes_query_marks_non_optional_fields_as_required() {
1253        let cfg = OpenApiRouteConfig::default().consumes_query::<OptionalQuery>();
1254
1255        let required_name = cfg
1256            .extra_parameters
1257            .iter()
1258            .find(|p| p.name == "required_name")
1259            .expect("required_name param");
1260        let optional_age = cfg
1261            .extra_parameters
1262            .iter()
1263            .find(|p| p.name == "optional_age")
1264            .expect("optional_age param");
1265
1266        assert!(required_name.required);
1267        assert!(!optional_age.required);
1268    }
1269
1270    #[test]
1271    fn infer_request_schema_from_deserialize_type() {
1272        let cfg = OpenApiRouteConfig::default()
1273            .with_request_type_from_deserialize::<Payload>("application/json");
1274
1275        let schema = cfg
1276            .request_schema
1277            .expect("request schema should be inferred");
1278        let props = schema.properties.expect("object properties should exist");
1279
1280        assert!(props.contains_key("name"));
1281        assert!(props.contains_key("age"));
1282        assert_eq!(
1283            cfg.request_content_type.as_deref(),
1284            Some("application/json")
1285        );
1286    }
1287
1288    #[test]
1289    fn infer_query_parameters_from_deserialize_type() {
1290        let cfg = OpenApiRouteConfig::default().with_query_parameters_from_deserialize::<Payload>();
1291
1292        assert_eq!(cfg.extra_parameters.len(), 2);
1293        assert!(cfg.extra_parameters.iter().any(|p| p.name == "name"));
1294        assert!(cfg.extra_parameters.iter().any(|p| p.name == "age"));
1295        assert!(cfg.extra_parameters.iter().all(|p| p.location == "query"));
1296    }
1297
1298    #[test]
1299    fn merge_outer_keeps_the_route_ahead_of_the_scope_it_is_in() {
1300        let group = OpenApiRouteConfig::default()
1301            .with_tag("group")
1302            .with_summary("group summary")
1303            .with_query_parameter("id".into(), OpenApiSchema::string(), false);
1304        let route = OpenApiRouteConfig::default()
1305            .with_tag("route")
1306            .with_query_parameter("id".into(), OpenApiSchema::integer(), true);
1307
1308        let merged = route.merge_outer(&group);
1309
1310        // The scope reads outermost first, and the route's own values win: a parameter
1311        // replaces a same-name one applied before it, so the route's has to come last
1312        assert_eq!(merged.tags, vec!["group".to_string(), "route".to_string()]);
1313        assert_eq!(merged.summary.as_deref(), Some("group summary"));
1314        assert_eq!(merged.extra_parameters.len(), 2);
1315        assert!(merged.extra_parameters[1].required);
1316    }
1317
1318    #[test]
1319    fn merge_combines_docs_and_preserves_existing_values() {
1320        let base = OpenApiRouteConfig::default()
1321            .with_tag("base")
1322            .with_summary("base summary")
1323            .with_doc("v1");
1324        let other = OpenApiRouteConfig::default()
1325            .with_tag("extra")
1326            .with_summary("other summary")
1327            .with_doc("v1")
1328            .with_doc("admin");
1329
1330        let merged = base.merge(&other);
1331
1332        assert_eq!(merged.summary.as_deref(), Some("base summary"));
1333        assert_eq!(merged.tags.as_slice(), ["base", "extra"]);
1334        assert_eq!(merged.docs.expect("docs"), vec!["v1", "admin"]);
1335    }
1336
1337    #[test]
1338    fn apply_to_operation_adds_content_and_interns_named_schema() {
1339        let cfg = OpenApiRouteConfig::default()
1340            .with_request_schema(
1341                OpenApiSchema::object()
1342                    .with_title("CreateUser")
1343                    .with_property("name", OpenApiSchema::string()),
1344            )
1345            .with_response_schema(
1346                200u16,
1347                OpenApiSchema::object()
1348                    .with_title("User")
1349                    .with_property("id", OpenApiSchema::integer()),
1350            )
1351            .produces_json_example(
1352                200u16,
1353                ResponsePayload {
1354                    message: "ok".to_string(),
1355                },
1356            )
1357            .with_summary("create user")
1358            .with_operation_id("createUser");
1359
1360        let mut op = OpenApiOperation::default();
1361        let mut schemas = BTreeMap::new();
1362
1363        cfg.apply_to_operation(&mut op, &mut schemas);
1364
1365        let op_json = serde_json::to_value(op).expect("serialize operation");
1366        assert_eq!(op_json["summary"], "create user");
1367        assert_eq!(op_json["operationId"], "createUser");
1368        assert!(
1369            op_json["requestBody"]["content"]
1370                .get("application/json")
1371                .is_some()
1372        );
1373        assert_eq!(
1374            op_json["responses"]["200"]["content"]["application/json"]["example"],
1375            json!({"message":"ok"})
1376        );
1377
1378        assert!(schemas.contains_key("CreateUser"));
1379    }
1380
1381    #[test]
1382    fn produces_no_schema_clears_content() {
1383        let cfg = OpenApiRouteConfig::default()
1384            .produces_json::<ResponsePayload>(200)
1385            .produces_no_schema(200);
1386
1387        let mut op = OpenApiOperation::default();
1388        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1389
1390        let op_json = serde_json::to_value(op).expect("serialize operation");
1391        assert!(op_json["responses"]["200"].get("content").is_none());
1392    }
1393
1394    #[test]
1395    fn with_tags_extends_existing_tags() {
1396        let cfg = OpenApiRouteConfig::default()
1397            .with_tag("base")
1398            .with_tags(["one", "two"]);
1399
1400        assert_eq!(cfg.tags, vec!["base", "one", "two"]);
1401    }
1402
1403    #[test]
1404    fn with_docs_initializes_and_extends_docs() {
1405        let cfg = OpenApiRouteConfig::default()
1406            .with_docs(["v1", "admin"])
1407            .with_doc("internal");
1408
1409        assert_eq!(cfg.docs.expect("docs"), vec!["v1", "admin", "internal"]);
1410    }
1411
1412    #[test]
1413    fn with_description_sets_operation_description() {
1414        let cfg = OpenApiRouteConfig::default().with_description("desc");
1415        assert_eq!(cfg.description.as_deref(), Some("desc"));
1416    }
1417
1418    #[test]
1419    fn consumes_methods_set_request_content_type() {
1420        let query_cfg = OpenApiRouteConfig::default().consumes_query::<Payload>();
1421        assert_eq!(query_cfg.extra_parameters.len(), 2);
1422
1423        let json_cfg = OpenApiRouteConfig::default().consumes_json::<Payload>();
1424        assert_eq!(
1425            json_cfg.request_content_type.as_deref(),
1426            Some("application/json")
1427        );
1428        assert!(json_cfg.request_schema.is_some());
1429
1430        let form_cfg = OpenApiRouteConfig::default().consumes_form::<Payload>();
1431        assert_eq!(
1432            form_cfg.request_content_type.as_deref(),
1433            Some("application/x-www-form-urlencoded")
1434        );
1435        assert!(form_cfg.request_schema.is_some());
1436
1437        let multipart_cfg = OpenApiRouteConfig::default().consumes_multipart();
1438        assert_eq!(
1439            multipart_cfg.request_content_type.as_deref(),
1440            Some("multipart/form-data")
1441        );
1442
1443        let stream_cfg = OpenApiRouteConfig::default().consumes_stream();
1444        assert_eq!(
1445            stream_cfg.request_content_type.as_deref(),
1446            Some("application/octet-stream")
1447        );
1448        assert_eq!(
1449            stream_cfg.request_schema.expect("schema").format.as_deref(),
1450            Some("binary")
1451        );
1452    }
1453
1454    #[test]
1455    fn produces_helpers_set_expected_response_schema_and_content_type() {
1456        fn apply(cfg: OpenApiRouteConfig) -> serde_json::Value {
1457            let mut op = OpenApiOperation::default();
1458            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1459            serde_json::to_value(op).expect("serialize")
1460        }
1461
1462        let text_json = apply(OpenApiRouteConfig::default().produces_text(200));
1463        assert!(
1464            text_json["responses"]["200"]["content"]
1465                .get("text/plain; charset=utf-8")
1466                .is_some()
1467        );
1468        assert_eq!(
1469            text_json["responses"]["200"]["content"]["text/plain; charset=utf-8"]["schema"]["type"],
1470            "string"
1471        );
1472
1473        let empty_json_json = apply(OpenApiRouteConfig::default().produces_empty_json(200));
1474        assert!(
1475            empty_json_json["responses"]["200"]["content"]
1476                .get("application/json")
1477                .is_some()
1478        );
1479        assert_eq!(
1480            empty_json_json["responses"]["200"]["content"]["application/json"]["schema"]["type"],
1481            "object"
1482        );
1483
1484        let form_json = apply(OpenApiRouteConfig::default().produces_form::<ResponsePayload>(200));
1485        assert!(
1486            form_json["responses"]["200"]["content"]
1487                .get("application/x-www-form-urlencoded")
1488                .is_some()
1489        );
1490        assert_eq!(
1491            form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1492                ["type"],
1493            "string"
1494        );
1495
1496        let empty_form_json = apply(OpenApiRouteConfig::default().produces_empty_form(200));
1497        assert!(
1498            empty_form_json["responses"]["200"]["content"]
1499                .get("application/x-www-form-urlencoded")
1500                .is_some()
1501        );
1502        assert_eq!(
1503            empty_form_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]
1504                ["type"],
1505            "object"
1506        );
1507
1508        let stream_json = apply(OpenApiRouteConfig::default().produces_stream(200));
1509        assert!(
1510            stream_json["responses"]["200"]["content"]
1511                .get("application/octet-stream")
1512                .is_some()
1513        );
1514        assert_eq!(
1515            stream_json["responses"]["200"]["content"]["application/octet-stream"]["schema"]["format"],
1516            "binary"
1517        );
1518
1519        let sse_json = apply(OpenApiRouteConfig::default().produces_sse(200));
1520        assert!(
1521            sse_json["responses"]["200"]["content"]
1522                .get("text/event-stream")
1523                .is_some()
1524        );
1525        assert_eq!(
1526            sse_json["responses"]["200"]["content"]["text/event-stream"]["schema"]["title"],
1527            "SSE stream"
1528        );
1529    }
1530
1531    #[test]
1532    fn produces_form_example_encodes_object_into_string_example() {
1533        #[derive(Serialize)]
1534        struct FormOut {
1535            first: String,
1536            second: i32,
1537        }
1538
1539        let cfg = OpenApiRouteConfig::default().produces_form_example(
1540            200u16,
1541            FormOut {
1542                first: "hello".to_string(),
1543                second: 7,
1544            },
1545        );
1546
1547        let mut op = OpenApiOperation::default();
1548        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1549        let op_json = serde_json::to_value(op).expect("serialize");
1550
1551        assert!(
1552            op_json["responses"]["200"]["content"]
1553                .get("application/x-www-form-urlencoded")
1554                .is_some()
1555        );
1556        assert_eq!(
1557            op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["schema"]["type"],
1558            "string"
1559        );
1560        assert_eq!(
1561            op_json["responses"]["200"]["content"]["application/x-www-form-urlencoded"]["example"],
1562            "first=hello&second=7"
1563        );
1564    }
1565
1566    #[test]
1567    fn produces_multiple_status_codes() {
1568        #[derive(Serialize, Default)]
1569        struct UserDto {
1570            id: u64,
1571        }
1572        #[derive(Serialize, Default)]
1573        struct ErrorDto {
1574            message: String,
1575        }
1576
1577        let cfg = OpenApiRouteConfig::default()
1578            .produces_json::<UserDto>(200)
1579            .produces_json::<ErrorDto>(404);
1580
1581        let mut op = OpenApiOperation::default();
1582        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1583        let op_json = serde_json::to_value(op).expect("serialize");
1584
1585        // Both statuses should appear
1586        assert!(
1587            op_json["responses"]["200"]["content"]
1588                .get("application/json")
1589                .is_some()
1590        );
1591        assert!(
1592            op_json["responses"]["404"]["content"]
1593                .get("application/json")
1594                .is_some()
1595        );
1596        // Default 200 is replaced by explicit responses
1597        assert_eq!(
1598            op_json["responses"]
1599                .as_object()
1600                .expect("responses object")
1601                .len(),
1602            2
1603        );
1604    }
1605
1606    #[test]
1607    fn explicit_responses_replace_default_200() {
1608        let cfg = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(201);
1609
1610        let mut op = OpenApiOperation::default();
1611        cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1612        let op_json = serde_json::to_value(op).expect("serialize");
1613
1614        // The default "200" must be gone - only "201" remains
1615        assert!(op_json["responses"].get("200").is_none());
1616        assert!(
1617            op_json["responses"]["201"]["content"]
1618                .get("application/json")
1619                .is_some()
1620        );
1621        assert_eq!(op_json["responses"]["201"]["description"], "Created");
1622    }
1623
1624    #[test]
1625    fn merge_does_not_override_existing_responses() {
1626        let base = OpenApiRouteConfig::default().produces_json::<ResponsePayload>(200);
1627        let other = OpenApiRouteConfig::default()
1628            .produces_text(200)
1629            .produces_json::<ResponsePayload>(404);
1630
1631        let merged = base.merge(&other);
1632
1633        let mut op = OpenApiOperation::default();
1634        merged.apply_to_operation(&mut op, &mut BTreeMap::new());
1635        let op_json = serde_json::to_value(op).expect("serialize");
1636
1637        // 200 should keep JSON (from base), not be overridden by text (from other)
1638        assert!(
1639            op_json["responses"]["200"]["content"]
1640                .get("application/json")
1641                .is_some()
1642        );
1643        assert!(
1644            op_json["responses"]["200"]["content"]
1645                .get("text/plain; charset=utf-8")
1646                .is_none()
1647        );
1648        // 404 should come from other
1649        assert!(
1650            op_json["responses"]["404"]["content"]
1651                .get("application/json")
1652                .is_some()
1653        );
1654    }
1655
1656    #[test]
1657    fn into_status_code_works_for_u16_and_http_status_code() {
1658        assert_eq!(200u16.into_status_code(), 200);
1659        assert_eq!(201.into_status_code(), 201);
1660        assert_eq!(202u32.into_status_code(), 202);
1661        assert_eq!(http::StatusCode::NOT_FOUND.into_status_code(), 404);
1662        assert_eq!(http::StatusCode::CREATED.into_status_code(), 201);
1663    }
1664
1665    #[cfg(feature = "problem-details")]
1666    mod problem_tests {
1667        use super::*;
1668
1669        #[test]
1670        fn produces_problem_generates_problem_json_schema_for_status() {
1671            let cfg = OpenApiRouteConfig::default().produces_problem(400u16);
1672            let mut op = OpenApiOperation::default();
1673            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1674            let op_json = serde_json::to_value(op).expect("serialize");
1675
1676            let content = &op_json["responses"]["400"]["content"]["application/problem+json"];
1677            assert!(
1678                content.is_object(),
1679                "application/problem+json content should exist"
1680            );
1681            assert_eq!(content["example"]["title"], "Bad Request");
1682            assert_eq!(content["example"]["status"], 400);
1683            assert_eq!(
1684                content["example"]["type"],
1685                "https://tools.ietf.org/html/rfc9110"
1686            );
1687            assert_eq!(op_json["responses"]["400"]["description"], "Bad Request");
1688            // Default "200" must be gone
1689            assert!(op_json["responses"].get("200").is_none());
1690        }
1691
1692        #[test]
1693        fn produces_problem_example_uses_provided_value() {
1694            #[derive(Serialize)]
1695            struct CustomProblem {
1696                r#type: String,
1697                title: String,
1698                status: u16,
1699                detail: String,
1700            }
1701
1702            let cfg = OpenApiRouteConfig::default().produces_problem_example(
1703                422u16,
1704                CustomProblem {
1705                    r#type: "https://tools.ietf.org/html/rfc9110".into(),
1706                    title: "Unprocessable Content".into(),
1707                    status: 422,
1708                    detail: "Validation failed".into(),
1709                },
1710            );
1711
1712            let mut op = OpenApiOperation::default();
1713            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1714            let op_json = serde_json::to_value(op).expect("serialize");
1715
1716            let content = &op_json["responses"]["422"]["content"]["application/problem+json"];
1717            assert!(
1718                content.is_object(),
1719                "application/problem+json content should exist"
1720            );
1721            assert_eq!(content["example"]["detail"], "Validation failed");
1722            assert_eq!(content["example"]["status"], 422);
1723            assert_eq!(
1724                op_json["responses"]["422"]["description"],
1725                "Unprocessable Entity"
1726            );
1727        }
1728
1729        #[test]
1730        fn produces_problem_composes_with_other_status_responses() {
1731            let cfg = OpenApiRouteConfig::default()
1732                .produces_json::<ResponsePayload>(200)
1733                .produces_problem(400)
1734                .produces_problem(500);
1735
1736            let mut op = OpenApiOperation::default();
1737            cfg.apply_to_operation(&mut op, &mut BTreeMap::new());
1738            let op_json = serde_json::to_value(op).expect("serialize");
1739
1740            assert!(
1741                op_json["responses"]["200"]["content"]
1742                    .get("application/json")
1743                    .is_some()
1744            );
1745            assert!(
1746                op_json["responses"]["400"]["content"]
1747                    .get("application/problem+json")
1748                    .is_some()
1749            );
1750            assert!(
1751                op_json["responses"]["500"]["content"]
1752                    .get("application/problem+json")
1753                    .is_some()
1754            );
1755            assert_eq!(op_json["responses"].as_object().unwrap().len(), 3);
1756        }
1757    }
1758
1759    #[test]
1760    fn produces_multipart_sets_response_content_type() {
1761        let cfg = OpenApiRouteConfig::default().produces_multipart(200u16);
1762        let resp = cfg.responses.get(&200u16).expect("200 response defined");
1763        match resp {
1764            ResponseBody::Content { content_type, .. } => {
1765                assert_eq!(content_type, "multipart/form-data");
1766            }
1767            _ => panic!("expected Content variant"),
1768        }
1769    }
1770}