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