Skip to main content

salvo_oapi/openapi/
path.rs

1//! Implements [OpenAPI Path Object][paths] types.
2//!
3//! [paths]: https://spec.openapis.org/oas/latest.html#paths-object
4use std::iter;
5use std::ops::{Deref, DerefMut};
6
7use serde::{Deserialize, Serialize};
8
9use super::{Operation, Operations, Parameter, Parameters, PathMap, PropMap, Server, Servers};
10
11/// Implements [OpenAPI Path Object][paths] types.
12///
13/// [paths]: https://spec.openapis.org/oas/latest.html#paths-object
14#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)]
15pub struct Paths(PathMap<String, PathItem>);
16impl Deref for Paths {
17    type Target = PathMap<String, PathItem>;
18
19    fn deref(&self) -> &Self::Target {
20        &self.0
21    }
22}
23impl DerefMut for Paths {
24    fn deref_mut(&mut self) -> &mut Self::Target {
25        &mut self.0
26    }
27}
28impl Paths {
29    /// Construct a new empty [`Paths`]. This is effectively same as calling [`Paths::default`].
30    #[must_use]
31    pub fn new() -> Self {
32        Default::default()
33    }
34    /// Inserts a key-value pair into the instance and returns `self`.
35    #[must_use]
36    pub fn path<K: Into<String>, V: Into<PathItem>>(mut self, key: K, value: V) -> Self {
37        self.insert(key, value);
38        self
39    }
40    /// Inserts a key-value pair into the instance.
41    pub fn insert<K: Into<String>, V: Into<PathItem>>(&mut self, key: K, value: V) {
42        let key = key.into();
43        let mut value = value.into();
44        self.0
45            .entry(key)
46            .and_modify(|item| {
47                if value.ref_location.is_some() {
48                    item.ref_location = value.ref_location.take();
49                }
50                if value.summary.is_some() {
51                    item.summary = value.summary.take();
52                }
53                if value.description.is_some() {
54                    item.description = value.description.take();
55                }
56                item.servers.append(&mut value.servers);
57                item.parameters.append(&mut value.parameters);
58                item.operations.append(&mut value.operations);
59                item.additional_operations
60                    .append(&mut value.additional_operations);
61            })
62            .or_insert(value);
63    }
64    /// Moves all elements from `other` into `self`, leaving `other` empty.
65    ///
66    /// If a key from `other` is already present in `self`, the two [`PathItem`]s are
67    /// merged field by field (see [`insert`](Self::insert)): `servers`, `parameters`
68    /// and `operations` are appended, while the scalar fields (`ref_location`,
69    /// `summary`, `description`) are overwritten by `other`'s non-empty values.
70    pub fn append(&mut self, other: &mut Self) {
71        let items = std::mem::take(&mut other.0);
72        for item in items {
73            self.insert(item.0, item.1);
74        }
75    }
76    /// Extends a collection with the contents of an iterator.
77    pub fn extend<I, K, V>(&mut self, iter: I)
78    where
79        I: IntoIterator<Item = (K, V)>,
80        K: Into<String>,
81        V: Into<PathItem>,
82    {
83        for (k, v) in iter.into_iter() {
84            self.insert(k, v);
85        }
86    }
87}
88
89/// Implements [OpenAPI Path Item Object][path_item] what describes [`Operation`]s available on
90/// a single path.
91///
92/// [path_item]: https://spec.openapis.org/oas/latest.html#path-item-object
93#[non_exhaustive]
94#[derive(Serialize, Default, Clone, PartialEq, Debug)]
95#[serde(rename_all = "camelCase")]
96pub struct PathItem {
97    /// External reference to a Path Item Object defined elsewhere.
98    ///
99    /// In OpenAPI 3.1 a Path Item Object can carry its own `$ref` field that delegates to
100    /// another Path Item definition. When set, sibling fields' behavior is undefined per
101    /// spec — most consumers resolve the reference and ignore them.
102    ///
103    /// See <https://spec.openapis.org/oas/v3.1.0#path-item-object>.
104    #[serde(rename = "$ref", skip_serializing_if = "Option::is_none", default)]
105    pub ref_location: Option<String>,
106
107    /// Optional summary intended to apply all operations in this [`PathItem`].
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub summary: Option<String>,
110
111    /// Optional description intended to apply all operations in this [`PathItem`].
112    /// Description supports markdown syntax.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub description: Option<String>,
115
116    /// Alternative [`Server`] array to serve all [`Operation`]s in this [`PathItem`] overriding
117    /// the global server array.
118    #[serde(skip_serializing_if = "Servers::is_empty", default)]
119    pub servers: Servers,
120
121    /// List of [`Parameter`]s common to all [`Operation`]s in this [`PathItem`]. Parameters cannot
122    /// contain duplicate parameters. They can be overridden in [`Operation`] level but cannot be
123    /// removed there.
124    #[serde(skip_serializing_if = "Parameters::is_empty", default)]
125    pub parameters: Parameters,
126
127    /// Map of operations in this [`PathItem`]. Operations can hold only one operation
128    /// per [`PathItemType`].
129    #[serde(flatten, default)]
130    pub operations: Operations,
131
132    /// Operations on this path that use an HTTP method with no dedicated field, e.g. a custom
133    /// or registered extension method. Added in OpenAPI 3.2.
134    ///
135    /// The key is the HTTP method with the exact capitalization sent in the request (e.g.
136    /// `PURGE`). Methods that already have a dedicated field must not appear here — use
137    /// [`PathItem::operations`] for those.
138    ///
139    /// See <https://spec.openapis.org/oas/v3.2.0.html#path-item-object>.
140    #[serde(skip_serializing_if = "PropMap::is_empty", default)]
141    pub additional_operations: PropMap<String, Operation>,
142
143    /// Optional extensions "x-something"
144    #[serde(skip_serializing_if = "PropMap::is_empty", flatten)]
145    pub extensions: PropMap<String, serde_json::Value>,
146}
147
148/// Deserializing a [`PathItem`] cannot be derived: the operation map and the extension map are
149/// both `flatten`ed, so serde would hand every unmatched key to *both* of them and an operation
150/// such as `get` would end up duplicated in `extensions`, making the value re-serialize with
151/// repeated keys. Partition the keys explicitly instead.
152impl<'de> Deserialize<'de> for PathItem {
153    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
154    where
155        D: serde::Deserializer<'de>,
156    {
157        fn field<T, E>(name: &str, value: serde_json::Value) -> Result<T, E>
158        where
159            T: serde::de::DeserializeOwned,
160            E: serde::de::Error,
161        {
162            serde_json::from_value(value)
163                .map_err(|e| E::custom(format!("invalid `{name}` in path item: {e}")))
164        }
165
166        let raw = PropMap::<String, serde_json::Value>::deserialize(deserializer)?;
167        let mut item = Self::default();
168        for (key, value) in raw {
169            match &*key {
170                "$ref" => item.ref_location = Some(field("$ref", value)?),
171                "summary" => item.summary = Some(field("summary", value)?),
172                "description" => item.description = Some(field("description", value)?),
173                "servers" => item.servers = field("servers", value)?,
174                "parameters" => item.parameters = field("parameters", value)?,
175                "additionalOperations" => {
176                    item.additional_operations = field("additionalOperations", value)?;
177                }
178                _ => {
179                    // A key naming an operation with a dedicated field goes to `operations`;
180                    // everything else (`x-*` and unknown keys) is kept as an extension.
181                    match serde_json::from_value::<PathItemType>(serde_json::Value::String(
182                        key.clone(),
183                    )) {
184                        Ok(item_type) => {
185                            item.operations
186                                .insert(item_type, field::<Operation, _>(&key, value)?);
187                        }
188                        Err(_) => {
189                            item.extensions.insert(key, value);
190                        }
191                    }
192                }
193            }
194        }
195        Ok(item)
196    }
197}
198
199impl PathItem {
200    /// Construct a new [`PathItem`] with provided [`Operation`] mapped to given [`PathItemType`].
201    pub fn new<O: Into<Operation>>(path_item_type: PathItemType, operation: O) -> Self {
202        let operations = PropMap::from_iter(iter::once((path_item_type, operation.into())));
203
204        Self {
205            operations: Operations(operations),
206            ..Default::default()
207        }
208    }
209
210    /// Construct a [`PathItem`] that is purely a reference to another Path Item, e.g. one
211    /// defined under `components.pathItems`.
212    ///
213    /// ```
214    /// # use salvo_oapi::PathItem;
215    /// let item = PathItem::from_ref("#/components/pathItems/PingWebhook");
216    /// ```
217    #[must_use]
218    pub fn from_ref<S: Into<String>>(ref_location: S) -> Self {
219        Self {
220            ref_location: Some(ref_location.into()),
221            ..Default::default()
222        }
223    }
224
225    /// Set the `$ref` location for this [`PathItem`] and return `self`.
226    #[must_use]
227    pub fn ref_location<S: Into<String>>(mut self, ref_location: S) -> Self {
228        self.ref_location = Some(ref_location.into());
229        self
230    }
231    /// Moves all elements from `other` into `self`, leaving `other` empty.
232    ///
233    /// If a key from `other` is already present in `self`, the respective
234    /// value from `self` will be overwritten with the respective value from `other`.
235    pub fn append(&mut self, other: &mut Self) {
236        self.operations.append(&mut other.operations);
237        self.additional_operations
238            .append(&mut other.additional_operations);
239        self.servers.append(&mut other.servers);
240        self.parameters.append(&mut other.parameters);
241        if other.description.is_some() {
242            self.description = other.description.take();
243        }
244        if other.summary.is_some() {
245            self.summary = other.summary.take();
246        }
247        if other.ref_location.is_some() {
248            self.ref_location = other.ref_location.take();
249        }
250        other
251            .extensions
252            .retain(|name, _| !self.extensions.contains_key(name));
253        self.extensions.append(&mut other.extensions);
254    }
255
256    /// Append a new [`Operation`] by [`PathItemType`] to this [`PathItem`]. Operations can
257    /// hold only one operation per [`PathItemType`].
258    #[must_use]
259    pub fn add_operation<O: Into<Operation>>(
260        mut self,
261        path_item_type: PathItemType,
262        operation: O,
263    ) -> Self {
264        self.operations.insert(path_item_type, operation.into());
265        self
266    }
267
268    /// Append an [`Operation`] for an HTTP method that has no dedicated Path Item field, e.g.
269    /// a custom method. Requires OpenAPI 3.2.
270    ///
271    /// `method` must be the HTTP method with the exact capitalization sent in the request, and
272    /// must not be one of the methods covered by [`PathItemType`].
273    ///
274    /// ```
275    /// # use salvo_oapi::{Operation, PathItem};
276    /// let item = PathItem::default().add_additional_operation("PURGE", Operation::new());
277    /// ```
278    #[must_use]
279    pub fn add_additional_operation<M: Into<String>, O: Into<Operation>>(
280        mut self,
281        method: M,
282        operation: O,
283    ) -> Self {
284        self.additional_operations
285            .insert(method.into(), operation.into());
286        self
287    }
288
289    /// Add or change summary intended to apply all operations in this [`PathItem`].
290    #[must_use]
291    pub fn summary<S: Into<String>>(mut self, summary: S) -> Self {
292        self.summary = Some(summary.into());
293        self
294    }
295
296    /// Add or change optional description intended to apply all operations in this [`PathItem`].
297    /// Description supports markdown syntax.
298    #[must_use]
299    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
300        self.description = Some(description.into());
301        self
302    }
303
304    /// Add list of alternative [`Server`]s to serve all [`Operation`]s in this [`PathItem`]
305    /// overriding the global server array.
306    #[must_use]
307    pub fn servers<I: IntoIterator<Item = Server>>(mut self, servers: I) -> Self {
308        self.servers = Servers(servers.into_iter().collect());
309        self
310    }
311
312    /// Append list of [`Parameter`]s common to all [`Operation`]s to this [`PathItem`].
313    #[must_use]
314    pub fn parameters<I: IntoIterator<Item = Parameter>>(mut self, parameters: I) -> Self {
315        self.parameters = Parameters(parameters.into_iter().collect());
316        self
317    }
318
319    /// Add openapi extensions (`x-something`) for [`PathItem`].
320    #[must_use]
321    pub fn extensions(mut self, extensions: PropMap<String, serde_json::Value>) -> Self {
322        self.extensions = extensions;
323        self
324    }
325}
326
327/// Path item operation type.
328///
329/// Mirrors the HTTP methods that have a dedicated field in the [Path Item Object][path_item];
330/// note that the spec deliberately does not list `CONNECT`, so it is intentionally absent
331/// here as well. Methods without a dedicated field go in
332/// [`PathItem::additional_operations`] instead.
333///
334/// [path_item]: https://spec.openapis.org/oas/latest.html#path-item-object
335#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord, Clone, Copy, Debug)]
336#[serde(rename_all = "lowercase")]
337pub enum PathItemType {
338    /// Type mapping for HTTP _GET_ request.
339    Get,
340    /// Type mapping for HTTP _POST_ request.
341    Post,
342    /// Type mapping for HTTP _PUT_ request.
343    Put,
344    /// Type mapping for HTTP _DELETE_ request.
345    Delete,
346    /// Type mapping for HTTP _OPTIONS_ request.
347    Options,
348    /// Type mapping for HTTP _HEAD_ request.
349    Head,
350    /// Type mapping for HTTP _PATCH_ request.
351    Patch,
352    /// Type mapping for HTTP _TRACE_ request.
353    Trace,
354    /// Type mapping for HTTP _QUERY_ request, as defined by
355    /// [draft-ietf-httpbis-safe-method-w-body](https://www.ietf.org/archive/id/draft-ietf-httpbis-safe-method-w-body-11.html).
356    ///
357    /// Added in OpenAPI 3.2; emitting it in a 3.1 document produces an invalid document.
358    Query,
359}
360
361#[cfg(test)]
362mod tests {
363    use assert_json_diff::assert_json_eq;
364    use serde_json::json;
365
366    use super::*;
367    use crate::oapi::response::Response;
368
369    #[test]
370    fn test_build_path_item() {
371        let path_item = PathItem::new(PathItemType::Get, Operation::new())
372            .summary("summary")
373            .description("description")
374            .servers(Servers::new())
375            .parameters(Parameters::new());
376
377        assert_json_eq!(
378            path_item,
379            json!({
380                "description": "description",
381                "summary": "summary",
382                "get": {
383                    "responses": {}
384                }
385            })
386        )
387    }
388
389    #[test]
390    fn path_item_ref_serializes_as_dollar_ref() {
391        let item = PathItem::from_ref("#/components/pathItems/PingWebhook");
392        assert_json_eq!(
393            item,
394            json!({ "$ref": "#/components/pathItems/PingWebhook" })
395        );
396    }
397
398    #[test]
399    fn path_item_ref_round_trips_via_serde() {
400        let raw = json!({ "$ref": "#/components/pathItems/PingWebhook" });
401        let item: PathItem = serde_json::from_value(raw.clone()).expect("deserialize");
402        assert_eq!(
403            item.ref_location.as_deref(),
404            Some("#/components/pathItems/PingWebhook")
405        );
406        // Other fields are absent — sibling fields with $ref are spec-undefined, so we
407        // expect an otherwise empty PathItem.
408        assert!(item.summary.is_none());
409        assert!(item.operations.is_empty());
410
411        let reserialized = serde_json::to_value(&item).expect("serialize");
412        assert_eq!(reserialized, raw);
413    }
414
415    #[test]
416    fn path_item_ref_setter_overrides_plain_construction() {
417        let item = PathItem::new(PathItemType::Get, Operation::new())
418            .ref_location("#/components/pathItems/Other");
419
420        let value = serde_json::to_value(&item).expect("serialize");
421        assert_eq!(
422            value["$ref"],
423            json!("#/components/pathItems/Other"),
424            "$ref should serialize when set"
425        );
426        // The previously-attached operation is still there in this object form;
427        // consumers will resolve $ref and ignore siblings, but the type doesn't
428        // suppress them.
429        assert!(value.get("get").is_some());
430    }
431
432    #[test]
433    fn path_item_parameters_serialize_as_named_field() {
434        use crate::Parameter;
435
436        let path_item = PathItem::new(PathItemType::Get, Operation::new())
437            .parameters([Parameter::new("id").location(crate::ParameterIn::Path)]);
438
439        assert_json_eq!(
440            path_item,
441            json!({
442                "parameters": [
443                    {
444                        "name": "id",
445                        "in": "path",
446                        "required": true
447                    }
448                ],
449                "get": {
450                    "responses": {}
451                }
452            })
453        );
454    }
455
456    #[test]
457    fn test_path_item_append() {
458        let mut path_item = PathItem::new(
459            PathItemType::Get,
460            Operation::new().add_response("200", Response::new("Get success")),
461        );
462        let mut other_path_item = PathItem::new(
463            PathItemType::Post,
464            Operation::new().add_response("200", Response::new("Post success")),
465        )
466        .description("description")
467        .summary("summary");
468        path_item.append(&mut other_path_item);
469
470        assert_json_eq!(
471            path_item,
472            json!({
473                "description": "description",
474                "summary": "summary",
475                "get": {
476                    "responses": {
477                        "200": {
478                            "description": "Get success"
479                        }
480                    }
481                },
482                "post": {
483                    "responses": {
484                        "200": {
485                            "description": "Post success"
486                        }
487                    }
488                }
489            })
490        )
491    }
492
493    #[test]
494    fn test_path_item_add_operation() {
495        let path_item = PathItem::new(
496            PathItemType::Get,
497            Operation::new().add_response("200", Response::new("Get success")),
498        )
499        .add_operation(
500            PathItemType::Post,
501            Operation::new().add_response("200", Response::new("Post success")),
502        );
503
504        assert_json_eq!(
505            path_item,
506            json!({
507                "get": {
508                    "responses": {
509                        "200": {
510                            "description": "Get success"
511                        }
512                    }
513                },
514                "post": {
515                    "responses": {
516                        "200": {
517                            "description": "Post success"
518                        }
519                    }
520                }
521            })
522        )
523    }
524
525    #[test]
526    fn test_paths_extend() {
527        let mut paths = Paths::new().path(
528            "/api/do_something",
529            PathItem::new(
530                PathItemType::Get,
531                Operation::new().add_response("200", Response::new("Get success")),
532            ),
533        );
534        paths.extend([(
535            "/api/do_something",
536            PathItem::new(
537                PathItemType::Post,
538                Operation::new().add_response("200", Response::new("Post success")),
539            )
540            .summary("summary")
541            .description("description"),
542        )]);
543
544        assert_json_eq!(
545            paths,
546            json!({
547                "/api/do_something": {
548                    "description": "description",
549                    "summary": "summary",
550                    "get": {
551                        "responses": {
552                            "200": {
553                                "description": "Get success"
554                            }
555                        }
556                    },
557                    "post": {
558                        "responses": {
559                            "200": {
560                                "description": "Post success"
561                            }
562                        }
563                    }
564                }
565            })
566        );
567    }
568
569    #[test]
570    fn path_item_query_operation_serializes_under_query_key() {
571        let path_item = PathItem::new(PathItemType::Query, Operation::new());
572        assert_json_eq!(path_item, json!({ "query": { "responses": {} } }));
573
574        let parsed: PathItem =
575            serde_json::from_value(json!({ "query": { "responses": {} } })).expect("deserialize");
576        assert!(parsed.operations.contains_key(&PathItemType::Query));
577    }
578
579    #[test]
580    fn path_item_additional_operations_round_trip() {
581        let path_item = PathItem::new(PathItemType::Get, Operation::new())
582            .add_additional_operation("PURGE", Operation::new());
583
584        let value = serde_json::to_value(&path_item).expect("serialize");
585        assert_json_eq!(
586            &value,
587            json!({
588                "get": { "responses": {} },
589                "additionalOperations": { "PURGE": { "responses": {} } }
590            })
591        );
592
593        let parsed: PathItem = serde_json::from_value(value).expect("deserialize");
594        assert!(parsed.operations.contains_key(&PathItemType::Get));
595        assert!(parsed.additional_operations.contains_key("PURGE"));
596        assert_eq!(parsed, path_item);
597    }
598
599    #[test]
600    fn path_item_deserialize_keeps_operations_out_of_extensions() {
601        let raw = json!({
602            "summary": "summary",
603            "get": { "responses": {} },
604            "additionalOperations": { "PURGE": { "responses": {} } },
605            "x-internal": true
606        });
607
608        let item: PathItem = serde_json::from_value(raw.clone()).expect("deserialize");
609        assert!(item.operations.contains_key(&PathItemType::Get));
610        assert!(item.additional_operations.contains_key("PURGE"));
611        assert_eq!(item.extensions.len(), 1);
612        assert_eq!(item.extensions.get("x-internal"), Some(&json!(true)));
613
614        // Re-serializing must not emit `get` twice.
615        assert_json_eq!(serde_json::to_value(&item).expect("serialize"), raw);
616    }
617
618    #[test]
619    fn path_item_append_merges_additional_operations() {
620        let mut path_item = PathItem::default().add_additional_operation("PURGE", Operation::new());
621        let mut other = PathItem::default().add_additional_operation("LINK", Operation::new());
622        path_item.append(&mut other);
623
624        assert!(path_item.additional_operations.contains_key("PURGE"));
625        assert!(path_item.additional_operations.contains_key("LINK"));
626    }
627
628    #[test]
629    fn test_paths_deref() {
630        let paths = Paths::new();
631        assert_eq!(0, paths.len());
632    }
633}