Skip to main content

lean_ctx/core/
openapi.rs

1//! OpenAPI 3.0 document for the public `/v1` surface, generated from a single
2//! in-code endpoint inventory ([`endpoints`]). The HTTP route that serves it
3//! lives in `http_server`; the SSOT lives here so it stays compiled and
4//! drift-tested without the `http-server` feature.
5//!
6//! Scope: the **public, stable** surface only — the same set documented in
7//! `docs/contracts/http-mcp-contract-v1.md`. Internal/experimental routes
8//! (agent registry, A2A, `.well-known`, shutdown) are intentionally excluded
9//! from the published spec. `tests/openapi_contract_up_to_date.rs` binds this
10//! inventory to that contract's Endpoints table.
11
12use serde_json::{Map, Value, json};
13
14/// One documented, public endpoint of the `/v1` surface.
15pub struct EndpointDoc {
16    pub method: &'static str,
17    pub path: &'static str,
18    /// `none` or a description containing `bearer` (drives the security block).
19    pub auth: &'static str,
20    pub summary: &'static str,
21}
22
23/// The public endpoint inventory — the single source of truth for the OpenAPI
24/// document and the contract-doc drift test.
25pub fn endpoints() -> Vec<EndpointDoc> {
26    vec![
27        EndpointDoc {
28            method: "GET",
29            path: "/health",
30            auth: "none",
31            summary: "Liveness probe",
32        },
33        EndpointDoc {
34            method: "GET",
35            path: "/v1/manifest",
36            auth: "bearer",
37            summary: "Full MCP manifest",
38        },
39        EndpointDoc {
40            method: "GET",
41            path: "/v1/capabilities",
42            auth: "bearer",
43            summary: "Instance capabilities discovery",
44        },
45        EndpointDoc {
46            method: "GET",
47            path: "/v1/openapi.json",
48            auth: "bearer",
49            summary: "OpenAPI 3.0 spec for this surface",
50        },
51        EndpointDoc {
52            method: "GET",
53            path: "/v1/cache/stats",
54            auth: "bearer",
55            summary: "Cross-agent cache and delivery statistics",
56        },
57        EndpointDoc {
58            method: "GET",
59            path: "/v1/tools",
60            auth: "bearer",
61            summary: "Paginated tool list",
62        },
63        EndpointDoc {
64            method: "POST",
65            path: "/v1/tools/call",
66            auth: "bearer",
67            summary: "Execute a single tool",
68        },
69        EndpointDoc {
70            method: "GET",
71            path: "/v1/events",
72            auth: "bearer",
73            summary: "SSE stream with replay",
74        },
75        EndpointDoc {
76            method: "GET",
77            path: "/v1/context/summary",
78            auth: "bearer",
79            summary: "Materialized workspace/channel summary",
80        },
81        EndpointDoc {
82            method: "GET",
83            path: "/v1/events/search",
84            auth: "bearer",
85            summary: "Full-text search over event payloads",
86        },
87        EndpointDoc {
88            method: "GET",
89            path: "/v1/events/lineage",
90            auth: "bearer",
91            summary: "Causal lineage chain for an event",
92        },
93        EndpointDoc {
94            method: "GET",
95            path: "/v1/metrics",
96            auth: "bearer",
97            summary: "JSON metrics snapshot (slo block; ?format=prometheus for text exposition)",
98        },
99    ]
100}
101
102/// Build the OpenAPI 3.0.3 document for this build.
103pub fn openapi_value() -> Value {
104    let mut paths: Map<String, Value> = Map::new();
105
106    for e in endpoints() {
107        let security = if e.auth.contains("bearer") {
108            json!([{ "bearerAuth": [] }])
109        } else {
110            json!([])
111        };
112        let operation = json!({
113            "summary": e.summary,
114            "security": security,
115            "responses": { "200": { "description": "OK" } },
116        });
117
118        let entry = paths
119            .entry(e.path.to_string())
120            .or_insert_with(|| Value::Object(Map::new()));
121        if let Some(obj) = entry.as_object_mut() {
122            obj.insert(e.method.to_lowercase(), operation);
123        }
124    }
125
126    json!({
127        "openapi": "3.0.3",
128        "info": {
129            "title": "lean-ctx HTTP/MCP API",
130            "version": env!("CARGO_PKG_VERSION"),
131            "description": "Public /v1 surface of the lean-ctx Context OS. \
132                            Full contract: docs/contracts/http-mcp-contract-v1.md. \
133                            Discover instance features at GET /v1/capabilities.",
134        },
135        "components": {
136            "securitySchemes": {
137                "bearerAuth": { "type": "http", "scheme": "bearer" }
138            }
139        },
140        "paths": Value::Object(paths),
141    })
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn document_is_well_formed() {
150        let v = openapi_value();
151        assert_eq!(v["openapi"], json!("3.0.3"));
152        assert!(v["paths"].as_object().is_some_and(|p| !p.is_empty()));
153        assert!(v["components"]["securitySchemes"]["bearerAuth"].is_object());
154    }
155
156    #[test]
157    fn every_endpoint_is_present() {
158        let v = openapi_value();
159        let paths = v["paths"].as_object().expect("paths object");
160        for e in endpoints() {
161            let op = &paths[e.path][e.method.to_lowercase()];
162            assert!(
163                op.is_object(),
164                "missing {} {} in OpenAPI paths",
165                e.method,
166                e.path
167            );
168        }
169    }
170
171    #[test]
172    fn bearer_endpoints_require_security() {
173        let v = openapi_value();
174        let paths = v["paths"].as_object().unwrap();
175        let manifest = &paths["/v1/manifest"]["get"];
176        assert_eq!(manifest["security"], json!([{ "bearerAuth": [] }]));
177        let health = &paths["/health"]["get"];
178        assert_eq!(health["security"], json!([]));
179    }
180}