Skip to main content

prospero_api/
openapi.rs

1//! The REST surface as an OpenAPI 3.1 document (#222).
2//!
3//! Clients — [ariel](https://github.com/caliban-ai/ariel) first — otherwise have
4//! to hand-write every request and response type by reading the guide. This
5//! module publishes the same shapes as a machine-readable contract at
6//! `GET /api/openapi.json`.
7//!
8//! # Why 3.1, and why `schemars` rather than a spec framework
9//!
10//! OpenAPI 3.1 dropped its bespoke schema dialect and adopted JSON Schema
11//! 2020-12 wholesale — which is exactly what `schemars` emits. So the derives on
12//! `prospero-types` drop into `components/schemas` untouched. Targeting 3.0
13//! instead would mean running schemars' downgrade transforms (`AddNullable`,
14//! `RemoveRefSiblings`, …), each of which loses something the real type says.
15//! Publishing 3.1 is therefore not a version preference; it is what lets the
16//! document describe the types as they actually are.
17//!
18//! The schemas come from the same structs the handlers serialize, so they cannot
19//! drift from the wire. The *routes* are a hand-written table below, because
20//! axum's `Router` cannot be enumerated at runtime — but `tests/openapi.rs`
21//! scrapes `lib.rs` for both the paths it registers and the verbs it answers on
22//! each, so a route or method missing from this table fails the build rather
23//! than shipping a spec that quietly lies.
24
25use schemars::SchemaGenerator;
26use schemars::generate::{Contract, SchemaSettings};
27use serde_json::{Map, Value, json};
28
29/// What a request or response carries.
30enum Body {
31    /// No body at all (`204`, `202`).
32    Empty,
33    /// A single instance of a named component schema.
34    Json(&'static str),
35    /// An array of a named component schema.
36    JsonArray(&'static str),
37    /// `text/plain`.
38    Text,
39    /// A Server-Sent Events stream.
40    Sse,
41}
42
43/// One documented response.
44struct Res {
45    status: &'static str,
46    description: &'static str,
47    body: Body,
48}
49
50/// A query-string parameter. Path parameters are derived from the path
51/// template instead, so they cannot be forgotten.
52struct Query {
53    name: &'static str,
54    description: &'static str,
55    /// JSON Schema type for the raw query value.
56    ty: &'static str,
57}
58
59/// One operation on a path.
60struct Op {
61    method: &'static str,
62    id: &'static str,
63    summary: &'static str,
64    /// The minimum scope `auth::required_access` enforces, surfaced so a client
65    /// can tell which token it needs without reading the server source.
66    scope: &'static str,
67    query: &'static [Query],
68    request: Option<&'static str>,
69    responses: &'static [Res],
70}
71
72/// Standard error responses every fallible route shares.
73const ERRORS: &[Res] = &[
74    Res {
75        status: "4XX",
76        description: "Request rejected: see the `kind` field for which.",
77        body: Body::Json("ApiErrorBody"),
78    },
79    Res {
80        status: "5XX",
81        description: "prosperod or a backend it depends on failed.",
82        body: Body::Json("ApiErrorBody"),
83    },
84];
85
86const OK_EMPTY: &str = "Applied.";
87
88/// Every documented path, in the order the router declares them.
89const ROUTES: &[(&str, &[Op])] = &[
90    (
91        "/healthz",
92        &[Op {
93            method: "get",
94            id: "healthz",
95            summary: "Liveness — always 200 while the process is up.",
96            scope: "open",
97            query: &[],
98            request: None,
99            responses: &[Res {
100                status: "200",
101                description: "The literal body `ok`.",
102                body: Body::Text,
103            }],
104        }],
105    ),
106    (
107        "/readyz",
108        &[Op {
109            method: "get",
110            id: "readyz",
111            summary: "Readiness — 200 when the event store is writable.",
112            scope: "open",
113            query: &[],
114            request: None,
115            responses: &[
116                Res {
117                    status: "200",
118                    description: "Ready to serve.",
119                    body: Body::Json("Readiness"),
120                },
121                Res {
122                    status: "503",
123                    description: "Not ready; the same body explains why.",
124                    body: Body::Json("Readiness"),
125                },
126            ],
127        }],
128    ),
129    (
130        "/api/session",
131        &[
132            Op {
133                method: "get",
134                id: "getSession",
135                summary: "Who the current credential belongs to.",
136                scope: "open",
137                query: &[],
138                request: None,
139                responses: &[Res {
140                    status: "200",
141                    description: "The signed-in principal, or anonymous.",
142                    body: Body::Json("SessionInfo"),
143                }],
144            },
145            Op {
146                method: "post",
147                id: "signIn",
148                summary: "Exchange a token for the dashboard session cookie.",
149                scope: "open",
150                query: &[],
151                request: Some("SignInBody"),
152                responses: &[Res {
153                    status: "200",
154                    description: "Signed in; the session cookie is set.",
155                    body: Body::Json("SessionInfo"),
156                }],
157            },
158            Op {
159                method: "delete",
160                id: "signOut",
161                summary: "Clear the session cookie.",
162                scope: "open",
163                query: &[],
164                request: None,
165                responses: &[Res {
166                    status: "204",
167                    description: "Signed out.",
168                    body: Body::Empty,
169                }],
170            },
171        ],
172    ),
173    (
174        "/api/openapi.json",
175        &[Op {
176            method: "get",
177            id: "getOpenapi",
178            summary: "This document.",
179            scope: "open",
180            query: &[],
181            request: None,
182            responses: &[Res {
183                status: "200",
184                description: "The OpenAPI 3.1 description of this server.",
185                body: Body::Json("OpenApiDocument"),
186            }],
187        }],
188    ),
189    (
190        "/api/metrics",
191        &[Op {
192            method: "get",
193            id: "getMetrics",
194            summary: "Point-in-time operational counters.",
195            scope: "read",
196            query: &[],
197            request: None,
198            responses: &[Res {
199                status: "200",
200                description: "Counter snapshot.",
201                body: Body::Json("MetricsSnapshot"),
202            }],
203        }],
204    ),
205    (
206        "/api/capabilities",
207        &[Op {
208            method: "get",
209            id: "getCapabilities",
210            summary: "What the active fleet backend supports.",
211            scope: "read",
212            query: &[],
213            request: None,
214            responses: &[Res {
215                status: "200",
216                description: "Backend capabilities.",
217                body: Body::Json("Capabilities"),
218            }],
219        }],
220    ),
221    (
222        "/api/fleet",
223        &[Op {
224            method: "get",
225            id: "getFleet",
226            summary: "Every workspace and agent in the fleet.",
227            scope: "read",
228            query: &[],
229            request: None,
230            responses: &[Res {
231                status: "200",
232                description: "Fleet snapshot.",
233                body: Body::Json("FleetSnapshot"),
234            }],
235        }],
236    ),
237    (
238        "/api/fleet/stream",
239        &[Op {
240            method: "get",
241            id: "streamFleet",
242            summary: "Fleet-wide event stream (SSE).",
243            scope: "read",
244            query: &[Query {
245                name: "from",
246                description: "A fleet cursor to resume after, or `now` to tail \
247                              only what happens next. Absent replays from the start.",
248                ty: "string",
249            }],
250            request: None,
251            responses: &[Res {
252                status: "200",
253                description: "An endless `text/event-stream` of `FleetEvent` frames.",
254                body: Body::Sse,
255            }],
256        }],
257    ),
258    (
259        "/api/usage",
260        &[Op {
261            method: "get",
262            id: "getUsage",
263            summary: "Token and cost usage, bucketed by workspace and day.",
264            scope: "read",
265            query: &[
266                Query {
267                    name: "since",
268                    description: "Inclusive window start (RFC-3339, any offset). \
269                                  Echoed back normalized to UTC.",
270                    ty: "string",
271                },
272                Query {
273                    name: "until",
274                    description: "Exclusive window end (RFC-3339, any offset). \
275                                  Echoed back normalized to UTC.",
276                    ty: "string",
277                },
278                Query {
279                    name: "days",
280                    description: "How many days back to look, resolved against the \
281                                  server's clock. Ignored when `since` is given. \
282                                  Bounded: a window past any real history is a 400.",
283                    ty: "integer",
284                },
285            ],
286            request: None,
287            responses: &[
288                Res {
289                    status: "200",
290                    description: "Usage report, echoing the window actually used.",
291                    body: Body::Json("UsageReport"),
292                },
293                Res {
294                    status: "400",
295                    description: "`since` or `until` is not an RFC-3339 timestamp, or \
296                                  `days` is past the server's limit (`kind`: \
297                                  `bad_request`).",
298                    body: Body::Json("ApiErrorBody"),
299                },
300            ],
301        }],
302    ),
303    (
304        "/api/workspaces",
305        &[
306            Op {
307                method: "get",
308                id: "listWorkspaces",
309                summary: "Registered workspaces.",
310                scope: "read",
311                query: &[],
312                request: None,
313                responses: &[Res {
314                    status: "200",
315                    description: "One summary per workspace.",
316                    body: Body::JsonArray("WorkspaceSummary"),
317                }],
318            },
319            Op {
320                method: "post",
321                id: "addWorkspace",
322                summary: "Register a workspace.",
323                scope: "admin",
324                query: &[],
325                request: Some("AddWorkspaceBody"),
326                responses: &[
327                    Res {
328                        status: "201",
329                        description: "Created — a synchronous backend applied it already.",
330                        body: Body::Empty,
331                    },
332                    Res {
333                        status: "202",
334                        description: "Accepted — an async backend (k8s) enqueued a reconcile.",
335                        body: Body::Empty,
336                    },
337                ],
338            },
339        ],
340    ),
341    (
342        "/api/workspaces/{name}",
343        &[Op {
344            method: "delete",
345            id: "removeWorkspace",
346            summary: "Unregister a workspace.",
347            scope: "admin",
348            query: &[],
349            request: None,
350            responses: &[Res {
351                status: "204",
352                description: OK_EMPTY,
353                body: Body::Empty,
354            }],
355        }],
356    ),
357    (
358        "/api/workspaces/{name}/config",
359        &[Op {
360            method: "put",
361            id: "setWorkspaceConfig",
362            summary: "Replace a workspace's provider configuration.",
363            scope: "admin",
364            query: &[],
365            request: Some("SetConfigBody"),
366            responses: &[
367                Res {
368                    status: "204",
369                    description: "Applied synchronously.",
370                    body: Body::Empty,
371                },
372                Res {
373                    status: "202",
374                    description: "Accepted — an async backend will re-reconcile.",
375                    body: Body::Empty,
376                },
377            ],
378        }],
379    ),
380    (
381        "/api/workspaces/{workspace}/agents",
382        &[
383            Op {
384                method: "get",
385                id: "listWorkspaceAgents",
386                summary: "Agents under one workspace.",
387                scope: "read",
388                query: &[],
389                request: None,
390                responses: &[Res {
391                    status: "200",
392                    description: "The workspace's agents.",
393                    body: Body::JsonArray("Agent"),
394                }],
395            },
396            Op {
397                method: "post",
398                id: "spawnAgent",
399                summary: "Spawn an agent in this workspace.",
400                scope: "operate",
401                query: &[],
402                request: Some("SpawnBody"),
403                responses: &[Res {
404                    status: "201",
405                    description: "Spawned.",
406                    body: Body::Json("SpawnedResponse"),
407                }],
408            },
409        ],
410    ),
411    (
412        "/api/agents/{id}",
413        &[
414            Op {
415                method: "get",
416                id: "getAgent",
417                summary: "One agent.",
418                scope: "read",
419                query: &[],
420                request: None,
421                responses: &[Res {
422                    status: "200",
423                    description: "The agent.",
424                    body: Body::Json("Agent"),
425                }],
426            },
427            Op {
428                method: "delete",
429                id: "removeAgent",
430                summary: "Forget a finished agent.",
431                scope: "operate",
432                query: &[],
433                request: None,
434                responses: &[Res {
435                    status: "204",
436                    description: OK_EMPTY,
437                    body: Body::Empty,
438                }],
439            },
440        ],
441    ),
442    (
443        "/api/agents/{id}/events",
444        &[Op {
445            method: "get",
446            id: "getAgentEvents",
447            summary: "Durable event history for one agent.",
448            scope: "read",
449            query: &[Query {
450                name: "from",
451                description: "Return events with `seq >= from` (default 0).",
452                ty: "integer",
453            }],
454            request: None,
455            responses: &[Res {
456                status: "200",
457                description: "Events in sequence order.",
458                body: Body::JsonArray("FleetEvent"),
459            }],
460        }],
461    ),
462    (
463        "/api/agents/{id}/stream",
464        &[Op {
465            method: "get",
466            id: "streamAgent",
467            summary: "One agent's live event stream (SSE).",
468            scope: "read",
469            query: &[Query {
470                name: "from",
471                description: "Replay from this `seq` before tailing (default 0).",
472                ty: "integer",
473            }],
474            request: None,
475            responses: &[Res {
476                status: "200",
477                description: "A `text/event-stream` of `FleetEvent` frames.",
478                body: Body::Sse,
479            }],
480        }],
481    ),
482    (
483        "/api/agents/{id}/kill",
484        &[Op {
485            method: "post",
486            id: "killAgent",
487            summary: "Stop a running agent.",
488            scope: "operate",
489            query: &[],
490            request: None,
491            responses: &[Res {
492                status: "202",
493                description: "Stop requested.",
494                body: Body::Empty,
495            }],
496        }],
497    ),
498    (
499        "/api/agents/{id}/respawn",
500        &[Op {
501            method: "post",
502            id: "respawnAgent",
503            summary: "Start a fresh agent from a finished one's request.",
504            scope: "operate",
505            query: &[],
506            request: None,
507            responses: &[Res {
508                status: "200",
509                description: "The new agent's id.",
510                body: Body::Json("RespawnedResponse"),
511            }],
512        }],
513    ),
514    (
515        "/api/agents/{id}/input",
516        &[Op {
517            method: "post",
518            id: "sendAgentInput",
519            summary: "Send a line of input to an interactive agent.",
520            scope: "operate",
521            query: &[],
522            request: Some("AgentInputBody"),
523            responses: &[Res {
524                status: "202",
525                description: "Input forwarded.",
526                body: Body::Empty,
527            }],
528        }],
529    ),
530    (
531        "/api/agents/{id}/end-input",
532        &[Op {
533            method: "post",
534            id: "endAgentInput",
535            summary: "Close an interactive agent's input stream.",
536            scope: "operate",
537            query: &[],
538            request: None,
539            responses: &[Res {
540                status: "202",
541                description: "Input closed.",
542                body: Body::Empty,
543            }],
544        }],
545    ),
546    (
547        "/api/automations",
548        &[
549            Op {
550                method: "get",
551                id: "listAutomations",
552                summary: "Every automation. Never includes webhook signing keys.",
553                scope: "read",
554                query: &[],
555                request: None,
556                responses: &[Res {
557                    status: "200",
558                    description: "The automations.",
559                    body: Body::JsonArray("Automation"),
560                }],
561            },
562            Op {
563                method: "post",
564                id: "createAutomation",
565                summary: "Create an automation.",
566                scope: "admin",
567                query: &[],
568                request: Some("CreateAutomationBody"),
569                responses: &[Res {
570                    status: "201",
571                    description: "Created. For a webhook trigger this response is \
572                                  the only time the signing key is ever returned.",
573                    body: Body::Json("CreatedAutomationResponse"),
574                }],
575            },
576        ],
577    ),
578    (
579        "/api/automations/{id}",
580        &[Op {
581            method: "delete",
582            id: "deleteAutomation",
583            summary: "Delete an automation and its run history.",
584            scope: "admin",
585            query: &[],
586            request: None,
587            responses: &[Res {
588                status: "204",
589                description: OK_EMPTY,
590                body: Body::Empty,
591            }],
592        }],
593    ),
594    (
595        "/api/automations/{id}/enabled",
596        &[Op {
597            method: "put",
598            id: "setAutomationEnabled",
599            summary: "Enable or disable an automation without deleting it.",
600            scope: "admin",
601            query: &[],
602            request: Some("SetEnabledBody"),
603            responses: &[Res {
604                status: "204",
605                description: OK_EMPTY,
606                body: Body::Empty,
607            }],
608        }],
609    ),
610    (
611        "/api/automations/{id}/runs",
612        &[Op {
613            method: "get",
614            id: "listAutomationRuns",
615            summary: "Recent firings of one automation, newest first.",
616            scope: "read",
617            query: &[Query {
618                name: "limit",
619                description: "How many runs to return.",
620                ty: "integer",
621            }],
622            request: None,
623            responses: &[Res {
624                status: "200",
625                description: "The run history.",
626                body: Body::JsonArray("AutomationRun"),
627            }],
628        }],
629    ),
630    (
631        "/api/automations/{id}/run",
632        &[Op {
633            method: "post",
634            id: "runAutomation",
635            summary: "Fire an automation by hand, ignoring its schedule.",
636            scope: "operate",
637            query: &[],
638            request: None,
639            responses: &[Res {
640                status: "200",
641                description: "The resulting run.",
642                body: Body::Json("FiredResponse"),
643            }],
644        }],
645    ),
646    (
647        "/api/automations/{id}/trigger",
648        &[Op {
649            method: "post",
650            id: "triggerAutomation",
651            summary: "Fire a webhook automation.",
652            scope: "open (HMAC signature)",
653            query: &[],
654            request: None,
655            responses: &[Res {
656                status: "200",
657                description: "The resulting run.",
658                body: Body::Json("FiredResponse"),
659            }],
660        }],
661    ),
662];
663
664/// `GET /api/openapi.json` — serve the document.
665///
666/// Built per request rather than cached: it is a handful of milliseconds of
667/// `serde_json` on a route nothing calls in a hot loop, and a `OnceLock` here
668/// would keep a stale document alive across a hot-reload in tests.
669pub async fn get_openapi() -> axum::Json<Value> {
670    axum::Json(document())
671}
672
673/// Suffix distinguishing a type's request shape from its response shape.
674const REQUEST_SUFFIX: &str = "Request";
675
676/// A fresh generator emitting OpenAPI-3.1-compatible refs under `contract`.
677fn generator_for(contract: Contract) -> SchemaGenerator {
678    SchemaGenerator::new(SchemaSettings::draft2020_12().with(|s| {
679        s.definitions_path = "/components/schemas".into();
680        s.contract = contract;
681    }))
682}
683
684/// Schemas for everything the server *writes*, under the serialize contract.
685///
686/// Registering a type also registers everything it references, so these roots
687/// pull in the whole reachable graph.
688fn response_schemas() -> Map<String, Value> {
689    let mut generator = generator_for(Contract::Serialize);
690    macro_rules! register {
691        ($($ty:ty),* $(,)?) => { $( let _ = generator.subschema_for::<$ty>(); )* };
692    }
693    register!(
694        prospero_core::MetricsSnapshot,
695        prospero_core::Readiness,
696        prospero_types::Agent,
697        prospero_types::Automation,
698        prospero_types::AutomationRun,
699        prospero_types::Capabilities,
700        prospero_types::CreatedAutomationResponse,
701        prospero_types::FiredResponse,
702        prospero_types::FleetEvent,
703        prospero_types::FleetSnapshot,
704        prospero_types::RespawnedResponse,
705        prospero_types::SessionInfo,
706        prospero_types::SpawnedResponse,
707        prospero_types::UsageReport,
708        prospero_types::WorkspaceSummary,
709    );
710    generator.take_definitions(true)
711}
712
713/// Schemas for everything the server *reads*, under the deserialize contract.
714fn request_schemas() -> Map<String, Value> {
715    let mut generator = generator_for(Contract::Deserialize);
716    macro_rules! register {
717        ($($ty:ty),* $(,)?) => { $( let _ = generator.subschema_for::<$ty>(); )* };
718    }
719    register!(
720        prospero_types::AddWorkspaceBody,
721        prospero_types::AgentInputBody,
722        prospero_types::CreateAutomationBody,
723        prospero_types::SetConfigBody,
724        prospero_types::SetEnabledBody,
725        prospero_types::SignInBody,
726        prospero_types::SpawnBody,
727    );
728    generator.take_definitions(true)
729}
730
731/// Merge the two directions into one `components/schemas`.
732///
733/// `schemars` describes a type differently depending on whether it is being
734/// read or written: `#[serde(default)]` makes a field optional to a *reader*
735/// but says nothing to a writer, and `skip_serializing_if` does the reverse.
736/// Publishing one schema per type would therefore misdescribe one direction —
737/// a generated client would either demand a field the server omits or omit one
738/// the server demands.
739///
740/// So each direction is generated under its own contract, and a type is only
741/// renamed when the two shapes actually differ. Most types read and write
742/// identically and keep their plain name; the handful that do not (a
743/// `SpawnTemplate` inside a request body versus inside a stored automation)
744/// gain a `Request` twin. The returned map is the merged set; the second value
745/// maps a request type's plain name to the name it was published under.
746fn merged_schemas() -> (Map<String, Value>, Map<String, Value>) {
747    let responses = response_schemas();
748    let requests = request_schemas();
749
750    // Decide each request type's published name before rewriting any refs, so
751    // a rename is applied consistently wherever it is referenced.
752    let mut renames = Map::new();
753    for (name, schema) in &requests {
754        let collides = responses.get(name).is_some_and(|r| r != schema);
755        if collides {
756            renames.insert(
757                name.clone(),
758                Value::String(format!("{name}{REQUEST_SUFFIX}")),
759            );
760        }
761    }
762
763    let mut merged = responses;
764    for (name, schema) in requests {
765        let published = renames
766            .get(&name)
767            .and_then(Value::as_str)
768            .unwrap_or(&name)
769            .to_string();
770        merged.insert(published, rename_refs(schema, &renames));
771    }
772    (merged, renames)
773}
774
775/// Rewrite every `$ref` in `schema` that points at a renamed type.
776fn rename_refs(schema: Value, renames: &Map<String, Value>) -> Value {
777    match schema {
778        Value::Object(map) => Value::Object(
779            map.into_iter()
780                .map(|(key, val)| {
781                    if key == "$ref" {
782                        if let Some(name) = val
783                            .as_str()
784                            .and_then(|r| r.strip_prefix("#/components/schemas/"))
785                            && let Some(renamed) = renames.get(name).and_then(Value::as_str)
786                        {
787                            return (key, json!(format!("#/components/schemas/{renamed}")));
788                        }
789                        (key, val)
790                    } else {
791                        (key, rename_refs(val, renames))
792                    }
793                })
794                .collect(),
795        ),
796        Value::Array(items) => {
797            Value::Array(items.into_iter().map(|v| rename_refs(v, renames)).collect())
798        }
799        other => other,
800    }
801}
802
803/// Build the OpenAPI document describing every REST route.
804pub fn document() -> Value {
805    let (mut schemas, renames) = merged_schemas();
806    schemas.insert("ApiErrorBody".into(), api_error_schema());
807    schemas.insert("OpenApiDocument".into(), open_api_document_schema());
808
809    let paths: Map<String, Value> = ROUTES
810        .iter()
811        .map(|(path, ops)| ((*path).to_string(), path_item(path, ops, &renames)))
812        .collect();
813
814    json!({
815        "openapi": "3.1.0",
816        "info": {
817            "title": "prosperod REST API",
818            "version": env!("CARGO_PKG_VERSION"),
819            "description":
820                "The control plane for a fleet of caliban agents. Every route \
821                 below except the open ones needs a scoped token \
822                 (`Authorization: Bearer pspo_…`) or the dashboard session \
823                 cookie; each operation records the scope it requires in \
824                 `x-prospero-scope`.",
825            // `identifier` is SPDX and is what OpenAPI 3.1 prefers over a URL.
826            "license": { "name": "AGPL-3.0-only", "identifier": "AGPL-3.0-only" },
827        },
828        "servers": [{ "url": "/", "description": "This prosperod instance." }],
829        "components": {
830            "schemas": Value::Object(schemas),
831            "securitySchemes": {
832                "bearer": {
833                    "type": "http",
834                    "scheme": "bearer",
835                    "description": "A scoped prospero token (`pspo_…`).",
836                },
837                "session": {
838                    "type": "apiKey",
839                    "in": "cookie",
840                    "name": "prospero_session",
841                    "description": "The dashboard's sign-in cookie.",
842                },
843            },
844        },
845        "security": [{ "bearer": [] }, { "session": [] }],
846        "paths": Value::Object(paths),
847    })
848}
849
850/// Build one path item, deriving path parameters from the template.
851fn path_item(path: &str, ops: &[Op], renames: &Map<String, Value>) -> Value {
852    let mut item = Map::new();
853
854    let params: Vec<Value> = path_params(path)
855        .into_iter()
856        .map(|name| {
857            json!({
858                "name": name,
859                "in": "path",
860                "required": true,
861                "schema": { "type": "string" },
862            })
863        })
864        .collect();
865    if !params.is_empty() {
866        item.insert("parameters".into(), Value::Array(params));
867    }
868
869    for op in ops {
870        item.insert(op.method.to_string(), operation(op, renames));
871    }
872    Value::Object(item)
873}
874
875/// The `{name}` segments of a path template, in order.
876///
877/// Deriving these means a new parameterised route cannot ship with its
878/// parameters undeclared.
879fn path_params(path: &str) -> Vec<&str> {
880    path.split('/')
881        .filter_map(|seg| seg.strip_prefix('{')?.strip_suffix('}'))
882        // `{*path}` is axum's catch-all syntax, not a named parameter.
883        .filter(|name| !name.starts_with('*'))
884        .collect()
885}
886
887fn operation(op: &Op, renames: &Map<String, Value>) -> Value {
888    let mut out = Map::new();
889    out.insert("operationId".into(), json!(op.id));
890    out.insert("summary".into(), json!(op.summary));
891    out.insert("x-prospero-scope".into(), json!(op.scope));
892
893    if op.scope == "open" {
894        // An open route takes no credential, so it must not inherit the
895        // document-level security requirement.
896        out.insert("security".into(), json!([]));
897    }
898
899    if !op.query.is_empty() {
900        let params: Vec<Value> = op
901            .query
902            .iter()
903            .map(|q| {
904                json!({
905                    "name": q.name,
906                    "in": "query",
907                    "required": false,
908                    "description": q.description,
909                    "schema": { "type": q.ty },
910                })
911            })
912            .collect();
913        out.insert("parameters".into(), Value::Array(params));
914    }
915
916    if let Some(schema) = op.request {
917        // A request body refers to the type's read shape, which is published
918        // under a `Request` name whenever it differs from the write shape.
919        let published = renames
920            .get(schema)
921            .and_then(Value::as_str)
922            .unwrap_or(schema);
923        out.insert(
924            "requestBody".into(),
925            json!({
926                "required": true,
927                "content": { "application/json": { "schema": schema_ref(published) } },
928            }),
929        );
930    }
931
932    let mut responses = Map::new();
933    for res in op.responses.iter().chain(ERRORS) {
934        responses.insert(res.status.to_string(), response(res));
935    }
936    out.insert("responses".into(), Value::Object(responses));
937
938    Value::Object(out)
939}
940
941fn response(res: &Res) -> Value {
942    let content = match &res.body {
943        Body::Empty => None,
944        Body::Json(name) => Some(json!({ "application/json": { "schema": schema_ref(name) } })),
945        Body::JsonArray(name) => Some(json!({
946            "application/json": { "schema": { "type": "array", "items": schema_ref(name) } }
947        })),
948        Body::Text => Some(json!({ "text/plain": { "schema": { "type": "string" } } })),
949        Body::Sse => Some(json!({ "text/event-stream": { "schema": { "type": "string" } } })),
950    };
951
952    match content {
953        Some(content) => json!({ "description": res.description, "content": content }),
954        None => json!({ "description": res.description }),
955    }
956}
957
958fn schema_ref(name: &str) -> Value {
959    json!({ "$ref": format!("#/components/schemas/{name}") })
960}
961
962/// The error envelope every failing route returns.
963///
964/// Hand-written because `ApiError` is an axum `IntoResponse`, not a serialized
965/// struct — the shape below is what `error.rs` actually writes.
966fn api_error_schema() -> Value {
967    json!({
968        "type": "object",
969        "description": "The body of any 4xx or 5xx response.",
970        "required": ["error", "kind"],
971        "properties": {
972            "error": { "type": "string", "description": "Human-readable message." },
973            "kind": {
974                "type": "string",
975                "description":
976                    "Machine-readable class, e.g. `unauthorized`, `not_found`, \
977                     `invalid_state`, `unreachable`.",
978            },
979        },
980    })
981}
982
983/// This document's own schema — deliberately open, since it is an OpenAPI
984/// document and describing it in full would just be quoting the OpenAPI meta-schema.
985fn open_api_document_schema() -> Value {
986    json!({
987        "type": "object",
988        "description": "An OpenAPI 3.1 document.",
989    })
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995
996    #[test]
997    fn path_parameters_come_from_the_template() {
998        assert_eq!(path_params("/api/agents/{id}/events"), vec!["id"]);
999        assert_eq!(
1000            path_params("/api/workspaces/{workspace}/agents"),
1001            vec!["workspace"]
1002        );
1003        assert!(path_params("/api/fleet").is_empty());
1004    }
1005
1006    #[test]
1007    fn the_catch_all_asset_segment_is_not_a_parameter() {
1008        assert!(path_params("/assets/{*path}").is_empty());
1009    }
1010
1011    /// A type whose read and write shapes differ must be published twice, or
1012    /// one of the two directions is being described by the other's schema.
1013    #[test]
1014    fn a_type_that_reads_and_writes_differently_is_published_twice() {
1015        let (schemas, renames) = merged_schemas();
1016
1017        // `SpawnTemplate` is the concrete case: it is nested in a request body
1018        // (`CreateAutomationBody`) and in a response (`Automation`), and its
1019        // defaulted fields make the two shapes differ.
1020        assert!(
1021            renames.contains_key("SpawnTemplate"),
1022            "SpawnTemplate reads and writes differently, so it should have been \
1023             split; renames were {renames:#?}"
1024        );
1025
1026        let write = schemas.get("SpawnTemplate").expect("response shape");
1027        let read = schemas
1028            .get("SpawnTemplateRequest")
1029            .expect("request shape should be published separately");
1030        assert_ne!(
1031            write, read,
1032            "the two shapes are published under different names but are identical, \
1033             so the split is pointless"
1034        );
1035    }
1036
1037    /// Every type published under both names must be reachable under the right
1038    /// one: a request body must not quietly point at the response shape.
1039    #[test]
1040    fn request_bodies_refer_to_the_read_shape() {
1041        let doc = document();
1042        let body = &doc["paths"]["/api/automations"]["post"]["requestBody"]["content"]["application/json"]
1043            ["schema"]["$ref"];
1044        assert_eq!(body, "#/components/schemas/CreateAutomationBody");
1045
1046        // …and that schema's nested template must be the read shape too.
1047        let nested = doc["components"]["schemas"]["CreateAutomationBody"]["properties"]["template"]
1048            ["$ref"]
1049            .as_str()
1050            .expect("template should be a $ref");
1051        assert_eq!(nested, "#/components/schemas/SpawnTemplateRequest");
1052    }
1053}