Skip to main content

zeroclaw_gateway/
openapi.rs

1//! Runtime-generated OpenAPI 3.1 document for the new `/api/config/*` surface.
2//!
3//! Built from the same `schemars::JsonSchema` derives the request/response
4//! types carry. The generator does not introspect the axum router — instead it
5//! walks a hand-maintained `(method, path, request_type, response_type)` list
6//! local to this module. New endpoints under the same surface should be added
7//! to that list when they land. CI checks (forthcoming) can diff the rendered
8//! spec against a committed snapshot to fail builds when handlers are added
9//! without a corresponding OpenAPI entry.
10//!
11//! Cached behind a `OnceCell` because the spec is static per build.
12//!
13//!
14
15use axum::{
16    http::{HeaderValue, StatusCode, header},
17    response::{IntoResponse, Response},
18};
19use std::sync::OnceLock;
20
21#[cfg(feature = "schema-export")]
22use schemars::{JsonSchema, schema_for};
23
24static CACHED: OnceLock<serde_json::Value> = OnceLock::new();
25
26/// `GET /api/docs` — the Scalar API explorer page. Loads the standalone Scalar
27/// bundle from a CDN and points it at `/api/openapi.json`. The page is a
28/// single static HTML blob — no NPM dep, no committed bundle, ~2KB.
29///
30/// Authentication: Scalar's built-in panel prompts the user for the bearer
31/// token before any "Try it out" call, so the docs themselves are
32/// unauthenticated but the live calls honor the existing pairing/bearer auth.
33pub async fn handle_docs() -> Response {
34    let html = include_str!("openapi_docs.html");
35    let mut response = (StatusCode::OK, html).into_response();
36    response.headers_mut().insert(
37        header::CONTENT_TYPE,
38        HeaderValue::from_static("text/html; charset=utf-8"),
39    );
40    response
41}
42
43/// `GET /api/openapi.json` — returns the OpenAPI 3.1 document for the gateway
44/// surface that is documented today (`/api/config/*`). Static per build;
45/// browsers and the eventual Scalar explorer consume this as their data source.
46pub async fn handle_openapi_json() -> Response {
47    let body = CACHED.get_or_init(build_spec).clone();
48    let mut response = (StatusCode::OK, axum::Json(body)).into_response();
49    response.headers_mut().insert(
50        header::CACHE_CONTROL,
51        HeaderValue::from_static("public, max-age=3600"),
52    );
53    response
54}
55
56/// Build the OpenAPI 3.1 document. Pub so the `xtask gen-openapi` binary
57/// can render the same JSON the gateway serves and write it to the
58/// committed snapshot at `crates/zeroclaw-gateway/openapi.json`. CI
59/// staleness check (`xtask gen-openapi --check`) diffs the rendered
60/// spec against the committed file so a handler change without a spec
61/// update fails the build.
62#[cfg(feature = "schema-export")]
63pub fn build_spec() -> serde_json::Value {
64    use crate::api_config::{
65        DriftEntry, DriftResponse, InitQuery, InitResponse, ListResponse, MigrateResponse, PatchOp,
66        PatchResponse, PropPutBody, PropResponse, ReloadStatusResponse, SecretResponse,
67    };
68    use zeroclaw_config::api_error::ConfigApiError;
69
70    fn schema_value<T: JsonSchema>() -> serde_json::Value {
71        serde_json::to_value(schema_for!(T)).unwrap_or(serde_json::Value::Null)
72    }
73
74    let components = serde_json::json!({
75        "schemas": {
76            "ConfigApiError":   schema_value::<ConfigApiError>(),
77            "PropPutBody":      schema_value::<PropPutBody>(),
78            "PropResponse":     schema_value::<PropResponse>(),
79            "SecretResponse":   schema_value::<SecretResponse>(),
80            "ListResponse":     schema_value::<ListResponse>(),
81            "PatchOp":          schema_value::<PatchOp>(),
82            "PatchResponse":    schema_value::<PatchResponse>(),
83            "InitQuery":        schema_value::<InitQuery>(),
84            "InitResponse":     schema_value::<InitResponse>(),
85            "MigrateResponse":  schema_value::<MigrateResponse>(),
86            "DriftEntry":       schema_value::<DriftEntry>(),
87            "DriftResponse":    schema_value::<DriftResponse>(),
88            "ReloadStatusResponse": schema_value::<ReloadStatusResponse>(),
89            "Config":           schema_value::<zeroclaw_config::schema::Config>(),
90        },
91        "securitySchemes": {
92            "bearerAuth": {
93                "type": "http",
94                "scheme": "bearer",
95                "description": "Pairing-derived bearer token. Printed at gateway startup.",
96            }
97        }
98    });
99
100    let path_param = serde_json::json!({
101        "name": "path",
102        "in": "query",
103        "required": true,
104        "schema": { "type": "string" },
105        "description": "Dotted property path, e.g. `agents.researcher.model_provider`."
106    });
107
108    let prefix_param = serde_json::json!({
109        "name": "prefix",
110        "in": "query",
111        "required": false,
112        "schema": { "type": "string" },
113        "description": "Optional prefix to scope the listing."
114    });
115
116    let section_param = serde_json::json!({
117        "name": "section",
118        "in": "query",
119        "required": false,
120        "schema": { "type": "string" },
121        "description": "Section prefix to scope the init pass (e.g. `model_providers`)."
122    });
123
124    let error_responses = serde_json::json!({
125        "400": {
126            "description": "Validation, type, or operation error. See ConfigApiError.code.",
127            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConfigApiError" } } }
128        },
129        "404": {
130            "description": "Path not found in the schema.",
131            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConfigApiError" } } }
132        },
133        "409": {
134            "description": "On-disk config drifted from in-memory state.",
135            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConfigApiError" } } }
136        },
137        "500": {
138            "description": "Internal error or daemon-reload failure.",
139            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConfigApiError" } } }
140        }
141    });
142
143    let prop_get_responses = serde_json::json!({
144        "200": {
145            "description": "Property value (non-secret) or `{populated}` (secret).",
146            "content": {
147                "application/json": {
148                    "schema": {
149                        "oneOf": [
150                            { "$ref": "#/components/schemas/PropResponse" },
151                            { "$ref": "#/components/schemas/SecretResponse" }
152                        ]
153                    }
154                }
155            }
156        },
157        "404": error_responses["404"].clone(),
158    });
159
160    let paths = serde_json::json!({
161        "/api/config/prop": {
162            "get": {
163                "tags": ["config"],
164                "summary": "Read one property",
165                "description": "Returns the user value for non-secret fields. For secret fields, returns `{path, populated}` only — never the value, length, or any encoded form.",
166                "parameters": [path_param.clone()],
167                "responses": prop_get_responses,
168            },
169            "put": {
170                "tags": ["config"],
171                "summary": "Set one property",
172                "description": "Validates the resulting whole-config state, persists, and swaps in-memory. For secret fields, response carries `{populated: true}` only.",
173                "requestBody": {
174                    "required": true,
175                    "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PropPutBody" } } }
176                },
177                "responses": prop_get_responses,
178            },
179            "delete": {
180                "tags": ["config"],
181                "summary": "Reset one property to its default",
182                "parameters": [path_param.clone()],
183                "responses": prop_get_responses,
184            },
185        },
186        "/api/config/list": {
187            "get": {
188                "tags": ["config"],
189                "summary": "Enumerate properties",
190                "description": "Returns every reachable path with its type, category, and onboard section. Secret entries carry `{populated, is_secret: true}` and no value.",
191                "parameters": [prefix_param],
192                "responses": {
193                    "200": {
194                        "description": "List of properties.",
195                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ListResponse" } } }
196                    }
197                }
198            }
199        },
200        "/api/config": {
201            "patch": {
202                "tags": ["config"],
203                "summary": "Apply a JSON Patch (RFC 6902) document atomically",
204                "description": "Operations execute in order against an in-memory copy; `Config::validate()` runs once at the end; on success the snapshot persists and swaps. On failure, on-disk and in-memory state are unchanged. `move`/`copy` return `op_not_supported`. `test` against a secret path returns `secret_test_forbidden`.\n\n**Drift guard:** if the on-disk file has drifted from in-memory state on any path being patched, returns 409 `config_changed_externally` unless the request carries `X-ZeroClaw-Override-Drift: true`. GET /api/config/drift to inspect first.",
205                "parameters": [{
206                    "name": "X-ZeroClaw-Override-Drift",
207                    "in": "header",
208                    "required": false,
209                    "schema": { "type": "string", "enum": ["true"] },
210                    "description": "Set to `true` to overwrite externally-edited values without confirmation."
211                }],
212                "requestBody": {
213                    "required": true,
214                    "content": {
215                        "application/json": {
216                            "schema": {
217                                "type": "array",
218                                "items": { "$ref": "#/components/schemas/PatchOp" }
219                            }
220                        }
221                    }
222                },
223                "responses": {
224                    "200": {
225                        "description": "All operations applied and config saved.",
226                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchResponse" } } }
227                    },
228                    "400": error_responses["400"].clone(),
229                    "404": error_responses["404"].clone(),
230                    "409": error_responses["409"].clone(),
231                    "500": error_responses["500"].clone(),
232                }
233            }
234        },
235        "/api/config/init": {
236            "post": {
237                "tags": ["config"],
238                "summary": "Instantiate `None` nested sections with defaults",
239                "parameters": [section_param],
240                "responses": {
241                    "200": {
242                        "description": "Initialized section names (empty when nothing was uninitialized).",
243                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InitResponse" } } }
244                    }
245                }
246            }
247        },
248        "/api/config/drift": {
249            "get": {
250                "tags": ["config"],
251                "summary": "Drift between in-memory and on-disk config",
252                "description": "Returns properties whose in-memory values differ from what's on disk now. Empty when they agree. Secret entries carry only `{path, secret: true, drifted: true}`; values never leave the server.",
253                "responses": {
254                    "200": {
255                        "description": "Drift summary.",
256                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DriftResponse" } } }
257                    }
258                }
259            }
260        },
261        "/api/config/reload-status": {
262            "get": {
263                "tags": ["config"],
264                "summary": "Pending-reload flag for the running daemon",
265                "description": "Returns `{pending_reload: true}` when one or more config writes have landed since the last `/admin/reload`. Distinct from `/api/config/drift`, which compares disk to in-memory; this flag fires on in-process PATCHes that hot-swap memory but still need subsystem re-init (channels, providers, scheduler) to take effect.",
266                "responses": {
267                    "200": {
268                        "description": "Pending-reload flag.",
269                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReloadStatusResponse" } } }
270                    }
271                }
272            }
273        },
274        "/api/config/migrate": {
275            "post": {
276                "tags": ["config"],
277                "summary": "Apply on-disk schema migration in place",
278                "description": "Mirrors `zeroclaw config migrate`. Backs up the previous file as `config.toml.bak` before writing.",
279                "responses": {
280                    "200": {
281                        "description": "Migration applied (or already at the current schema version).",
282                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MigrateResponse" } } }
283                    }
284                }
285            }
286        }
287    });
288
289    let mut spec = serde_json::json!({
290        "openapi": "3.1.0",
291        "info": {
292            "title": "ZeroClaw Gateway — Config CRUD",
293            "version": env!("CARGO_PKG_VERSION"),
294            "description": "Per-property CRUD endpoints over the same `Config` mutation core that `zeroclaw config get/set/list/init/migrate` uses on the CLI. See https://github.com/zeroclaw-labs/zeroclaw/issues/6175 for the full surface and acceptance checklist.",
295        },
296        "security": [{"bearerAuth": []}],
297        "paths": paths,
298        "components": components,
299    });
300    flatten_defs_into_components(&mut spec);
301    spec
302}
303
304/// schemars emits nested types under each component's `$defs` and
305/// references them as `#/$defs/<Name>`. OpenAPI 3.1 tooling
306/// (openapi-typescript, Scalar, codegen) expects them at top-level
307/// `#/components/schemas/<Name>`. Hoist every `$defs` entry into
308/// `components.schemas` and rewrite refs in place so the spec validates
309/// and external tooling can walk it.
310#[cfg(feature = "schema-export")]
311fn flatten_defs_into_components(spec: &mut serde_json::Value) {
312    use serde_json::Value;
313
314    // Collect every `$defs` map across the spec — typically one per
315    // top-level component schema. Hoist entries into a single
316    // `components.schemas` map. Later entries with the same name win;
317    // the macro generates identical schemas for identical types so
318    // collisions are benign.
319    let mut hoisted: serde_json::Map<String, Value> = serde_json::Map::new();
320    collect_defs(spec, &mut hoisted);
321    if let Some(schemas) = spec
322        .pointer_mut("/components/schemas")
323        .and_then(|v| v.as_object_mut())
324    {
325        for (k, v) in hoisted {
326            schemas.entry(k).or_insert(v);
327        }
328    }
329    rewrite_refs(spec);
330    strip_defs(spec);
331}
332
333#[cfg(feature = "schema-export")]
334fn collect_defs(
335    value: &mut serde_json::Value,
336    out: &mut serde_json::Map<String, serde_json::Value>,
337) {
338    match value {
339        serde_json::Value::Object(map) => {
340            if let Some(serde_json::Value::Object(defs)) = map.get("$defs") {
341                for (name, schema) in defs {
342                    out.entry(name.clone()).or_insert_with(|| schema.clone());
343                }
344            }
345            for (_, child) in map.iter_mut() {
346                collect_defs(child, out);
347            }
348        }
349        serde_json::Value::Array(arr) => {
350            for child in arr.iter_mut() {
351                collect_defs(child, out);
352            }
353        }
354        _ => {}
355    }
356}
357
358#[cfg(feature = "schema-export")]
359fn rewrite_refs(value: &mut serde_json::Value) {
360    match value {
361        serde_json::Value::Object(map) => {
362            if let Some(serde_json::Value::String(s)) = map.get_mut("$ref")
363                && let Some(rest) = s.strip_prefix("#/$defs/")
364            {
365                *s = format!("#/components/schemas/{rest}");
366            }
367            for (_, child) in map.iter_mut() {
368                rewrite_refs(child);
369            }
370        }
371        serde_json::Value::Array(arr) => {
372            for child in arr.iter_mut() {
373                rewrite_refs(child);
374            }
375        }
376        _ => {}
377    }
378}
379
380#[cfg(feature = "schema-export")]
381fn strip_defs(value: &mut serde_json::Value) {
382    match value {
383        serde_json::Value::Object(map) => {
384            map.remove("$defs");
385            for (_, child) in map.iter_mut() {
386                strip_defs(child);
387            }
388        }
389        serde_json::Value::Array(arr) => {
390            for child in arr.iter_mut() {
391                strip_defs(child);
392            }
393        }
394        _ => {}
395    }
396}
397
398#[cfg(not(feature = "schema-export"))]
399pub fn build_spec() -> serde_json::Value {
400    serde_json::json!({
401        "openapi": "3.1.0",
402        "info": {
403            "title": "ZeroClaw Gateway",
404            "version": env!("CARGO_PKG_VERSION"),
405            "description": "OpenAPI generation requires the `schema-export` feature; this build was compiled without it.",
406        },
407        "paths": {},
408    })
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[cfg(feature = "schema-export")]
416    #[test]
417    fn spec_has_expected_paths() {
418        let spec = build_spec();
419        let paths = spec.get("paths").unwrap();
420        assert!(paths.get("/api/config/prop").is_some());
421        assert!(paths.get("/api/config/list").is_some());
422        assert!(paths.get("/api/config").is_some());
423        assert!(paths.get("/api/config/init").is_some());
424        assert!(paths.get("/api/config/migrate").is_some());
425        assert!(paths.get("/api/config/drift").is_some());
426        assert!(paths.get("/api/config/reload-status").is_some());
427    }
428
429    #[cfg(feature = "schema-export")]
430    #[test]
431    fn spec_declares_bearer_auth() {
432        let spec = build_spec();
433        let scheme = spec
434            .pointer("/components/securitySchemes/bearerAuth/scheme")
435            .and_then(|v| v.as_str());
436        assert_eq!(scheme, Some("bearer"));
437    }
438}