1use 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
21pub const OPENAPI_VERSION: &str = "3.1.0";
23
24pub const OAS_DIALECT: &str = "https://spec.openapis.org/oas/3.1/dialect/base";
27
28pub fn infrastructure_routes() -> Vec<crate::web::ProjectionRoute> {
49 crate::web::projection_routes()
50}
51
52const 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
61pub 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 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 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
200fn 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
244fn 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
254fn 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
279pub 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}