1use schemars::SchemaGenerator;
26use schemars::generate::{Contract, SchemaSettings};
27use serde_json::{Map, Value, json};
28
29enum Body {
31 Empty,
33 Json(&'static str),
35 JsonArray(&'static str),
37 Text,
39 Sse,
41}
42
43struct Res {
45 status: &'static str,
46 description: &'static str,
47 body: Body,
48}
49
50struct Query {
53 name: &'static str,
54 description: &'static str,
55 ty: &'static str,
57}
58
59struct Op {
61 method: &'static str,
62 id: &'static str,
63 summary: &'static str,
64 scope: &'static str,
67 query: &'static [Query],
68 request: Option<&'static str>,
69 responses: &'static [Res],
70}
71
72const 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
88const 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
664pub async fn get_openapi() -> axum::Json<Value> {
670 axum::Json(document())
671}
672
673const REQUEST_SUFFIX: &str = "Request";
675
676fn 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
684fn 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
713fn 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
731fn merged_schemas() -> (Map<String, Value>, Map<String, Value>) {
747 let responses = response_schemas();
748 let requests = request_schemas();
749
750 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
775fn 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
803pub 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 "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
850fn 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
875fn path_params(path: &str) -> Vec<&str> {
880 path.split('/')
881 .filter_map(|seg| seg.strip_prefix('{')?.strip_suffix('}'))
882 .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 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 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
962fn 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
983fn 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 #[test]
1014 fn a_type_that_reads_and_writes_differently_is_published_twice() {
1015 let (schemas, renames) = merged_schemas();
1016
1017 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 #[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 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}