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}