Skip to main content

salvo_oapi/
routing.rs

1use std::any::TypeId;
2use std::collections::{BTreeSet, HashMap};
3use std::sync::{LazyLock, RwLock};
4
5use salvo_core::Router;
6use salvo_core::http::Method;
7use salvo_core::routing::FilterInfo;
8
9use crate::SecurityRequirement;
10use crate::path::PathItemType;
11
12fn normalize_oapi_path(path: &str) -> String {
13    let mut normalized = String::with_capacity(path.len());
14    let mut chars = path.char_indices().peekable();
15
16    while let Some((start, ch)) = chars.next() {
17        if ch != '{' {
18            normalized.push(ch);
19            continue;
20        }
21        // Keep escaped literal braces (`{{`) as-is.
22        if chars.peek().map(|(_, next)| *next) == Some('{') {
23            normalized.push('{');
24            normalized.push('{');
25            chars.next();
26            continue;
27        }
28
29        let content_start = start + ch.len_utf8();
30        let mut braces_depth = 0usize;
31        let mut escaping = false;
32        let mut param_end = None;
33
34        for (idx, current) in chars.by_ref() {
35            if escaping {
36                escaping = false;
37                continue;
38            }
39            match current {
40                '\\' => escaping = true,
41                '{' => braces_depth += 1,
42                '}' => {
43                    if braces_depth == 0 {
44                        param_end = Some(idx);
45                        break;
46                    }
47                    braces_depth -= 1;
48                }
49                _ => {}
50            }
51        }
52
53        if let Some(param_end) = param_end {
54            let Some(content) = path.get(content_start..param_end) else {
55                break;
56            };
57            if let Some(name_end) = content.find([':', '|']) {
58                normalized.push('{');
59                let Some(name) = content.get(..name_end) else {
60                    break;
61                };
62                normalized.push_str(name);
63                normalized.push('}');
64            } else {
65                normalized.push('{');
66                normalized.push_str(content);
67                normalized.push('}');
68            }
69        } else {
70            if let Some(rest) = path.get(start..) {
71                normalized.push_str(rest);
72            }
73            break;
74        }
75    }
76    normalized
77}
78
79#[derive(Debug, Default)]
80pub(crate) struct NormNode {
81    // pub(crate) router_id: usize,
82    pub(crate) handler_type_id: Option<TypeId>,
83    pub(crate) handler_type_name: Option<&'static str>,
84    pub(crate) method: Option<PathItemType>,
85    pub(crate) path: Option<String>,
86    pub(crate) children: Vec<Self>,
87    pub(crate) metadata: Metadata,
88}
89
90impl NormNode {
91    pub(crate) fn new(router: &Router, inherited_metadata: Metadata) -> Self {
92        let mut node = Self {
93            // router_id: router.id,
94            metadata: inherited_metadata,
95            ..Self::default()
96        };
97        let registry = METADATA_REGISTRY
98            .read()
99            .expect("failed to lock METADATA_REGISTRY for read");
100        if let Some(metadata) = registry.get(&router.id) {
101            node.metadata.tags.extend(metadata.tags.iter().cloned());
102            node.metadata
103                .securities
104                .extend(metadata.securities.iter().cloned());
105        }
106
107        for filter in router.filters() {
108            match filter.info() {
109                FilterInfo::Path(path) => {
110                    node.path = Some(normalize_oapi_path(&path));
111                }
112                FilterInfo::Method(method) => {
113                    // Only overwrite when the method maps to a known
114                    // `PathItemType`; unknown/extension methods leave any
115                    // previously recognized value in place so combining a
116                    // standard method filter with a custom one does not erase
117                    // the standard mapping (the previous string-parsing path
118                    // had the same behavior via its `_ => {}` arm).
119                    //
120                    // CONNECT is intentionally not mapped: OpenAPI 3.1 does
121                    // not define a `connect` operation under Path Item, so
122                    // emitting one would produce an invalid document.
123                    let item = match method {
124                        Method::GET => Some(PathItemType::Get),
125                        Method::POST => Some(PathItemType::Post),
126                        Method::PUT => Some(PathItemType::Put),
127                        Method::DELETE => Some(PathItemType::Delete),
128                        Method::HEAD => Some(PathItemType::Head),
129                        Method::OPTIONS => Some(PathItemType::Options),
130                        Method::TRACE => Some(PathItemType::Trace),
131                        Method::PATCH => Some(PathItemType::Patch),
132                        _ => None,
133                    };
134                    if item.is_some() {
135                        node.method = item;
136                    } else if method == Method::CONNECT {
137                        tracing::warn!(
138                            "HTTP CONNECT has no OpenAPI 3.1 mapping; the route will be \
139                             omitted from the generated document"
140                        );
141                    }
142                }
143                // Other filter kinds (Scheme/Host/Port/Other) do not carry
144                // information that maps to OpenAPI path items.
145                _ => {}
146            }
147        }
148        node.handler_type_id = router.goal.as_ref().map(|h| h.type_id());
149        node.handler_type_name = router.goal.as_ref().map(|h| h.type_name());
150        let routers = router.routers();
151        if !routers.is_empty() {
152            for router in routers {
153                node.children.push(Self::new(router, node.metadata.clone()));
154            }
155        }
156        node
157    }
158}
159
160/// A component for save router metadata.
161type MetadataMap = RwLock<HashMap<usize, Metadata>>;
162static METADATA_REGISTRY: LazyLock<MetadataMap> = LazyLock::new(MetadataMap::default);
163
164/// Router extension trait for openapi metadata.
165pub trait RouterExt {
166    /// Add security requirement to the router.
167    ///
168    /// All endpoints in the router and its descendants will inherit this security requirement.
169    #[must_use]
170    fn oapi_security(self, security: SecurityRequirement) -> Self;
171
172    /// Add security requirements to the router.
173    ///
174    /// All endpoints in the router and its descendants will inherit these security requirements.
175    #[must_use]
176    fn oapi_securities<I>(self, security: I) -> Self
177    where
178        I: IntoIterator<Item = SecurityRequirement>;
179
180    /// Add tag to the router.
181    ///
182    /// All endpoints in the router and its descendants will inherit this tag.
183    #[must_use]
184    fn oapi_tag(self, tag: impl Into<String>) -> Self;
185
186    /// Add tags to the router.
187    ///
188    /// All endpoints in the router and its descendants will inherit these tags.
189    #[must_use]
190    fn oapi_tags<I, V>(self, tags: I) -> Self
191    where
192        I: IntoIterator<Item = V>,
193        V: Into<String>;
194}
195
196impl RouterExt for Router {
197    fn oapi_security(self, security: SecurityRequirement) -> Self {
198        let mut guard = METADATA_REGISTRY
199            .write()
200            .expect("failed to lock METADATA_REGISTRY for write");
201        let metadata = guard.entry(self.id).or_default();
202        metadata.securities.push(security);
203        self
204    }
205    fn oapi_securities<I>(self, iter: I) -> Self
206    where
207        I: IntoIterator<Item = SecurityRequirement>,
208    {
209        let mut guard = METADATA_REGISTRY
210            .write()
211            .expect("failed to lock METADATA_REGISTRY for write");
212        let metadata = guard.entry(self.id).or_default();
213        metadata.securities.extend(iter);
214        self
215    }
216    fn oapi_tag(self, tag: impl Into<String>) -> Self {
217        let mut guard = METADATA_REGISTRY
218            .write()
219            .expect("failed to lock METADATA_REGISTRY for write");
220        let metadata = guard.entry(self.id).or_default();
221        metadata.tags.insert(tag.into());
222        self
223    }
224    fn oapi_tags<I, V>(self, iter: I) -> Self
225    where
226        I: IntoIterator<Item = V>,
227        V: Into<String>,
228    {
229        let mut guard = METADATA_REGISTRY
230            .write()
231            .expect("failed to lock METADATA_REGISTRY for write");
232        let metadata = guard.entry(self.id).or_default();
233        metadata.tags.extend(iter.into_iter().map(Into::into));
234        self
235    }
236}
237
238#[non_exhaustive]
239#[derive(Default, Clone, Debug)]
240pub(crate) struct Metadata {
241    pub(crate) tags: BTreeSet<String>,
242    pub(crate) securities: Vec<SecurityRequirement>,
243}
244
245#[cfg(test)]
246mod tests {
247    use super::normalize_oapi_path;
248
249    #[test]
250    fn normalize_braced_path_constraints() {
251        assert_eq!(normalize_oapi_path("/posts/{id}"), "/posts/{id}");
252        assert_eq!(normalize_oapi_path("/posts/{id:num}"), "/posts/{id}");
253        assert_eq!(
254            normalize_oapi_path("/posts/{id:num(3..=10)}"),
255            "/posts/{id}"
256        );
257        assert_eq!(normalize_oapi_path(r"/posts/{id|\d+}"), "/posts/{id}");
258        assert_eq!(normalize_oapi_path("/posts/{id|[a-z]{2}}"), "/posts/{id}");
259        assert_eq!(
260            normalize_oapi_path("/posts/article_{id:num}"),
261            "/posts/article_{id}"
262        );
263    }
264}