1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use camel_component_api::CamelError;
use tokio::sync::{RwLock, mpsc};
use tokio_util::sync::CancellationToken;
use tower_http::services::ServeDir;
use crate::RequestEnvelope;
use crate::rest_match::RestEndpoint;
/// Discriminates between a plain static file mount and
/// a mount that also performs SPA‑style fallback to index.html.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountMode {
Static,
Spa,
}
#[allow(dead_code)]
pub struct StaticMount {
pub mount_path: String,
pub mode: MountMode,
pub dir: PathBuf,
pub cache_control: String,
pub error_pages: HashMap<u16, PathBuf>,
pub serve_dir: ServeDir,
}
impl std::fmt::Debug for StaticMount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StaticMount")
.field("mount_path", &self.mount_path)
.field("mode", &self.mode)
.field("dir", &self.dir)
.field("cache_control", &self.cache_control)
.field("error_pages", &self.error_pages)
.finish_non_exhaustive()
}
}
pub(crate) struct HttpRouteRegistryInner {
/// Legacy path-keyed API route registry. Used for `http:` routes
/// registered without an `httpMethod=` URI param. Multiple routes on
/// the same path overwrite each other here.
pub api_routes: HashMap<String, mpsc::Sender<RequestEnvelope>>,
/// Method-aware REST endpoint registry. Populated by REST-lowered
/// `http:` routes (those whose URI carries `httpMethod=...`). Allows
/// GET and POST on the same path to coexist, and supports path
/// templates like `/users/{id}`. Per plan expert guidance E1.
pub rest_endpoints: Vec<RestEndpoint<mpsc::Sender<RequestEnvelope>>>,
pub mounts: Vec<StaticMount>,
}
impl std::fmt::Debug for HttpRouteRegistryInner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpRouteRegistryInner")
.field("api_routes", &self.api_routes.keys())
.field("rest_endpoints", &self.rest_endpoints.len())
.field("mounts", &self.mounts.len())
.finish()
}
}
#[derive(Clone)]
pub struct HttpRouteRegistry {
pub(crate) inner: Arc<RwLock<HttpRouteRegistryInner>>,
/// Cancelled by `monitor_axum_task` when the shared Axum server task
/// backing this registry exits unexpectedly (panic or abort). Every
/// `HttpConsumer` hosted on that server selects on this token in its
/// `start()` loop; on cancellation the consumer returns `Err`, which
/// camel-core's consumer watcher converts into a per-route
/// `CrashNotification` → `FailRoute` → supervision backoff restart
/// (ADR-0007 route-supervised contract — shared transports must fail
/// their hosted routes like per-route transports do).
///
/// Registries constructed directly via [`HttpRouteRegistry::new`]
/// (tests, embedding) carry a token that is never cancelled — their
/// consumers keep the pre-fix semantics.
pub(crate) server_exited: CancellationToken,
}
impl std::fmt::Debug for HttpRouteRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpRouteRegistry").finish_non_exhaustive()
}
}
impl Default for HttpRouteRegistry {
fn default() -> Self {
Self::new()
}
}
impl HttpRouteRegistry {
pub fn new() -> Self {
Self::new_with_server_exited(CancellationToken::new())
}
/// Crate-private constructor used by `spawn_entry`: binds the registry
/// to the death signal of the shared server that serves it.
pub(crate) fn new_with_server_exited(server_exited: CancellationToken) -> Self {
Self {
inner: Arc::new(RwLock::new(HttpRouteRegistryInner {
api_routes: HashMap::new(),
rest_endpoints: Vec::new(),
mounts: Vec::new(),
})),
server_exited,
}
}
pub async fn register_api_route(&self, path: String, sender: mpsc::Sender<RequestEnvelope>) {
let mut inner = self.inner.write().await;
inner.api_routes.insert(path, sender);
}
pub async fn unregister_api_route(&self, path: &str) {
let mut inner = self.inner.write().await;
inner.api_routes.remove(path);
}
/// Register a method-aware REST endpoint. Two endpoints on the same
/// path with different methods coexist; two endpoints with the same
/// `(method, path)` overwrite (last write wins), matching the
/// semantics expected for re-registration of the same logical route.
pub async fn register_rest_endpoint(
&self,
method: String,
segments: Vec<crate::rest_match::PathSegment>,
sender: mpsc::Sender<RequestEnvelope>,
) {
let mut inner = self.inner.write().await;
// Drop any prior endpoint with the same (method, segments)
// signature so the new sender wins.
inner
.rest_endpoints
.retain(|ep| !(ep.method == method && ep.segments == segments));
inner.rest_endpoints.push(RestEndpoint {
method,
segments,
payload: sender,
});
}
/// Remove the REST endpoint matching `(method, path_template)`.
///
/// Only the endpoint with the SAME method AND segments is removed —
/// other HTTP methods sharing the path template are preserved. Stopping
/// the `GET /users` consumer must not tear down the live `POST /users`
/// endpoint (the core REST multi-verb use case). Fixes review C1.
pub async fn unregister_rest_endpoint(&self, method: &str, path_template: &str) {
let target_segments = crate::rest_match::parse_path_template(path_template);
let mut inner = self.inner.write().await;
inner.rest_endpoints.retain(|ep| {
// Match BOTH method and segments: a sibling verb on the same
// path template stays registered.
!(ep.method == method && ep.segments == target_segments)
});
}
/// Register a static mount. Duplicate detection is by `mount_path`
/// only — every mount on a given port must have a unique prefix.
///
/// A single SPA mount is still the convention, but it is no longer
/// enforced structurally; the dispatch loop treats all mounts
/// uniformly (sorted by longest prefix) and uses `mode` to decide
/// whether to attempt SPA-fallback after ServeDir fails.
#[allow(dead_code)]
pub async fn register_static_mount(&self, mount: StaticMount) -> Result<(), CamelError> {
let mut inner = self.inner.write().await;
if inner
.mounts
.iter()
.any(|m| m.mount_path == mount.mount_path)
{
return Err(CamelError::Config(format!(
"duplicate static mount path '{}' on this port",
mount.mount_path
)));
}
inner.mounts.push(mount);
Ok(())
}
/// Unregister a static mount by its unique `mount_path`.
#[allow(dead_code)]
pub async fn unregister_static_mount(&self, mount_path: &str) {
let mut inner = self.inner.write().await;
inner.mounts.retain(|m| m.mount_path != mount_path);
}
}