Skip to main content

coil_runtime/http/routing/
model.rs

1use std::collections::BTreeMap;
2
3use coil_config::PlatformConfig;
4use coil_core::ModuleManifest;
5
6use super::error::{
7    RouteBuildError, RouteUrlError, validate_host, validate_route_name, validate_route_path,
8};
9use super::matching::{match_route_path, render_route_path};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub enum HttpMethod {
13    Get,
14    Head,
15    Post,
16    Put,
17    Patch,
18    Delete,
19}
20
21impl HttpMethod {
22    pub const fn is_state_changing(self) -> bool {
23        matches!(self, Self::Post | Self::Put | Self::Patch | Self::Delete)
24    }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum RouteArea {
29    Public,
30    Account,
31    Admin,
32    Api,
33    Fragment,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum HostPattern {
38    Any,
39    Exact(String),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum LocalePolicy {
44    DefaultOnly,
45    Localized,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum RouteAuthGate {
50    Public,
51    Session,
52    Capability(coil_auth::Capability),
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct RouteDefinition {
57    pub name: String,
58    pub method: HttpMethod,
59    pub path: String,
60    pub area: RouteArea,
61    pub host: HostPattern,
62    pub locale_policy: LocalePolicy,
63    pub auth: RouteAuthGate,
64    pub module: Option<String>,
65    pub feature_flag: Option<String>,
66}
67
68impl RouteDefinition {
69    pub fn new(
70        name: impl Into<String>,
71        method: HttpMethod,
72        path: impl Into<String>,
73    ) -> Result<Self, RouteBuildError> {
74        let name = validate_route_name(name.into())?;
75        let path = validate_route_path(path.into())?;
76
77        Ok(Self {
78            name,
79            method,
80            path,
81            area: RouteArea::Public,
82            host: HostPattern::Any,
83            locale_policy: LocalePolicy::DefaultOnly,
84            auth: RouteAuthGate::Public,
85            module: None,
86            feature_flag: None,
87        })
88    }
89
90    pub fn with_area(mut self, area: RouteArea) -> Self {
91        self.area = area;
92        self
93    }
94
95    pub fn with_host(mut self, host: impl Into<String>) -> Result<Self, RouteBuildError> {
96        self.host = HostPattern::Exact(validate_host(host.into())?);
97        Ok(self)
98    }
99
100    pub fn localized(mut self) -> Self {
101        self.locale_policy = LocalePolicy::Localized;
102        self
103    }
104
105    pub fn requiring_session(mut self) -> Self {
106        self.auth = RouteAuthGate::Session;
107        self
108    }
109
110    pub fn requiring_capability(mut self, capability: coil_auth::Capability) -> Self {
111        self.auth = RouteAuthGate::Capability(capability);
112        self
113    }
114
115    pub fn from_module(mut self, module: impl Into<String>) -> Self {
116        self.module = Some(module.into());
117        self
118    }
119
120    pub fn with_feature_flag(mut self, feature_flag: impl Into<String>) -> Self {
121        self.feature_flag = Some(feature_flag.into());
122        self
123    }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum MiddlewareStage {
128    TransportNormalization,
129    CustomerAppResolution,
130    TraceContext,
131    LocaleResolution,
132    SessionResolution,
133    BrowserPolicy,
134    ResponsePolicy,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct HttpRuntimePlan {
139    pub middleware: Vec<MiddlewareStage>,
140    pub routes: Vec<RouteDefinition>,
141}
142
143impl HttpRuntimePlan {
144    pub fn resolve(
145        &self,
146        config: &PlatformConfig,
147        method: HttpMethod,
148        host: &str,
149        path: &str,
150    ) -> Option<ResolvedRoute> {
151        self.resolve_match(config, method, host, path)
152            .map(|matched| matched.resolved)
153    }
154
155    pub fn resolve_match(
156        &self,
157        config: &PlatformConfig,
158        method: HttpMethod,
159        host: &str,
160        path: &str,
161    ) -> Option<ResolvedRouteMatch> {
162        let site = config.site_for_host(host);
163        let site_id = site.map(|site| site.id.clone());
164        let default_locale = config.default_locale_for_site(site_id.as_deref());
165        let supported_locales = site
166            .map(|site| site.supported_locales.as_slice())
167            .unwrap_or(config.i18n.supported_locales.as_slice());
168        let localized_routes = site
169            .and_then(|site| site.localized_routes)
170            .unwrap_or(config.i18n.localized_routes);
171        self.routes.iter().find_map(|route| {
172            if route.method != method {
173                return None;
174            }
175
176            if let HostPattern::Exact(expected) = &route.host {
177                if expected != host {
178                    return None;
179                }
180            }
181
182            match route.locale_policy {
183                LocalePolicy::DefaultOnly => match match_route_path(&route.path, path) {
184                    Some(params) => Some(ResolvedRouteMatch {
185                        route: route.clone(),
186                        resolved: ResolvedRoute {
187                            route_name: route.name.clone(),
188                            site_id: site_id.clone(),
189                            locale: None,
190                            auth: route.auth,
191                            params,
192                        },
193                    }),
194                    None => None,
195                },
196                LocalePolicy::Localized if localized_routes => {
197                    if route.path == "/" && path == "/" {
198                        return Some(ResolvedRouteMatch {
199                            route: route.clone(),
200                            resolved: ResolvedRoute {
201                                route_name: route.name.clone(),
202                                site_id: site_id.clone(),
203                                locale: Some(default_locale.to_string()),
204                                auth: route.auth,
205                                params: BTreeMap::new(),
206                            },
207                        });
208                    }
209
210                    supported_locales.iter().find_map(|locale| {
211                        if route.path == "/" {
212                            let localized_root = format!("/{}", locale.trim_matches('/'));
213                            if path == localized_root || path == format!("{localized_root}/") {
214                                return Some(ResolvedRouteMatch {
215                                    route: route.clone(),
216                                    resolved: ResolvedRoute {
217                                        route_name: route.name.clone(),
218                                        site_id: site_id.clone(),
219                                        locale: Some(locale.clone()),
220                                        auth: route.auth,
221                                        params: BTreeMap::new(),
222                                    },
223                                });
224                            }
225                            return None;
226                        }
227
228                        let localized_path = format!(
229                            "/{}/{}",
230                            locale.trim_matches('/'),
231                            route.path.trim_start_matches('/')
232                        );
233                        match_route_path(&localized_path, path).map(|params| ResolvedRouteMatch {
234                            route: route.clone(),
235                            resolved: ResolvedRoute {
236                                route_name: route.name.clone(),
237                                site_id: site_id.clone(),
238                                locale: Some(locale.clone()),
239                                auth: route.auth,
240                                params,
241                            },
242                        })
243                    })
244                }
245                LocalePolicy::Localized => None,
246            }
247        })
248    }
249
250    pub fn path_for(
251        &self,
252        config: &PlatformConfig,
253        route_name: &str,
254        params: &BTreeMap<String, String>,
255        locale: Option<&str>,
256    ) -> Result<String, RouteUrlError> {
257        self.path_for_site(config, None, route_name, params, locale)
258    }
259
260    pub fn path_for_site(
261        &self,
262        config: &PlatformConfig,
263        site_id: Option<&str>,
264        route_name: &str,
265        params: &BTreeMap<String, String>,
266        locale: Option<&str>,
267    ) -> Result<String, RouteUrlError> {
268        let route = self
269            .routes
270            .iter()
271            .find(|route| route.name == route_name)
272            .ok_or_else(|| RouteUrlError::UnknownRoute {
273                route: route_name.to_string(),
274            })?;
275        let rendered_path = render_route_path(&route.path, params, route_name)?;
276
277        if route.locale_policy == LocalePolicy::Localized {
278            let locale = locale.unwrap_or(config.default_locale_for_site(site_id));
279            if !config
280                .supported_locales_for_site(site_id)
281                .iter()
282                .any(|item| item == locale)
283            {
284                return Err(RouteUrlError::UnsupportedLocale {
285                    route: route_name.to_string(),
286                    locale: locale.to_string(),
287                });
288            }
289
290            if rendered_path == "/" {
291                if locale == config.default_locale_for_site(site_id) {
292                    return Ok("/".to_string());
293                }
294                return Ok(format!("/{}", locale.trim_matches('/')));
295            }
296
297            return Ok(format!(
298                "/{}/{}",
299                locale.trim_matches('/'),
300                rendered_path.trim_start_matches('/')
301            ));
302        }
303
304        Ok(rendered_path)
305    }
306
307    pub fn absolute_url_for(
308        &self,
309        config: &PlatformConfig,
310        route_name: &str,
311        params: &BTreeMap<String, String>,
312        locale: Option<&str>,
313    ) -> Result<String, RouteUrlError> {
314        self.absolute_url_for_site(config, None, route_name, params, locale)
315    }
316
317    pub fn absolute_url_for_site(
318        &self,
319        config: &PlatformConfig,
320        site_id: Option<&str>,
321        route_name: &str,
322        params: &BTreeMap<String, String>,
323        locale: Option<&str>,
324    ) -> Result<String, RouteUrlError> {
325        let route = self
326            .routes
327            .iter()
328            .find(|route| route.name == route_name)
329            .ok_or_else(|| RouteUrlError::UnknownRoute {
330                route: route_name.to_string(),
331            })?;
332        let path = self.path_for_site(config, site_id, route_name, params, locale)?;
333        let host = match &route.host {
334            HostPattern::Exact(host) => host.as_str(),
335            HostPattern::Any => config.canonical_host_for_site(site_id),
336        };
337        Ok(format!("https://{host}{path}"))
338    }
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct ResolvedRoute {
343    pub route_name: String,
344    pub site_id: Option<String>,
345    pub locale: Option<String>,
346    pub auth: RouteAuthGate,
347    pub params: BTreeMap<String, String>,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct ResolvedRouteMatch {
352    pub route: RouteDefinition,
353    pub resolved: ResolvedRoute,
354}
355
356impl ResolvedRoute {
357    pub fn capability_auth_resource<P>(
358        &self,
359        route: &RouteDefinition,
360        module_manifest: Option<&ModuleManifest>,
361        package: &P,
362    ) -> Result<Option<coil_auth::Entity>, coil_auth::CoilAuthError>
363    where
364        P: coil_auth::AuthModelPackage + ?Sized,
365    {
366        let RouteAuthGate::Capability(capability) = self.auth else {
367            return Ok(None);
368        };
369
370        let binding = package
371            .binding_for(capability)
372            .ok_or(coil_auth::CoilAuthError::MissingCapabilityBinding { capability })?;
373        let namespace = binding
374            .resource_namespaces
375            .first()
376            .copied()
377            .expect("route capability bindings must expose at least one namespace");
378        let contract_kind = module_manifest
379            .and_then(|manifest| {
380                manifest
381                    .capability_contracts
382                    .iter()
383                    .find(|contract| contract.capability == capability)
384            })
385            .and_then(|contract| contract.resource_kinds.first())
386            .map(String::as_str);
387
388        Ok(Some(super::resolution::route_capability_resource(
389            namespace,
390            route.module.as_deref(),
391            contract_kind,
392            &self.route_name,
393        )))
394    }
395}