Skip to main content

majordomus_cli/http/
openapi.rs

1//! The OpenAPI projection: an OpenAPI 3.1 document built from the registry and nothing
2//! else. `operationId` is the canonical id; every operation carries the canonical id,
3//! kind, stability, policies and the other projections as `x-majordomus-*` extensions so
4//! that a reader can reconcile it against MCP and the CLI. The tags are the modules; the
5//! examples are the capabilities' own benchmark cases, so an operation without an example
6//! is an operation without a case, and the executable will not compile in that state; the
7//! error responses are the statuses the router maps [`crate::capability::CapabilityError`]
8//! to, by kind. Deterministic: every map is sorted, nothing carries a timestamp.
9
10use std::collections::BTreeMap;
11
12use serde_json::{json, Map, Value};
13
14use crate::about;
15use crate::capability::{
16    CanonicalSchema, CapabilityKind, CapabilityRegistry, CaseContext, HttpMethod,
17};
18
19use super::router::ErrorBody;
20
21/// The OpenAPI version emitted.
22pub const OPENAPI_VERSION: &str = "3.1.0";
23
24/// The schema dialect declared: OpenAPI 3.1's base dialect, which is JSON Schema 2020-12
25/// plus the OAS vocabulary, and the only value Swagger UI accepts without a warning.
26pub const OAS_DIALECT: &str = "https://spec.openapis.org/oas/3.1/dialect/base";
27
28/// The routes that are the projection's own rather than capabilities, read from the
29/// surfaces that declare them: where each answers, what it is, and whether a publication
30/// can carry it.
31///
32/// It was a constant list, and a constant list is how `/docs` came to mean Swagger UI in
33/// three files and documentation in a fourth. The declarations in `crate::web::discover`
34/// are the one place a mount is written; [`crate::web::projection_routes`] reads them, and
35/// the document, the site dataset, the generated reference and the Cockpit all read that.
36/// A reader of this document is often a published page with no server behind it, so the
37/// availability travels with the route.
38///
39/// ```
40/// use majordomus_cli::http::openapi::infrastructure_routes;
41/// use majordomus_cli::web::Availability;
42/// let routes = infrastructure_routes();
43/// let swagger = routes.iter().find(|r| r.path == "/swagger").unwrap();
44/// assert_eq!(swagger.availability, Availability::ServedOnly);
45/// assert!(routes.iter().all(|r| !r.what.is_empty()));
46/// assert!(!routes.iter().any(|r| r.path.starts_with("/api/v1/")), "a capability route is not infrastructure");
47/// ```
48pub fn infrastructure_routes() -> Vec<crate::web::ProjectionRoute> {
49    crate::web::projection_routes()
50}
51
52/// The error statuses the router answers, by code, with the reason each one is given.
53/// `refused` is a command's alone: a query has nothing to refuse.
54const ERROR_STATUSES: &[(&str, &str, &str)] = &[
55    ("400", "invalid_input", "The input does not fit the schema: a parameter of the wrong type, an unknown one, or a value the capability rejects."),
56    ("404", "not_found", "The input names something the repository does not hold."),
57    ("422", "refused", "The command was understood and turned down for the reason the message gives."),
58    ("500", "internal", "The capability failed for a reason of its own; the message names it."),
59];
60
61/// The OpenAPI document of the registry. `cases` is the repository the examples are
62/// drawn from: every operation's examples are its capability's benchmark cases against
63/// that index; `None` renders the document without examples, for a registry that has no
64/// repository behind it. Fails only on a schema component name defined twice with
65/// different content.
66///
67/// ```
68/// use majordomus_cli::capability::{builtin, CapabilityRegistry};
69/// use majordomus_cli::http::openapi::document;
70/// let registry = CapabilityRegistry::builder().with_builtin(builtin::all()).build().unwrap();
71/// let doc = document(&registry, "0.0.0", None).unwrap();
72/// assert_eq!(doc["openapi"], "3.1.0");
73/// assert_eq!(doc["paths"]["/api/v1/object"]["get"]["operationId"], "objects.get");
74/// assert_eq!(doc["paths"]["/api/v1/object"]["get"]["x-majordomus-mcp"]["tool"], "majordomus_get");
75/// assert_eq!(doc["paths"]["/api/v1/object"]["get"]["tags"], serde_json::json!(["objects"]));
76/// assert!(doc["tags"].as_array().unwrap().iter().any(|t| t["name"] == "objects"));
77/// assert!(doc["paths"]["/api/v1/object"]["get"]["responses"]["404"].is_object());
78/// assert!(doc["paths"]["/api/v1/object"]["get"]["responses"].get("422").is_none(), "a query has nothing to refuse");
79/// assert!(doc["paths"]["/api/v1/peers/announce"]["post"]["responses"]["422"].is_object());
80/// ```
81pub fn document(
82    registry: &CapabilityRegistry,
83    version: &str,
84    cases: Option<&CaseContext<'_>>,
85) -> Result<Value, String> {
86    let _phase = crate::perf::phase(crate::perf::Phase::OpenApiBuild);
87    crate::perf::Counters::bump(&crate::perf::COUNTERS.openapi_builds);
88    let mut components: BTreeMap<String, Value> = BTreeMap::new();
89    let error_ref = CanonicalSchema::of::<ErrorBody>().openapi_ref(&mut components)?;
90    let mut paths: BTreeMap<String, BTreeMap<String, Value>> = BTreeMap::new();
91    let mut tagged: BTreeMap<String, usize> = BTreeMap::new();
92    for c in registry.iter() {
93        let Some(http) = &c.exposure.http else {
94            continue;
95        };
96        let output_ref = c.output.openapi_ref(&mut components)?;
97        let examples: Vec<(String, Value)> = match cases {
98            Some(ctx) => registry
99                .cases(c.id.as_str())
100                .map(|provider| {
101                    provider(ctx)
102                        .into_iter()
103                        .map(|case| (case.name.to_string(), case.input))
104                        .collect()
105                })
106                .unwrap_or_default(),
107            None => Vec::new(),
108        };
109        let mut op = Map::new();
110        op.insert("operationId".into(), json!(c.id));
111        op.insert("summary".into(), json!(c.title));
112        op.insert("description".into(), json!(c.description));
113        op.insert("tags".into(), json!([c.id.namespace()]));
114        *tagged.entry(c.id.namespace().to_string()).or_default() += 1;
115        match http.method {
116            HttpMethod::Get => {
117                // resolved first: an input whose schema root is a `$ref` — the
118                // `#[serde(transparent)]` newtype a module wraps a domain type in — has no
119                // properties at its root, and this is the projection that binds them
120                let input = c.input.resolved().for_openapi(&mut components)?;
121                let (props, required) = CanonicalSchema {
122                    name: None,
123                    schema: input,
124                }
125                .properties();
126                let params: Vec<Value> = props
127                    .into_iter()
128                    .map(|(name, schema)| {
129                        query_parameter(&name, schema, required.contains(&name), &examples)
130                    })
131                    .collect();
132                op.insert("parameters".into(), Value::Array(params));
133            }
134            HttpMethod::Post => {
135                let input_ref = c.input.openapi_ref(&mut components)?;
136                let mut content = json!({ "schema": input_ref });
137                if !examples.is_empty() {
138                    content["examples"] =
139                        named_examples(examples.iter().map(|(n, v)| (n.as_str(), v.clone())));
140                }
141                op.insert(
142                    "requestBody".into(),
143                    json!({ "required": true, "description": format!("The input of `{}`.", c.id), "content": { "application/json": content } }),
144                );
145            }
146        }
147        op.insert(
148            "responses".into(),
149            responses(c.kind, &c.title, &output_ref, &error_ref),
150        );
151        op.insert("x-majordomus-id".into(), json!(c.id));
152        op.insert("x-majordomus-kind".into(), json!(c.kind));
153        op.insert("x-majordomus-stability".into(), json!(c.stability));
154        op.insert("x-majordomus-provenance".into(), json!(c.provenance));
155        op.insert("x-majordomus-benchmark".into(), json!(c.benchmark));
156        op.insert("x-majordomus-cache".into(), json!(c.cache));
157        op.insert("x-majordomus-effect".into(), json!(c.execution.effect));
158        if let Some(mcp) = &c.exposure.mcp {
159            op.insert("x-majordomus-mcp".into(), json!(mcp));
160        }
161        if let Some(cli) = &c.exposure.cli {
162            op.insert("x-majordomus-cli".into(), json!(cli.path.join(" ")));
163        }
164        paths
165            .entry(http.path.clone())
166            .or_default()
167            .insert(http.method.as_str().to_lowercase(), Value::Object(op));
168    }
169    // the tags are the modules that put something on the wire, in the modules' own words
170    let tags: Vec<Value> = registry
171        .modules()
172        .filter(|m| tagged.contains_key(m.id.as_str()))
173        .map(|m| json!({ "name": m.id, "description": m.description }))
174        .collect();
175    Ok(json!({
176        "openapi": OPENAPI_VERSION,
177        "jsonSchemaDialect": OAS_DIALECT,
178        "info": {
179            "title": about::NAME,
180            "version": version,
181            "summary": about::SUMMARY,
182            "description": about::description(),
183            "license": { "name": about::LICENSE, "identifier": about::LICENSE },
184            "contact": { "name": about::NAME, "url": about::REPOSITORY }
185        },
186        "externalDocs": { "description": "The API reference, rendered from this document.", "url": about::REFERENCE_URL },
187        "servers": [{ "url": "/", "description": "The shared server `majordomus mcp` binds, or `majordomus serve`; loopback, the port in its log." }],
188        "tags": tags,
189        "paths": paths,
190        "components": { "schemas": components },
191        "x-majordomus": {
192            "generator": format!("majordomus-cli {version}"),
193            "infrastructure": infrastructure_routes(),
194            "binding": "GET binds every top-level input property as a query parameter; POST binds the input as the JSON body",
195            "errors": ERROR_STATUSES.iter().map(|(status, code, reason)| json!({ "status": status, "code": code, "reason": reason })).collect::<Vec<_>>()
196        }
197    }))
198}
199
200/// One query parameter from an input property. A query string carries text, never
201/// `null`, so an optional property's nullability and `null` default are the schema's way
202/// of saying "absent", which `required: false` already says; the parameter's examples are
203/// the cases that set it.
204fn query_parameter(
205    name: &str,
206    mut schema: Value,
207    required: bool,
208    cases: &[(String, Value)],
209) -> Value {
210    let description = schema.get("description").cloned();
211    if let Value::Object(m) = &mut schema {
212        m.remove("description");
213        if let Some(Value::Array(types)) = m.get("type").cloned() {
214            let kept: Vec<Value> = types.into_iter().filter(|t| t != "null").collect();
215            m.insert(
216                "type".into(),
217                match kept.len() {
218                    1 => kept[0].clone(),
219                    _ => Value::Array(kept),
220                },
221            );
222        }
223        if m.get("default") == Some(&Value::Null) {
224            m.remove("default");
225        }
226    }
227    let mut p = json!({ "name": name, "in": "query", "required": required, "schema": schema });
228    if let Some(d) = description {
229        p["description"] = d;
230    }
231    let set = cases.iter().filter_map(|(case, input)| {
232        input
233            .get(name)
234            .filter(|v| !v.is_null())
235            .map(|v| (case.as_str(), v.clone()))
236    });
237    let examples = named_examples(set);
238    if examples.as_object().is_some_and(|m| !m.is_empty()) {
239        p["examples"] = examples;
240    }
241    p
242}
243
244/// OpenAPI's `examples` map: case name to `{ "value": ... }`, sorted.
245fn named_examples<'a>(cases: impl Iterator<Item = (&'a str, Value)>) -> Value {
246    let map: Map<String, Value> = cases
247        .map(|(name, value)| (name.to_string(), json!({ "value": value })))
248        .collect::<BTreeMap<_, _>>()
249        .into_iter()
250        .collect();
251    Value::Object(map)
252}
253
254/// The responses of one operation: the result, one entry per error status the router can
255/// answer for this kind, and `default` for anything the transport adds (405, an
256/// unparseable request).
257fn responses(kind: CapabilityKind, title: &str, output_ref: &Value, error_ref: &Value) -> Value {
258    let mut map = Map::new();
259    map.insert(
260        "200".into(),
261        json!({ "description": format!("{title}: the result."), "content": { "application/json": { "schema": output_ref } } }),
262    );
263    for (status, code, reason) in ERROR_STATUSES {
264        if *code == "refused" && kind != CapabilityKind::Command {
265            continue;
266        }
267        map.insert(
268            (*status).into(),
269            json!({ "description": format!("`{code}`: {reason}"), "content": { "application/json": { "schema": error_ref } } }),
270        );
271    }
272    map.insert(
273        "default".into(),
274        json!({ "description": "Any other failure the transport reports: 405 `method_not_allowed` for another method on this path, 400 `invalid_input` for a body that is not a JSON object.", "content": { "application/json": { "schema": error_ref } } }),
275    );
276    Value::Object(map)
277}
278
279/// Serialise deterministically, pretty, with a trailing newline.
280pub fn render(doc: &Value) -> String {
281    let mut s = serde_json::to_string_pretty(doc).unwrap_or_else(|_| doc.to_string());
282    s.push('\n');
283    s
284}