Skip to main content

webui_desktop/
routes.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use std::sync::Arc;
5
6use percent_encoding::percent_decode_str;
7use serde_json::Value;
8
9use crate::{
10    DesktopError, DesktopHttpMethod, DesktopProtocolRequest, DesktopProtocolResponse, Result,
11};
12
13type RouteStateHandler = dyn Fn(RouteContext<'_>) -> Result<Value> + Send + Sync;
14type ApiHandler = dyn Fn(ApiContext<'_>) -> Result<DesktopProtocolResponse> + Send + Sync;
15
16/// Registry of Rust route state providers.
17#[derive(Default)]
18pub struct RouteStateRegistry {
19    routes: Vec<RouteStateEntry>,
20}
21
22/// Registry of Rust custom-protocol API handlers.
23#[derive(Default)]
24pub struct ApiRouteRegistry {
25    routes: Vec<ApiRouteEntry>,
26}
27
28impl ApiRouteRegistry {
29    /// Create an empty API route registry.
30    #[must_use]
31    pub fn new() -> Self {
32        Self { routes: Vec::new() }
33    }
34
35    /// Register an API handler for a URL path pattern.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`DesktopError`] if `pattern` is invalid.
40    pub fn route<F>(&mut self, pattern: impl AsRef<str>, handler: F) -> Result<()>
41    where
42        F: Fn(ApiContext<'_>) -> Result<DesktopProtocolResponse> + Send + Sync + 'static,
43    {
44        self.routes.push(ApiRouteEntry {
45            pattern: RoutePattern::parse(pattern.as_ref())?,
46            handler: Arc::new(handler),
47        });
48        Ok(())
49    }
50
51    pub(crate) fn resolve(
52        &self,
53        request: &DesktopProtocolRequest<'_>,
54    ) -> Result<Option<DesktopProtocolResponse>> {
55        let path = route_path(request.path);
56        for entry in &self.routes {
57            let Some(params) = entry.pattern.matches(path) else {
58                continue;
59            };
60            return (entry.handler)(ApiContext {
61                method: &request.method,
62                path,
63                params: &params,
64                body: request.body,
65            })
66            .map(Some);
67        }
68        Ok(None)
69    }
70}
71
72struct ApiRouteEntry {
73    pattern: RoutePattern,
74    handler: Arc<ApiHandler>,
75}
76
77impl RouteStateRegistry {
78    pub(crate) fn has_provider(&self, path: &str) -> bool {
79        self.routes
80            .iter()
81            .any(|entry| entry.pattern.matches(path).is_some())
82    }
83
84    /// Create an empty route state registry.
85    #[must_use]
86    pub fn new() -> Self {
87        Self { routes: Vec::new() }
88    }
89
90    /// Register a route state provider.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`DesktopError`] if `pattern` is invalid.
95    pub fn route<F>(&mut self, pattern: impl AsRef<str>, handler: F) -> Result<()>
96    where
97        F: Fn(RouteContext<'_>) -> Result<Value> + Send + Sync + 'static,
98    {
99        self.routes.push(RouteStateEntry {
100            pattern: RoutePattern::parse(pattern.as_ref())?,
101            handler: Arc::new(handler),
102        });
103        Ok(())
104    }
105
106    pub(crate) fn resolve(&self, path: &str, base_state: &Value) -> Result<Option<Value>> {
107        for entry in &self.routes {
108            let Some(params) = entry.pattern.matches(path) else {
109                continue;
110            };
111            return (entry.handler)(RouteContext {
112                path,
113                params: &params,
114                base_state,
115            })
116            .map(Some)
117            .map_err(|err| DesktopError::RouteProvider {
118                path: path.to_string(),
119                message: err.chain_message(),
120            });
121        }
122        Ok(None)
123    }
124}
125
126struct RouteStateEntry {
127    pattern: RoutePattern,
128    handler: Arc<RouteStateHandler>,
129}
130
131struct RoutePattern {
132    segments: Vec<RouteSegment>,
133}
134
135enum RouteSegment {
136    Literal(String),
137    Param(String),
138}
139
140/// Context passed to Rust route state providers.
141pub struct RouteContext<'a> {
142    /// Request path without query string.
143    pub path: &'a str,
144    params: &'a [(String, String)],
145    /// File-backed base state loaded by the desktop runtime.
146    pub base_state: &'a Value,
147}
148
149/// Context passed to Rust custom-protocol API handlers.
150pub struct ApiContext<'a> {
151    /// Request method.
152    pub method: &'a DesktopHttpMethod,
153    /// Request path without query string.
154    pub path: &'a str,
155    params: &'a [(String, String)],
156    /// Request body bytes.
157    pub body: &'a [u8],
158}
159
160impl ApiContext<'_> {
161    /// Return a route parameter by name.
162    #[must_use]
163    pub fn param(&self, name: &str) -> Option<&str> {
164        self.params
165            .iter()
166            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
167    }
168}
169
170impl RouteContext<'_> {
171    /// Return a route parameter by name.
172    #[must_use]
173    pub fn param(&self, name: &str) -> Option<&str> {
174        self.params
175            .iter()
176            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
177    }
178}
179
180impl RoutePattern {
181    fn parse(pattern: &str) -> Result<Self> {
182        if !pattern.starts_with('/') {
183            return Err(DesktopError::InvalidRoutePattern {
184                pattern: pattern.to_string(),
185                help: "desktop route patterns must start with '/', e.g. /contacts/:id".to_string(),
186            });
187        }
188        let trimmed = pattern.trim_matches('/');
189        let mut segments = Vec::new();
190        if !trimmed.is_empty() {
191            for segment in trimmed.split('/') {
192                if segment.is_empty() || segment == "." || segment == ".." {
193                    return Err(DesktopError::InvalidRoutePattern {
194                        pattern: pattern.to_string(),
195                        help: "route pattern segments cannot be empty, '.', or '..'".to_string(),
196                    });
197                }
198                if let Some(param) = segment.strip_prefix(':') {
199                    if param.is_empty() {
200                        return Err(DesktopError::InvalidRoutePattern {
201                            pattern: pattern.to_string(),
202                            help: "route parameter names cannot be empty".to_string(),
203                        });
204                    }
205                    segments.push(RouteSegment::Param(param.to_string()));
206                } else {
207                    segments.push(RouteSegment::Literal(segment.to_string()));
208                }
209            }
210        }
211        Ok(Self { segments })
212    }
213
214    fn matches(&self, path: &str) -> Option<Vec<(String, String)>> {
215        let trimmed = route_path(path).trim_matches('/');
216        if trimmed.is_empty() {
217            return self.segments.is_empty().then(Vec::new);
218        }
219        if trimmed.split('/').count() != self.segments.len() {
220            return None;
221        }
222        let mut params = Vec::new();
223        for (pattern, raw_segment) in self.segments.iter().zip(trimmed.split('/')) {
224            let segment = percent_decode_str(raw_segment)
225                .decode_utf8()
226                .map(|value| value.into_owned())
227                .unwrap_or_else(|_| raw_segment.to_string());
228            match pattern {
229                RouteSegment::Literal(expected) if expected == &segment => {}
230                RouteSegment::Literal(_) => return None,
231                RouteSegment::Param(name) => params.push((name.clone(), segment)),
232            }
233        }
234        Some(params)
235    }
236}
237
238pub(crate) fn route_path(request_path: &str) -> &str {
239    request_path
240        .split_once('?')
241        .map_or(request_path, |(path, _)| path)
242}