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}