Skip to main content

codex_app_server_client/rest/
openapi.rs

1//! OpenAPI 3.1.0 document for the `rest` module's HTTP surface.
2//!
3//! [`openapi_spec`] hand-builds a `serde_json::Value` describing every route
4//! in [`super::routes`] rather than deriving it from the Rust types via a
5//! schema-generation crate (`schemars`, `utoipa`, ...). That's a deliberate
6//! trade-off, not an oversight: this crate promises zero path-dependencies
7//! and a minimal, audited `crates.io` dependency footprint (see
8//! `README.md`), and every schema-derive crate considered pulls in either a
9//! proc-macro-heavy dependency tree or its own opinionated JSON Schema
10//! dialect that doesn't map onto OpenAPI 3.1 cleanly (`serde`'s
11//! `rename_all_fields` internally-tagged enums in particular - see
12//! [`RestEventResponse`](super::types::RestEventResponse) - have no clean
13//! derive-crate story as of this writing). Hand-writing means every schema
14//! below is transcribed by reading `src/rest/types.rs` and `src/compat.rs`
15//! directly; the tests at the bottom of this file exist specifically to
16//! catch that transcription drifting from the real wire format or the real
17//! mounted routes.
18//!
19//! # Determinism
20//!
21//! This crate does not enable `serde_json`'s `preserve_order` feature, so in
22//! an ordinary standalone build `serde_json::Map` is backed by a
23//! `BTreeMap` and serializes with sorted keys automatically. But Cargo
24//! unifies feature flags across an entire build's unit graph: when this
25//! crate is built as part of the full `soma` workspace (rather than in
26//! isolation with `cargo test -p codex-app-server-client`), sibling crates
27//! that *do* enable `preserve_order` (see `crates/shared/openapi/Cargo.toml`,
28//! `crates/shared/codemode/Cargo.toml`) flip `serde_json::Map` to an
29//! insertion-order-preserving `IndexMap` for every crate in that build,
30//! including this one - see `xtask/Cargo.toml`'s comment on the same
31//! incident for prior art. `json::obj` below builds every JSON object in
32//! this module by sorting its entries before insertion, so
33//! [`openapi_spec`]'s serialized output is byte-identical either way.
34//! [`serde_json::json!`] is still used freely for arrays and scalar leaves,
35//! where element order is meaningful (arrays) or there's nothing to order
36//! (leaves) - only object-shaped literals go through `json::obj`.
37//!
38//! # Module layout
39//!
40//! This document has three natural pieces, plus the shared low-level JSON
41//! builders they're all built from:
42//!
43//! - [`json`] - generic, schema-agnostic JSON/JSON-Schema value builders
44//!   (`obj`, `schema_ref`, `object_schema`, ...).
45//! - [`schemas`] - `components.schemas`, i.e. [`schemas::build_schemas`].
46//! - [`route_table`] - the single source-of-truth route table ([`route_table::ROUTES`])
47//!   read by both [`paths`] and this file's own coverage tests.
48//! - [`paths`] - `paths`, i.e. [`paths::build_paths`], including every
49//!   per-operation request/response/parameter builder.
50
51use serde_json::{Value, json};
52
53mod json;
54mod paths;
55mod route_table;
56mod schemas;
57
58#[cfg(test)]
59use route_table::{ROUTES, RouteDef};
60
61/// Builds the full OpenAPI 3.1.0 document for the `rest` module.
62///
63/// Deterministic: every object in the returned [`serde_json::Value`] is
64/// built through `json::obj`, so `serde_json::to_string_pretty(&openapi_spec())`
65/// is byte-identical run to run and build to build - see the module docs'
66/// "Determinism" section. `tests::openapi_spec_matches_checked_in_file`
67/// pins that output against the checked-in `openapi.json`.
68pub fn openapi_spec() -> Value {
69    json::obj(vec![
70        ("openapi", json!("3.1.0")),
71        (
72            "info",
73            json::obj(vec![
74                ("title", json!("codex-app-server-client REST adapter")),
75                ("version", json!(env!("CARGO_PKG_VERSION"))),
76                (
77                    "description",
78                    json!(
79                        "HTTP surface for the optional `rest` feature of `codex-app-server-client` \
80                         - a portable adapter around local `codex app-server` JSON-RPC processes. \
81                         This is only an adapter: it does not authenticate callers, authorize \
82                         requests, sandbox clients, or make the upstream app-server safe to expose \
83                         on a network by itself.\n\n\
84                         **Routes are opt-in per router constructor** - see each operation's \
85                         description for which of `rest::router()` (health/compat only), \
86                         `rest::text_turn_router()`, or `rest::trusted_bridge_router()` mounts it. \
87                         `rest::router_with_options`/`_with_backend*` let a host application mix \
88                         and match via `RestRouterOptions`.\n\n\
89                         **Authentication is opt-in and not part of the base router.** Wrap any \
90                         router in `rest::bearer_auth(token)` (a `tower` `Layer`) to require an \
91                         `Authorization: Bearer <token>` header on every request except (by \
92                         default) `GET /health` and `GET /v1/health` - see \
93                         `BearerAuthLayer::allow_unauthenticated_health`. Operations below that can \
94                         return `401` note that it only applies once this layer is added; the base \
95                         router never returns `401` on its own. A caller that presents the one \
96                         configured token gets everything the mounted router exposes - this is \
97                         transport auth only, not per-session or per-method authorization."
98                    ),
99                ),
100            ]),
101        ),
102        (
103            "servers",
104            json!([json::obj(vec![
105                ("url", json!("http://127.0.0.1:43210")),
106                (
107                    "description",
108                    json!(
109                        "Default loopback bind address used by the `rest_server` example and the `codex-app-server-rest` binary's `text-turn` mode."
110                    )
111                ),
112            ]),]),
113        ),
114        (
115            "components",
116            json::obj(vec![
117                (
118                    "securitySchemes",
119                    json::obj(vec![(
120                        "bearerAuth",
121                        json::obj(vec![
122                            ("type", json!("http")),
123                            ("scheme", json!("bearer")),
124                            (
125                                "description",
126                                json!(
127                                    "Opt-in via `rest::bearer_auth(token)`; not required by the base \
128                                     router. See this document's top-level `info.description`."
129                                ),
130                            ),
131                        ]),
132                    )]),
133                ),
134                ("schemas", schemas::build_schemas()),
135            ]),
136        ),
137        ("paths", paths::build_paths()),
138    ])
139}
140
141#[cfg(test)]
142mod tests {
143    use std::{collections::BTreeSet, fs, path::PathBuf};
144
145    use axum::{
146        body::{Body, to_bytes},
147        http::{Method, Request, header},
148    };
149    use tower::ServiceExt;
150
151    use super::*;
152    use crate::{
153        CompatibilityReport,
154        rest::{
155            RestBackend, RestFuture, RestRouterOptions, RestTextTurnResponse,
156            router_with_backend_and_options,
157        },
158    };
159
160    /// Path to the checked-in spec, relative to this crate's manifest.
161    fn checked_in_path() -> PathBuf {
162        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("openapi.json")
163    }
164
165    fn rendered() -> String {
166        // `to_string_pretty` (not `to_string`) so the checked-in file is
167        // human-diffable in review, same rationale as any other checked-in
168        // generated JSON in this repo.
169        let mut text = serde_json::to_string_pretty(&openapi_spec())
170            .expect("openapi_spec() must always serialize");
171        text.push('\n');
172        text
173    }
174
175    /// Pins `openapi_spec()`'s serialized output against the checked-in
176    /// `openapi.json`. This crate has no `xtask` (see README.md: zero
177    /// path-dependencies on anything else in the workspace, including
178    /// tooling crates), so unlike `docs/generated/openapi.json` elsewhere in
179    /// this repo, regeneration is an env-var-gated test run rather than an
180    /// `xtask` subcommand:
181    ///
182    /// ```sh
183    /// CODEX_REST_OPENAPI_WRITE=1 cargo test -p codex-app-server-client --features rest openapi_spec_matches_checked_in_file
184    /// ```
185    ///
186    /// then review the diff and commit `openapi.json` alongside the change
187    /// that caused it.
188    #[test]
189    fn openapi_spec_matches_checked_in_file() {
190        let rendered = rendered();
191        if std::env::var_os("CODEX_REST_OPENAPI_WRITE").is_some() {
192            fs::write(checked_in_path(), &rendered).expect("failed to write openapi.json");
193            return;
194        }
195        let checked_in = fs::read_to_string(checked_in_path()).unwrap_or_else(|error| {
196            panic!(
197                "failed to read {}: {error}\n\n\
198                 Generate it with:\n\
199                 CODEX_REST_OPENAPI_WRITE=1 cargo test -p codex-app-server-client --features rest openapi_spec_matches_checked_in_file",
200                checked_in_path().display()
201            )
202        });
203        // Compare line-by-line rather than byte-for-byte: git checks this file
204        // out with CRLF on Windows (`core.autocrlf`), while `rendered()`
205        // always emits LF, so a byte compare fails there for a reason that has
206        // nothing to do with the spec's content. Same approach as
207        // `apps/soma/tests/architecture_boundaries.rs`, which hit this first.
208        // Still an exact comparison of every line, so real drift - a changed
209        // value, a added or removed key - fails exactly as before.
210        assert!(
211            rendered.lines().eq(checked_in.lines()),
212            "openapi_spec() no longer matches the checked-in openapi.json.\n\n\
213             Regenerate it with:\n\
214             CODEX_REST_OPENAPI_WRITE=1 cargo test -p codex-app-server-client --features rest openapi_spec_matches_checked_in_file\n\n\
215             then review the diff and commit crates/shared/codex-app-server-client/openapi.json."
216        );
217    }
218
219    /// Every `$ref` and `discriminator.mapping` target in the document must
220    /// name a schema that actually exists under `components/schemas`.
221    ///
222    /// This exists because it caught a real bug: `RestEventResponse`'s
223    /// `discriminator.mapping` pointed at four `RestEventResponse*` names
224    /// whose schemas were only ever built inline inside the `oneOf` array and
225    /// never registered as components. The document still round-tripped as
226    /// JSON and every other test passed - but a spec-compliant generator
227    /// (`openapi-typescript`, via Redocly) rejects the whole document when it
228    /// can't resolve a mapping ref, which defeats the point of publishing the
229    /// spec at all. A dangling ref is invisible to `serde_json`, so it needs
230    /// its own assertion.
231    /// Every operation that declares a `requestBody` must document a `413`.
232    ///
233    /// The router caps request bodies with `DefaultBodyLimit`
234    /// (`RestLimits::max_request_body_bytes`), so any body-reading route can
235    /// return `413` before its handler runs. `paths::ensure_request_body_limit_413`
236    /// adds it centrally; this asserts nothing slipped past that - a route with
237    /// a body but no documented `413` would under-report the real API to every
238    /// generated client.
239    #[test]
240    fn every_route_with_a_request_body_documents_413() {
241        let spec = openapi_spec();
242        let paths = spec["paths"].as_object().expect("paths is an object");
243
244        let mut missing = Vec::new();
245        for (path, item) in paths {
246            let operations = item.as_object().expect("path item is an object");
247            for (method, operation) in operations {
248                if operation.get("requestBody").is_none() {
249                    continue;
250                }
251                if operation["responses"].get("413").is_none() {
252                    missing.push(format!("{} {path}", method.to_uppercase()));
253                }
254            }
255        }
256        assert!(
257            missing.is_empty(),
258            "these operations declare a requestBody but do not document a 413 (the router's \
259             DefaultBodyLimit can reject any of them): {missing:?}"
260        );
261    }
262
263    #[test]
264    fn every_schema_ref_resolves_to_a_real_component() {
265        let spec = openapi_spec();
266        let defined: BTreeSet<String> = spec["components"]["schemas"]
267            .as_object()
268            .expect("components/schemas is an object")
269            .keys()
270            .cloned()
271            .collect();
272
273        let mut refs = Vec::new();
274        collect_schema_refs(&spec, &mut refs);
275        assert!(
276            !refs.is_empty(),
277            "found no schema refs at all - collect_schema_refs is not walking the document"
278        );
279
280        let dangling: Vec<&String> = refs
281            .iter()
282            .filter(|name| !defined.contains(*name))
283            .collect();
284        assert!(
285            dangling.is_empty(),
286            "openapi_spec() references schemas that do not exist under components/schemas: \
287             {dangling:?}\n\nDefined schemas: {defined:?}"
288        );
289    }
290
291    /// Walks the whole document collecting every `#/components/schemas/<name>`
292    /// target, from both `$ref` values and `discriminator.mapping` values
293    /// (the latter are plain strings, not `$ref` objects, which is exactly why
294    /// they were able to dangle unnoticed).
295    fn collect_schema_refs(value: &Value, out: &mut Vec<String>) {
296        const PREFIX: &str = "#/components/schemas/";
297        match value {
298            Value::Object(map) => {
299                for (key, child) in map {
300                    match (key.as_str(), child) {
301                        ("$ref", Value::String(target)) => {
302                            if let Some(name) = target.strip_prefix(PREFIX) {
303                                out.push(name.to_owned());
304                            }
305                        }
306                        ("mapping", Value::Object(mapping)) => {
307                            for target in mapping.values().filter_map(Value::as_str) {
308                                if let Some(name) = target.strip_prefix(PREFIX) {
309                                    out.push(name.to_owned());
310                                }
311                            }
312                        }
313                        _ => collect_schema_refs(child, out),
314                    }
315                }
316            }
317            Value::Array(items) => {
318                for item in items {
319                    collect_schema_refs(item, out);
320                }
321            }
322            _ => {}
323        }
324    }
325
326    /// A `RestBackend` that answers every call immediately without spawning
327    /// a real `codex` process - the two mandatory trait methods
328    /// (`compatibility_report`, `run_text_turn`) get trivial canned
329    /// responses; every other method falls back to the trait's own default
330    /// impl (see `src/rest/types.rs`), which already returns a
331    /// properly-JSON-shaped `RestError::NotFound`/empty-list/etc for
332    /// anything it doesn't implement - exactly the shape a real backend
333    /// would return for "session not found", which is what
334    /// `every_documented_route_is_actually_mounted` needs: a JSON response
335    /// distinguishable from axum's own no-route-matched fallback, without
336    /// depending on `codex` being installed in the test environment.
337    struct MinimalBackend;
338
339    impl RestBackend for MinimalBackend {
340        fn compatibility_report(&self) -> RestFuture<CompatibilityReport> {
341            Box::pin(async { Ok(CompatibilityReport::from_installed_version(None)) })
342        }
343
344        fn run_text_turn(
345            &self,
346            _request: super::super::types::RestTextTurnRequest,
347        ) -> RestFuture<RestTextTurnResponse> {
348            Box::pin(async { Ok(RestTextTurnResponse::default()) })
349        }
350    }
351
352    /// Request bodies for the routes that require one. Kept next to (not
353    /// merged into) [`ROUTES`] because a body is a probe-test concern only -
354    /// `openapi_spec()` itself never needs a concrete instance, only the
355    /// schema.
356    fn probe_body(route: &RouteDef) -> Option<Value> {
357        match (route.method, route.path_template) {
358            ("post", "/v1/text-turn") => Some(json!({"prompt": "hello"})),
359            ("post", "/v1/call/{method}") => Some(json!({})),
360            ("post", "/v1/sessions") => Some(json!({})),
361            ("post", "/v1/sessions/{sessionId}/call/{method}") => Some(json!({})),
362            ("post", "/v1/sessions/{sessionId}/requests/{requestKey}/result") => {
363                Some(json!({"result": {}}))
364            }
365            ("post", "/v1/sessions/{sessionId}/requests/{requestKey}/error") => {
366                Some(json!({"code": -32000, "message": "denied"}))
367            }
368            _ => None,
369        }
370    }
371
372    /// The coverage test for bead g0qf.2's "route-coverage" requirement.
373    ///
374    /// axum 0.8's `Router` has no public API to enumerate its own routes, so
375    /// this can't diff "routes the live router actually has" against
376    /// "routes `openapi_spec()` documents" by introspection. Instead it
377    /// takes the documented, honest fallback: [`ROUTES`] is the *one* table
378    /// both `openapi_spec()` (via [`paths::build_paths`]) and this
379    /// test read, and this test proves each entry in it is real by actually
380    /// issuing an HTTP request for it against a live
381    /// `trusted_bridge_router()` (the superset router - it mounts every gate
382    /// in `RouteGate`) and checking the response could only have come from
383    /// that route's real handler, not axum's built-in no-route-matched
384    /// fallback:
385    ///
386    /// - every route except the SSE stream one always answers with a
387    ///   `Content-Type: application/json` body on this crate's own success
388    ///   *and* error paths (including extraction failures like a malformed
389    ///   body - see `invalid_json`/`invalid_request` in `routes.rs`, both of
390    ///   which still go through `Json(...)`), so any non-JSON body is proof
391    ///   the request never reached a real handler.
392    /// - the SSE stream route commits `Content-Type: text/event-stream` the
393    ///   moment the response starts, before the backend is even polled once
394    ///   (see that operation's description above), so a non-SSE content
395    ///   type there is the same tell.
396    ///
397    /// What this test does *not* catch: a route added to `routes.rs` and
398    /// never added to `ROUTES` here. axum exposes no route-enumeration API,
399    /// so that direction is unverifiable *from the live router*.
400    /// [`every_path_mounted_by_routes_rs_is_in_the_routes_table`] covers it
401    /// from the other side instead, by reading `routes.rs`'s own source for
402    /// its `.route(...)` path literals - the two tests together pin both
403    /// directions.
404    #[tokio::test]
405    async fn every_documented_route_is_actually_mounted() {
406        let app =
407            router_with_backend_and_options(MinimalBackend, RestRouterOptions::trusted_bridge());
408
409        for route in ROUTES {
410            let method = Method::from_bytes(route.method.to_ascii_uppercase().as_bytes())
411                .unwrap_or_else(|error| panic!("invalid method `{}`: {error}", route.method));
412            let mut builder = Request::builder().method(method).uri(route.probe_path);
413            let request = match probe_body(route) {
414                Some(body) => {
415                    builder = builder.header(header::CONTENT_TYPE, "application/json");
416                    builder.body(Body::from(body.to_string())).unwrap()
417                }
418                None => builder.body(Body::empty()).unwrap(),
419            };
420
421            let response = app.clone().oneshot(request).await.unwrap_or_else(|error| {
422                panic!("{} {} failed: {error}", route.method, route.probe_path)
423            });
424            let content_type = response
425                .headers()
426                .get(header::CONTENT_TYPE)
427                .and_then(|value| value.to_str().ok())
428                .unwrap_or_default()
429                .to_owned();
430            let status = response.status();
431            // Drain the body so a hung/oversized stream would fail the test
432            // rather than the test process, and so `content_type` above
433            // (already read from headers, which arrive before any body) is
434            // the only thing this assertion needs.
435            let _ = to_bytes(response.into_body(), usize::MAX).await;
436
437            let is_sse_route = route.path_template.ends_with("/events/stream");
438            if is_sse_route {
439                assert!(
440                    content_type.starts_with("text/event-stream"),
441                    "{} {} returned Content-Type {content_type:?} (status {status}), not \
442                     text/event-stream - openapi_spec() documents this as the SSE route but \
443                     trusted_bridge_router() did not route the probe request to it",
444                    route.method,
445                    route.probe_path,
446                );
447            } else {
448                assert!(
449                    content_type.starts_with("application/json"),
450                    "{} {} returned Content-Type {content_type:?} (status {status}), not JSON - \
451                     openapi_spec() documents `{} {}` as a mounted route, but \
452                     trusted_bridge_router() did not route the probe request to a real handler \
453                     (axum's own no-route-matched fallback never returns application/json)",
454                    route.method,
455                    route.probe_path,
456                    route.method,
457                    route.path_template,
458                );
459            }
460        }
461    }
462
463    /// Every path mounted by `routes.rs` must appear in [`ROUTES`], and vice
464    /// versa.
465    ///
466    /// This closes the one direction
467    /// [`every_documented_route_is_actually_mounted`] structurally cannot:
468    /// that test probes a live router for each `ROUTES` entry, proving
469    /// `ROUTES` is a subset of what is mounted. A route added to `routes.rs`
470    /// and never mirrored here would compile, pass every other test, and be
471    /// silently absent from `openapi.json` and every generated client
472    /// forever - the failure mode with no symptom.
473    ///
474    /// Works by reading `routes.rs`'s own source and extracting its
475    /// `.route("...")` path literals. Source-grep rather than introspection
476    /// because axum 0.8's `Router` exposes no route-enumeration API (the same
477    /// reason `ROUTES` exists at all), and this repo already uses
478    /// source-pattern contract checks elsewhere (`xtask/src/patterns/`).
479    ///
480    /// Compares paths only, not methods: the two files spell parameters
481    /// differently (`routes.rs` uses axum's `{session_id}`/`{*method}`, this
482    /// table uses OpenAPI's `{sessionId}`), so both sides are normalized to a
483    /// bare `{}` placeholder per parameter segment. A residual gap that
484    /// remains: adding a *method* to an already-listed path (e.g. a `.patch()`
485    /// on `/v1/sessions`) is not caught here - only a new or removed path is.
486    #[test]
487    fn every_path_mounted_by_routes_rs_is_in_the_routes_table() {
488        /// Collapses each `{...}` path segment to `{}` so axum's and
489        /// OpenAPI's differing parameter spellings compare equal.
490        fn normalize(path: &str) -> String {
491            path.split('/')
492                .map(|segment| {
493                    if segment.starts_with('{') && segment.ends_with('}') {
494                        "{}"
495                    } else {
496                        segment
497                    }
498                })
499                .collect::<Vec<_>>()
500                .join("/")
501        }
502
503        let source = include_str!("routes.rs");
504        let call_count = source.matches(".route(").count();
505        let mounted: BTreeSet<String> = source
506            .match_indices(".route(")
507            .map(|(index, matched)| {
508                // `rustfmt` wraps longer calls, so the path literal is not
509                // necessarily on the same line as `.route(`. Skip whitespace
510                // rather than assuming `.route("`, and fail loudly on any
511                // shape this parser cannot read - a silently-skipped call
512                // would make this whole test vacuously pass.
513                let rest = source[index + matched.len()..].trim_start();
514                let rest = rest.strip_prefix('"').unwrap_or_else(|| {
515                    panic!(
516                        "expected a string literal as the first argument to .route(, found: {:?}. \
517                         This test parses routes.rs's source; teach it the new shape rather than \
518                         letting it skip the call.",
519                        &rest[..rest.len().min(40)]
520                    )
521                });
522                let end = rest
523                    .find('"')
524                    .expect("a .route( path literal must have a closing quote");
525                normalize(&rest[..end])
526            })
527            .collect();
528        assert!(
529            !mounted.is_empty(),
530            "found no .route(...) calls in routes.rs - the source-grep in this test has broken \
531             and is silently proving nothing"
532        );
533        // `mounted` is a set, so duplicate paths would collapse silently.
534        // There are none today; if that changes, this catches the collapse
535        // rather than letting the set comparison quietly under-count.
536        assert_eq!(
537            mounted.len(),
538            call_count,
539            "routes.rs has {call_count} .route(...) calls but only {} distinct paths - a \
540             duplicate path would make the comparison below under-count",
541            mounted.len()
542        );
543
544        let documented: BTreeSet<String> = ROUTES
545            .iter()
546            .map(|route| normalize(route.path_template))
547            .collect();
548
549        let undocumented: Vec<&String> = mounted.difference(&documented).collect();
550        assert!(
551            undocumented.is_empty(),
552            "routes.rs mounts these paths, but they are missing from ROUTES - they would be \
553             absent from openapi.json and every generated client: {undocumented:?}"
554        );
555
556        let unmounted: Vec<&String> = documented.difference(&mounted).collect();
557        assert!(
558            unmounted.is_empty(),
559            "ROUTES documents these paths, but routes.rs does not mount them: {unmounted:?}"
560        );
561    }
562
563    /// Cheap structural sanity check that every [`ROUTES`] entry has a
564    /// matching `paths.<template>.<method>` entry in `openapi_spec()`, and
565    /// vice versa. This is *not* the coverage test bead g0qf.2 asks for -
566    /// [`every_documented_route_is_actually_mounted`] above is - since
567    /// `build_paths` mechanically derives `paths` from `ROUTES`, so by
568    /// construction this can only fail if `operation_for`'s match arms and
569    /// `ROUTES`'s entries fall out of sync with each other (a bug this
570    /// module could introduce internally), not if `routes.rs` drifts from
571    /// either. Kept anyway because it's a real, cheap regression guard for
572    /// that internal-consistency failure mode, and its assertion messages
573    /// are far more direct than tracing a panic out of `operation_for`.
574    #[test]
575    fn openapi_spec_paths_match_routes_table_exactly() {
576        let spec = openapi_spec();
577        let paths = spec
578            .get("paths")
579            .and_then(Value::as_object)
580            .expect("openapi_spec() must have an object `paths`");
581
582        for route in ROUTES {
583            let path_item = paths.get(route.path_template).unwrap_or_else(|| {
584                panic!(
585                    "ROUTES has `{} {}` but openapi_spec()'s paths has no entry for `{}`",
586                    route.method, route.probe_path, route.path_template
587                )
588            });
589            assert!(
590                path_item.get(route.method).is_some(),
591                "ROUTES has `{} {}` but openapi_spec()'s path item for `{}` has no `{}` operation",
592                route.method,
593                route.probe_path,
594                route.path_template,
595                route.method,
596            );
597        }
598
599        for (path, item) in paths {
600            let methods = item.as_object().unwrap_or_else(|| {
601                panic!("openapi_spec()'s path item for `{path}` is not an object")
602            });
603            for method in methods.keys() {
604                assert!(
605                    ROUTES
606                        .iter()
607                        .any(|route| route.path_template == path && route.method == method),
608                    "openapi_spec() documents `{method} {path}` but ROUTES has no matching entry"
609                );
610            }
611        }
612    }
613}