1use 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
26pub 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
43pub 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#[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#[cfg(feature = "schema-export")]
311fn flatten_defs_into_components(spec: &mut serde_json::Value) {
312 use serde_json::Value;
313
314 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}