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::{json, Map, Value};
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/tools",
54            auth: "bearer",
55            summary: "Paginated tool list",
56        },
57        EndpointDoc {
58            method: "POST",
59            path: "/v1/tools/call",
60            auth: "bearer",
61            summary: "Execute a single tool",
62        },
63        EndpointDoc {
64            method: "GET",
65            path: "/v1/events",
66            auth: "bearer",
67            summary: "SSE stream with replay",
68        },
69        EndpointDoc {
70            method: "GET",
71            path: "/v1/context/summary",
72            auth: "bearer",
73            summary: "Materialized workspace/channel summary",
74        },
75        EndpointDoc {
76            method: "GET",
77            path: "/v1/events/search",
78            auth: "bearer",
79            summary: "Full-text search over event payloads",
80        },
81        EndpointDoc {
82            method: "GET",
83            path: "/v1/events/lineage",
84            auth: "bearer",
85            summary: "Causal lineage chain for an event",
86        },
87        EndpointDoc {
88            method: "GET",
89            path: "/v1/metrics",
90            auth: "bearer",
91            summary: "JSON metrics snapshot (slo block; ?format=prometheus for text exposition)",
92        },
93    ]
94}
95
96/// Build the OpenAPI 3.0.3 document for this build.
97pub fn openapi_value() -> Value {
98    let mut paths: Map<String, Value> = Map::new();
99
100    for e in endpoints() {
101        let security = if e.auth.contains("bearer") {
102            json!([{ "bearerAuth": [] }])
103        } else {
104            json!([])
105        };
106        let operation = json!({
107            "summary": e.summary,
108            "security": security,
109            "responses": { "200": { "description": "OK" } },
110        });
111
112        let entry = paths
113            .entry(e.path.to_string())
114            .or_insert_with(|| Value::Object(Map::new()));
115        if let Some(obj) = entry.as_object_mut() {
116            obj.insert(e.method.to_lowercase(), operation);
117        }
118    }
119
120    json!({
121        "openapi": "3.0.3",
122        "info": {
123            "title": "lean-ctx HTTP/MCP API",
124            "version": env!("CARGO_PKG_VERSION"),
125            "description": "Public /v1 surface of the lean-ctx Context OS. \
126                            Full contract: docs/contracts/http-mcp-contract-v1.md. \
127                            Discover instance features at GET /v1/capabilities.",
128        },
129        "components": {
130            "securitySchemes": {
131                "bearerAuth": { "type": "http", "scheme": "bearer" }
132            }
133        },
134        "paths": Value::Object(paths),
135    })
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn document_is_well_formed() {
144        let v = openapi_value();
145        assert_eq!(v["openapi"], json!("3.0.3"));
146        assert!(v["paths"].as_object().is_some_and(|p| !p.is_empty()));
147        assert!(v["components"]["securitySchemes"]["bearerAuth"].is_object());
148    }
149
150    #[test]
151    fn every_endpoint_is_present() {
152        let v = openapi_value();
153        let paths = v["paths"].as_object().expect("paths object");
154        for e in endpoints() {
155            let op = &paths[e.path][e.method.to_lowercase()];
156            assert!(
157                op.is_object(),
158                "missing {} {} in OpenAPI paths",
159                e.method,
160                e.path
161            );
162        }
163    }
164
165    #[test]
166    fn bearer_endpoints_require_security() {
167        let v = openapi_value();
168        let paths = v["paths"].as_object().unwrap();
169        let manifest = &paths["/v1/manifest"]["get"];
170        assert_eq!(manifest["security"], json!([{ "bearerAuth": [] }]));
171        let health = &paths["/health"]["get"];
172        assert_eq!(health["security"], json!([]));
173    }
174}