Skip to main content

majordomus_cli/http/
surfaces.rs

1//! What this process serves, resolved once: the topology narrowed to this router, with a
2//! handler bound to every surface in it.
3//!
4//! The router does not name surfaces. It asks this table who owns a path, and the table was
5//! built from the resolved topology — so a static surface that a producer declared is
6//! served because it was discovered, and nothing is served that was not.
7//!
8//! A native surface is the one thing a topology cannot supply on its own: a path the
9//! executable answers needs code behind it. That binding lives here, in one match, and a
10//! native surface with no arm refuses the router at construction rather than answering 404
11//! at run time. `every_native_surface_has_a_handler` is the test that keeps the two halves
12//! of that match in step.
13
14use std::collections::BTreeMap;
15use std::path::Path;
16use std::sync::Arc;
17
18use crate::error::{Error, Result};
19use crate::web::discover::{self, Runtime};
20use crate::web::files::Files;
21use crate::web::model::{Surface, SurfaceKind, Topology};
22use crate::web::validate::{self, Artifacts};
23
24/// The code behind a route the executable answers itself.
25///
26/// One value per native surface, matched from its declared id. The variants are what this
27/// executable can do, not what a path is called: renaming a mount changes the declaration
28/// and nothing here.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Native {
31    /// The page this process is entered through.
32    Home,
33    /// The capability registry's own routes.
34    Api,
35    /// The OpenAPI document.
36    OpenApi,
37    /// The Swagger UI shell.
38    Swagger,
39    /// MCP over HTTP.
40    Mcp,
41    /// The Cockpit.
42    Cockpit,
43    /// The UI: `majordomus ui`, the primary surface (ADR 0135).
44    Ui,
45    /// The live channel over which executions report what they are doing.
46    Events,
47}
48
49impl Native {
50    /// The handler a native surface's id binds to, when this executable has one.
51    pub fn of(id: &str) -> Option<Native> {
52        Some(match id {
53            discover::HOME => Native::Home,
54            "api" => Native::Api,
55            "openapi" => Native::OpenApi,
56            "swagger" => Native::Swagger,
57            "mcp" => Native::Mcp,
58            "cockpit" => Native::Cockpit,
59            crate::ui::SURFACE => Native::Ui,
60            "events" => Native::Events,
61            _ => return None,
62        })
63    }
64}
65
66/// What a resolved surface is answered by.
67#[derive(Debug)]
68pub enum Bound {
69    /// A route this executable answers.
70    Route(Native),
71    /// A directory a producer generated. Boxed: a static surface carries its resolved root
72    /// and its cache, and a table of them should not pay that for the routes beside them.
73    Directory(Box<Files>),
74}
75
76/// The surfaces one router serves, with what answers each.
77#[derive(Debug)]
78pub struct Served {
79    topology: Topology,
80    bound: BTreeMap<String, Bound>,
81}
82
83impl Served {
84    /// Narrow `topology` to what a process with these runtime capabilities serves, bind a
85    /// handler to every surface in it, and refuse anything that cannot be served coherently.
86    ///
87    /// The refusal is the point: a collision, a nested mount or a native surface with no
88    /// handler stops the router being built, so it is found by whoever changed a
89    /// declaration rather than by whoever sent the first request.
90    pub fn resolve(topology: &Topology, root: &Path, runtime: Runtime) -> Result<Self> {
91        let topology = topology.served(runtime);
92        let findings = validate::validate(&topology, root, Artifacts::Ignore);
93        if validate::blocking(&findings) {
94            let reasons: Vec<String> = findings
95                .iter()
96                .filter(|f| f.severity == validate::Severity::Error)
97                .map(|f| format!("{} ({}): {}", f.rule, f.surface, f.message))
98                .collect();
99            return Err(Error::InvalidSurface {
100                surface: "topology".into(),
101                reason: format!(
102                    "this process cannot serve the surfaces it discovered: {}; `majordomus web validate` reports the same findings with their remedies",
103                    reasons.join("; ")
104                ),
105            });
106        }
107        let mut bound = BTreeMap::new();
108        for surface in &topology.surfaces {
109            let handler = match surface.kind {
110                SurfaceKind::StaticDirectory => {
111                    Bound::Directory(Box::new(Files::new(surface, root)))
112                }
113                SurfaceKind::NativeRoute => {
114                    let Some(native) = Native::of(&surface.id) else {
115                        return Err(Error::InvalidSurface {
116                            surface: surface.id.clone(),
117                            reason: format!(
118                                "is declared as a route this executable answers and no handler is bound to it; add an arm to http::surfaces::Native::of, or declare {} as a generated directory",
119                                surface.id
120                            ),
121                        });
122                    };
123                    Bound::Route(native)
124                }
125                SurfaceKind::Redirect => {
126                    return Err(Error::InvalidSurface {
127                        surface: surface.id.clone(),
128                        reason: "a redirect surface is declared and this executable serves none"
129                            .into(),
130                    })
131                }
132            };
133            bound.insert(surface.id.clone(), handler);
134        }
135        Ok(Served { topology, bound })
136    }
137
138    /// The topology this serves, narrowed and validated.
139    pub fn topology(&self) -> &Topology {
140        &self.topology
141    }
142
143    /// The same topology behind an `Arc`, for a context that hands it to a capability.
144    pub fn shared(&self) -> Arc<Topology> {
145        Arc::new(self.topology.clone())
146    }
147
148    /// The surface that owns a request path, with what answers it.
149    pub fn owner(&self, path: &str) -> Option<(&Surface, &Bound)> {
150        let surface = self.topology.owner(path)?;
151        let bound = self.bound.get(&surface.id)?;
152        Some((surface, bound))
153    }
154
155    /// Is a surface's producer's output there? A route the executable answers always is.
156    pub fn ready(&self, id: &str) -> bool {
157        match self.bound.get(id) {
158            Some(Bound::Directory(files)) => files.available(),
159            Some(Bound::Route(_)) => true,
160            None => false,
161        }
162    }
163
164    /// The one line a server logs when it starts: how many surfaces it serves and where
165    /// each one is, `base` prefixed so the log is clickable.
166    ///
167    /// Every served surface, not only the public ones: this is an operator's diagnostic,
168    /// and the operator attaching a second client needs the MCP mount that the home page
169    /// has no reason to advertise to a browser.
170    ///
171    /// Both halves are read off the resolution. A surface added tomorrow is in this line,
172    /// and a route that moves moves here, because nothing below names one.
173    ///
174    /// ```
175    /// use majordomus_cli::http::Served;
176    /// use majordomus_cli::web::{discover::{self, Runtime}, Topology};
177    /// let topology = Topology::new(discover::native_all());
178    /// let served = Served::resolve(&topology, std::path::Path::new("/nonexistent"), Runtime::full()).unwrap();
179    /// let line = served.summary("http://127.0.0.1:8741");
180    /// assert!(line.contains("http://127.0.0.1:8741/swagger"));
181    /// // the count is the resolution's, not a number written here: a surface added to
182    /// // native_all is in this line, and this assertion does not go stale for saying so
183    /// assert!(line.contains(&format!("{} surface(s)", discover::native_all().len())));
184    /// ```
185    pub fn summary(&self, base: &str) -> String {
186        let mut parts: Vec<String> = self
187            .topology
188            .surfaces
189            .iter()
190            .map(|s| {
191                let mount = s.mount.as_str();
192                let path = if s.mount.is_root() { "/" } else { mount };
193                format!("{} {base}{path}", s.id)
194            })
195            .collect();
196        crate::order::canonical(&mut parts);
197        format!(
198            "{} surface(s): {}",
199            self.topology.surfaces.len(),
200            parts.join(", ")
201        )
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn every_native_surface_has_a_handler() {
211        for surface in discover::native_all() {
212            assert!(
213                Native::of(&surface.id).is_some(),
214                "the native surface '{}' is declared and nothing answers it",
215                surface.id
216            );
217        }
218    }
219
220    #[test]
221    fn a_native_surface_with_no_handler_refuses_the_router() {
222        let mut surfaces = discover::native_all();
223        // a surface at no reserved mount, so what refuses the router is the missing
224        // handler and not a reservation
225        let cockpit = surfaces
226            .iter_mut()
227            .find(|s| s.id == "cockpit")
228            .expect("the cockpit is declared");
229        cockpit.id = "invented".into();
230        let topology = Topology::new(surfaces);
231        let err = Served::resolve(&topology, Path::new("/nonexistent"), Runtime::full())
232            .expect_err("a surface nothing answers is refused")
233            .to_string();
234        assert!(err.contains("invented"), "{err}");
235        assert!(err.contains("Native::of"), "{err}");
236    }
237
238    #[test]
239    fn a_collision_refuses_the_router_and_names_both_surfaces() {
240        let mut surfaces = discover::native_all();
241        let mut clone = surfaces
242            .iter()
243            .find(|s| s.id == "cockpit")
244            .expect("the cockpit is declared")
245            .clone();
246        clone.id = "second".into();
247        surfaces.push(clone);
248        let topology = Topology::new(surfaces);
249        let err = Served::resolve(&topology, Path::new("/nonexistent"), Runtime::full())
250            .expect_err("two surfaces on one mount are refused")
251            .to_string();
252        assert!(err.contains("mount-collision"), "{err}");
253        assert!(err.contains("second"), "{err}");
254    }
255
256    #[test]
257    fn the_reserved_mounts_belong_to_the_surfaces_that_own_them() {
258        let topology = Topology::new(discover::native_all());
259        let served = Served::resolve(&topology, Path::new("/nonexistent"), Runtime::full())
260            .expect("the declared topology is servable");
261        assert_eq!(served.owner("/swagger").unwrap().0.id, "swagger");
262        assert_eq!(served.owner("/openapi.json").unwrap().0.id, "openapi");
263        assert_eq!(served.owner("/api/v1/health").unwrap().0.id, "api");
264        assert_eq!(served.owner("/mcp").unwrap().0.id, "mcp");
265        assert_eq!(served.owner("/cockpit/graphs").unwrap().0.id, "cockpit");
266        // the home page owns what nothing else claims, and nothing else claims it
267        assert_eq!(served.owner("/").unwrap().0.id, discover::HOME);
268        assert_eq!(served.owner("/whatever").unwrap().0.id, discover::HOME);
269    }
270
271    /// A repository whose only relevant fact is that it has a site.
272    fn with_a_site() -> tempfile::TempDir {
273        let tmp = tempfile::tempdir().expect("tempdir");
274        std::fs::create_dir_all(tmp.path().join("site")).expect("mkdir");
275        std::fs::write(tmp.path().join(discover::SITE_CONFIG), "base_url = \"/\"\n")
276            .expect("write");
277        tmp
278    }
279
280    #[test]
281    fn a_documentation_surface_does_not_swallow_the_routes_beside_it() {
282        let tmp = with_a_site();
283        let mut surfaces = discover::native_all();
284        surfaces.extend(discover::application(tmp.path()));
285        let topology = Topology::new(surfaces);
286        let served = Served::resolve(&topology, tmp.path(), Runtime::full())
287            .expect("the topology is servable");
288        assert_eq!(served.owner("/docs/").unwrap().0.id, discover::DOCS);
289        assert_eq!(
290            served.owner("/docs/cli/index.html").unwrap().0.id,
291            discover::DOCS
292        );
293        for (path, id) in [
294            ("/swagger", "swagger"),
295            ("/openapi.json", "openapi"),
296            ("/api/v1/objects", "api"),
297            ("/mcp", "mcp"),
298            ("/cockpit", "cockpit"),
299        ] {
300            assert_eq!(served.owner(path).unwrap().0.id, id, "{path}");
301        }
302        // the deployment is published, never served: it would otherwise claim `/`
303        assert!(!served.topology().ids().contains(&discover::APPLICATION));
304    }
305
306    /// The crate's rustdoc, discovered from the crate, bound to the directory its producer
307    /// writes, and answered for every kind of file the reference is made of — through the
308    /// same table the router consults, so what is proved here is what a request gets.
309    #[test]
310    fn the_crates_rustdoc_is_bound_to_its_directory_beside_the_documentation() {
311        let tmp = with_a_site();
312        let krate = tmp.path().join(crate::capability::model::CRATE_DIR);
313        std::fs::create_dir_all(krate.join("src")).expect("mkdir");
314        std::fs::write(krate.join("Cargo.toml"), "").expect("write");
315        std::fs::write(krate.join("src/lib.rs"), "//! x\n").expect("write");
316
317        // declared before it is built: the mount is held and answers what to run
318        let unbuilt = discover::discover(tmp.path(), Runtime::full()).expect("discovers");
319        let served = Served::resolve(&unbuilt, tmp.path(), Runtime::full())
320            .expect("an unbuilt reference is servable");
321        assert!(!served.ready(discover::RUSTDOC));
322        let Some((surface, Bound::Directory(files))) = served.owner("/rustdoc/") else {
323            panic!("the rustdoc mount is bound to a directory");
324        };
325        assert_eq!(surface.id, discover::RUSTDOC);
326        let response = files.respond("/rustdoc/");
327        assert_eq!(response.status, 503);
328        assert!(
329            response.body.text().contains(discover::RUSTDOC_PRODUCER),
330            "{}",
331            response.body
332        );
333
334        let dir = tmp.path().join(discover::RUSTDOC_ARTIFACT);
335        for (file, body) in [
336            ("index.html", "<h1>landing</h1>"),
337            ("static.files/a.css", "body{}"),
338            ("static.files/b.js", "var b;"),
339            ("static.files/c.woff2", "wOF2"),
340            ("majordomus_cli/index.html", "<h1>crate</h1>"),
341            ("majordomus_cli/macro.m!.html", "<p>redirect</p>"),
342        ] {
343            let path = dir.join(file);
344            std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
345            std::fs::write(path, body).expect("write");
346        }
347        let built = discover::discover(tmp.path(), Runtime::full()).expect("discovers");
348        let served =
349            Served::resolve(&built, tmp.path(), Runtime::full()).expect("the reference serves");
350        assert!(served.ready(discover::RUSTDOC));
351        for (path, media) in [
352            ("/rustdoc/", "text/html; charset=utf-8"),
353            ("/rustdoc/static.files/a.css", "text/css; charset=utf-8"),
354            (
355                "/rustdoc/static.files/b.js",
356                "text/javascript; charset=utf-8",
357            ),
358            ("/rustdoc/static.files/c.woff2", "font/woff2"),
359            (
360                "/rustdoc/majordomus_cli/index.html",
361                "text/html; charset=utf-8",
362            ),
363            (
364                "/rustdoc/majordomus_cli/macro.m!.html",
365                "text/html; charset=utf-8",
366            ),
367        ] {
368            let Some((surface, Bound::Directory(files))) = served.owner(path) else {
369                panic!("{path} is not owned by a directory");
370            };
371            assert_eq!(surface.id, discover::RUSTDOC, "{path}");
372            let response = files.respond(path);
373            assert_eq!(response.status, 200, "{path}: {}", response.body);
374            assert_eq!(response.content_type, media, "{path}");
375        }
376        // the documentation keeps its own mount, and the reference does not swallow it
377        assert_eq!(served.owner("/docs/").unwrap().0.id, discover::DOCS);
378        assert!(served
379            .summary("http://x")
380            .contains("rustdoc http://x/rustdoc"));
381    }
382
383    #[test]
384    fn a_process_without_a_feature_serves_none_of_its_surfaces() {
385        let topology = Topology::new(discover::native_all());
386        let served = Served::resolve(&topology, Path::new("/nonexistent"), Runtime::default())
387            .expect("the topology is servable");
388        assert!(served.owner("/mcp").is_none_or(|(s, _)| s.id != "mcp"));
389        assert!(!served.topology().ids().contains(&"cockpit"));
390        assert!(served
391            .summary("http://x")
392            .contains("swagger http://x/swagger"));
393    }
394}