Skip to main content

majordomus_cli/capability/builtin/
web.rs

1//! The `web` module: the surfaces this repository exposes over HTTP, answered from the
2//! resolution the process is already serving from.
3//!
4//! This is the machine-readable half of the home page, and deliberately not a second
5//! answer: both read `ctx.web`, which is resolved once per process, and the HTTP router
6//! hands its capability calls a context narrowed to exactly what it serves. Asking this
7//! capability over a served socket therefore describes that server; asking it on the
8//! command line or over a standalone MCP session describes the repository, whose surfaces
9//! carry the availability and the feature that say which process would serve them.
10//!
11//! Nothing here knows the name of a surface. A report that a producer generates tomorrow
12//! is in this answer the moment it declares itself, and a route that is removed leaves it.
13//!
14//! ```
15//! use majordomus_cli::capability::builtin::web::module;
16//! // the module declares its capability once; every projection of it is derived
17//! let m = module();
18//! let c = m.capabilities.iter().find(|c| c.capability.id.as_str() == "web.surfaces").unwrap();
19//! assert_eq!(c.capability.exposure.http.as_ref().unwrap().path, "/api/v1/web/surfaces");
20//! assert_eq!(c.capability.exposure.mcp.as_ref().unwrap().tool.as_deref(), Some("majordomus_web_surfaces"));
21//! ```
22
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26use crate::capability::handler::{CapabilityError, Context};
27use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
28use crate::capability::module::ModuleDescriptor;
29use crate::capability::CachePolicy;
30use crate::web::validate::{self, Artifacts, Finding};
31use crate::web::{Surface, Visibility};
32use crate::{capability, module};
33
34use super::{get, Empty};
35
36/// The URI under which the topology is read as an MCP resource.
37pub const SURFACES_URI: &str = "majordomus://web";
38
39/// The web surfaces of this repository, as the process that answers resolved them.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41pub struct SurfaceReport {
42    /// Every resolved surface, in route-precedence order: the order a router consults
43    /// them in, so the first whose mount owns a path is the one that answers it.
44    pub surfaces: Vec<Surface>,
45    /// The ids a process answers for, in the same order. A surface published and not
46    /// served is absent here.
47    pub served: Vec<String>,
48    /// The ids a publication holds, in the same order.
49    pub published: Vec<String>,
50    /// The ids offered to a person, which is what the home page lists.
51    pub public: Vec<String>,
52    /// What validation says about the topology as it stands. Empty is the healthy answer;
53    /// a finding here is the same one `majordomus web validate` reports, with its remedy.
54    pub findings: Vec<Finding>,
55}
56
57fn surfaces(ctx: &Context, _: Empty) -> Result<SurfaceReport, CapabilityError> {
58    Ok(report(
59        &ctx.web,
60        std::path::Path::new(&ctx.index.repository.root),
61    ))
62}
63
64/// The report of one resolved topology.
65///
66/// Separate from the handler because it is the whole of the answer: given a topology and
67/// the root its artifacts are relative to, everything below is a reading of that value and
68/// nothing is asked of the process.
69fn report(topology: &crate::web::Topology, root: &std::path::Path) -> SurfaceReport {
70    let ids = |take: &dyn Fn(&Surface) -> bool| -> Vec<String> {
71        topology
72            .surfaces
73            .iter()
74            .filter(|s| take(s))
75            .map(|s| s.id.clone())
76            .collect()
77    };
78    SurfaceReport {
79        served: ids(&|s| s.availability.is_served()),
80        published: ids(&|s| s.publishes()),
81        public: ids(&|s| s.visibility == Visibility::Public),
82        findings: validate::validate(topology, root, Artifacts::Ignore),
83        surfaces: topology.surfaces.clone(),
84    }
85}
86
87/// The module.
88pub fn module() -> ModuleDescriptor {
89    module! {
90        id: "web",
91        title: "Web surfaces",
92        description: "What this repository exposes over HTTP, resolved from the producers that make it rather than from a register anybody maintains: the routes the executable answers itself, the documentation build, and every generated report that declared its own mount. The same resolution serves the router, renders the home page and composes a publication.",
93        stability: Stability::BehaviorallyVerified,
94        capabilities: [
95            capability! {
96                id: "web.surfaces",
97                title: "Every web surface, resolved",
98                description: "The web topology in route-precedence order, with each surface's mount, category, visibility, kind, producer, artifact, runtime feature and the provenance of every value a reader could be surprised by; and which ids are served, published and offered to a person. Answered from the resolution this process serves from, so it cannot disagree with what the router routes or what the home page lists.",
99                input: Empty,
100                output: SurfaceReport,
101                stability: Stability::BehaviorallyVerified,
102                exposure: Exposure {
103                    mcp: Some(McpExposure {
104                        tool: Some("majordomus_web_surfaces".into()),
105                        resource: Some(McpResource { uri: SURFACES_URI.into(), name: "web".into() }),
106                    }),
107                    http: get("/api/v1/web/surfaces"),
108                    cli: None,
109                },
110                tags: ["web", "introspection"],
111                cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(5) },
112                handler: surfaces,
113            },
114        ],
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::web::discover::{self, Runtime};
122    use crate::web::Topology;
123
124    #[test]
125    fn the_report_is_three_readings_of_one_topology_and_invents_nothing() {
126        let mut surfaces = discover::native_all();
127        surfaces.extend(discover::application(std::path::Path::new("/nonexistent")));
128        let topology = Topology::new(surfaces);
129        let report = report(&topology, std::path::Path::new("/nonexistent"));
130
131        assert_eq!(report.surfaces.len(), topology.surfaces.len());
132        // every id in every list is a surface of the topology: no list is maintained
133        for id in report
134            .served
135            .iter()
136            .chain(&report.published)
137            .chain(&report.public)
138        {
139            assert!(
140                topology.get(id).is_some(),
141                "{id} is in a list and not in the topology"
142            );
143        }
144        assert!(report.served.contains(&"swagger".to_string()));
145        assert!(report.public.contains(&"swagger".to_string()));
146        // MCP is served and not advertised; the home page reads `public`, not `served`
147        assert!(report.served.contains(&"mcp".to_string()));
148        assert!(!report.public.contains(&"mcp".to_string()));
149        assert!(report.findings.is_empty(), "{:?}", report.findings);
150    }
151
152    #[test]
153    fn a_narrowed_topology_answers_for_the_process_that_narrowed_it() {
154        let bare = Topology::new(discover::native(Runtime::default()));
155        let report = report(&bare, std::path::Path::new("/nonexistent"));
156        assert!(!report.served.contains(&"cockpit".to_string()));
157        assert!(!report.served.contains(&"mcp".to_string()));
158        assert!(report.published.is_empty(), "a process publishes nothing");
159    }
160}