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}