Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions src/openapi/define-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// The route-registration seam (#9519).
//
// One call registers a route on the Hono app AND contributes its OpenAPI operation, from the same
// zod schemas that validate the request at runtime. That single-source property is the whole point:
// today the app's route table (createApp, 241 operations) and the spec's path list
// (buildOpenApiSpec, 151) are separate hand-maintained lists that had drifted apart by 90 routes,
// and request bodies are validated by inline schemas the published document never mentions.
//
// Deliberately a local shim rather than @hono/zod-openapi (decision recorded on #9519): 241 routes
// have to migrate incrementally, and this coexists with plain `app.get(...)` registrations, whereas
// adopting that library means rewriting createApp() wholesale and re-coupling the zod version. The
// emit path reuses the @asteasolutions/zod-to-openapi registry the repo already builds its spec
// with, so nothing about the generated document's shape changes.
import type { Context, Hono, MiddlewareHandler } from "hono";
import type { OpenAPIRegistry, RouteConfig } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";

/**
* Who a caller must be. These are the identity kinds `src/auth/security.ts` actually
* authenticates, so the security stanza emitted into the document is derived from the same
* declaration the runtime gate enforces -- replacing `isProtectedPath()`, a second, path-prefix
* model of the same policy that had already drifted out of agreement with it.
*/
export type RouteAuth = "public" | "token" | "session" | "internal";

export type RouteMethod = "get" | "post" | "put" | "patch" | "delete";

export type DefineRouteOptions<Body extends z.ZodTypeAny | undefined, Query extends z.ZodObject | undefined> = {
method: RouteMethod;
/** Hono-style path (`/v1/repos/:owner/:repo`). Normalized to OpenAPI form for the document. */
path: string;
/** Stable, hand-chosen operation id. Required: a generated client's method names come from these,
* so leaving them to be slugified from method+path makes every path edit a breaking API change
* for consumers. */
operationId: string;
/** At least one tag. The document currently emits `tags: []` everywhere, which collapses every
* operation into one flat namespace in generated clients and doc explorers alike. */
tags: [string, ...string[]];
summary: string;
description?: string;
auth: RouteAuth;
request?: {
body?: Body;
query?: Query;
};
responses: Record<number, { description: string; schema?: z.ZodTypeAny }>;
};

/** Hono writes `:param`; OpenAPI writes `{param}`. */
function toSpecPath(path: string): string {
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
}

/** `public` routes carry no security stanza; everything else accepts either credential the API
* actually supports. Internal routes are bearer-only -- there is no cookie path to them. */
function securityFor(auth: RouteAuth): RouteConfig["security"] {
if (auth === "public") return undefined;
if (auth === "internal") return [{ LoopOverBearer: [] }];
return [{ LoopOverBearer: [] }, { LoopOverSessionCookie: [] }];
}

/**
* The validation error shape.
*
* Matches what the hand-written `.safeParse` call sites in src/api/routes.ts already return
* (`{ error: "invalid_<thing>_request", issues }`), so migrating a route through this seam does not
* change the body a client sees on a 400.
*/
export function invalidRequestBody(kind: string, error: z.ZodError): { error: string; issues: unknown } {
return { error: `invalid_${kind}_request`, issues: error.issues };
}

export type RouteHandlerArgs<Body, Query> = {
body: Body;
query: Query;
};

/**
* Register a route on the app and in the OpenAPI registry from one definition.
*
* The handler receives already-validated `body`/`query` rather than a raw context accessor, so a
* migrated handler cannot forget to parse -- the parse is the only way to reach the handler at all.
*/
export function defineRoute<
AppEnv extends { Bindings: Record<string, unknown>; Variables: Record<string, unknown> },
Body extends z.ZodTypeAny | undefined = undefined,
Query extends z.ZodObject | undefined = undefined,
>(
app: Hono<AppEnv>,
registry: OpenAPIRegistry,
options: DefineRouteOptions<Body, Query>,
handler: (
c: Context<AppEnv>,
args: RouteHandlerArgs<Body extends z.ZodTypeAny ? z.infer<Body> : undefined, Query extends z.ZodObject ? z.infer<Query> : undefined>,
) => Response | Promise<Response>,
): void {
registerRouteSpec(registry, options);

const wrapped: MiddlewareHandler<AppEnv> = async (c) => {
let body: unknown;
if (options.request?.body) {
// A body-carrying route with an unparseable/absent JSON body is a client error, not a 500 --
// matching how the existing hand-written call sites treat `await c.req.json()` failures.
const raw = await c.req.json().catch(() => undefined);
const parsed = (options.request.body as z.ZodTypeAny).safeParse(raw);
if (!parsed.success) return c.json(invalidRequestBody(options.operationId, parsed.error), 400);
body = parsed.data;
}
let query: unknown;
if (options.request?.query) {
const parsed = (options.request.query as z.ZodTypeAny).safeParse(c.req.query());
if (!parsed.success) return c.json(invalidRequestBody(options.operationId, parsed.error), 400);
query = parsed.data;
}
return handler(c as Context<AppEnv>, { body, query } as never);
};

app[options.method](options.path, wrapped);
}

/** Spec-side view of a route definition: the same fields, with the request schemas widened, since
* emitting the document never needs the parsed types the handler does. */
export type RouteSpecOptions = Omit<DefineRouteOptions<z.ZodTypeAny | undefined, z.ZodObject | undefined>, "request"> & {
request?: { body?: z.ZodTypeAny | undefined; query?: z.ZodObject | undefined } | undefined;
};

/** The spec half, exported separately so a route that cannot yet move its handler through the seam
* can still contribute a correct operation and leave the ratchet baseline. */
export function registerRouteSpec(registry: OpenAPIRegistry, options: RouteSpecOptions): void {
const responses: RouteConfig["responses"] = {};
for (const [status, response] of Object.entries(options.responses)) {
responses[Number(status)] = response.schema
? { description: response.description, content: { "application/json": { schema: response.schema } } }
: { description: response.description };
}

const security = securityFor(options.auth);
registry.registerPath({
method: options.method,
path: toSpecPath(options.path),
operationId: options.operationId,
tags: options.tags,
summary: options.summary,
...(options.description ? { description: options.description } : {}),
...(security ? { security } : {}),
...(options.request?.body || options.request?.query
? {
request: {
...(options.request.body ? { body: { content: { "application/json": { schema: options.request.body } } } } : {}),
...(options.request.query ? { query: options.request.query } : {}),
},
}
: {}),
responses,
});
}
81 changes: 81 additions & 0 deletions src/openapi/route-inventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Route↔spec inventory (#9519).
//
// The Worker's route table and its OpenAPI spec are two independently hand-maintained lists:
// createApp() registers the routes Hono actually serves, buildOpenApiSpec() registers the paths the
// published document describes, and until now nothing compared them. The measured gap when this
// landed was 91 live routes with no spec entry -- including every ORB management surface -- and no
// check could detect it, because ui:openapi:check only compares the generated file to its own
// generator, and test/unit/openapi.test.ts is a hand-written allowlist that never enumerates the
// app.
//
// This module is the comparison. It is deliberately pure (no fs, no env) so both the ratchet test
// and any future tooling can use it, and so it stays cheap enough to run on every CI job.
import type { Hono } from "hono";

/** A route as either side describes it, normalized to one comparable string. */
export type RouteKey = string;

/** Methods an OpenAPI path item can carry that this repo actually uses. */
const SPEC_METHODS = ["get", "post", "put", "patch", "delete"] as const;

/**
* Hono writes path params as `:owner`; OpenAPI writes them as `{owner}`. Normalizing to the
* OpenAPI form (rather than the reverse) keeps the baseline file readable as spec paths.
*
* A trailing wildcard (`/v1/foo/*`) has no OpenAPI equivalent at all -- it is a middleware mount or
* a catch-all, not an operation -- so callers filter those out rather than trying to name them.
*/
export function normalizeRoutePath(path: string): string {
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
}

/** True for a Hono entry that is not a documentable operation: middleware registered with `use`,
* `app.all(...)` mounts, and wildcard catch-alls. */
export function isNonOperationRoute(method: string, path: string): boolean {
return method === "ALL" || path.includes("*");
}

/**
* Every operation the app actually serves, as `METHOD /normalized/path`.
*
* Reads Hono's own `routes` array rather than a hand-kept list -- that is the entire point: a route
* added with `app.get(...)` and forgotten everywhere else still shows up here.
*/
export function listLiveRouteKeys(app: Hono<never>): RouteKey[] {
const entries = (app as unknown as { routes: Array<{ method: string; path: string }> }).routes;
const keys = new Set<RouteKey>();
for (const entry of entries) {
if (isNonOperationRoute(entry.method, entry.path)) continue;
keys.add(`${entry.method.toUpperCase()} ${normalizeRoutePath(entry.path)}`);
}
return [...keys].sort();
}

/** Every operation the generated OpenAPI document describes, in the same key format. */
export function listSpecRouteKeys(document: { paths?: Record<string, unknown> }): RouteKey[] {
const keys = new Set<RouteKey>();
for (const [path, item] of Object.entries(document.paths ?? {})) {
if (!item || typeof item !== "object") continue;
for (const method of SPEC_METHODS) {
if ((item as Record<string, unknown>)[method]) keys.add(`${method.toUpperCase()} ${path}`);
}
}
return [...keys].sort();
}

export type RouteSpecDiff = {
/** Live routes with no operation in the spec -- the gap the ratchet drives to zero. */
missingFromSpec: RouteKey[];
/** Spec operations with no live route. These are worse than a gap: the published document
* promises an endpoint that does not exist, so a generated client compiles a call that 404s. */
missingFromApp: RouteKey[];
};

export function diffRoutesAgainstSpec(liveKeys: readonly RouteKey[], specKeys: readonly RouteKey[]): RouteSpecDiff {
const live = new Set(liveKeys);
const spec = new Set(specKeys);
return {
missingFromSpec: liveKeys.filter((key) => !spec.has(key)),
missingFromApp: specKeys.filter((key) => !live.has(key)),
};
}
92 changes: 92 additions & 0 deletions src/openapi/unspecced-routes-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
[
"DELETE /v1/internal/repos/{owner}/{repo}/ai-key",
"DELETE /v1/internal/repos/{owner}/{repo}/linear-key",
"DELETE /v1/repos/{owner}/{repo}/ai-key",
"DELETE /v1/repos/{owner}/{repo}/linear-key",
"GET /loopover/shot",
"GET /openapi.json",
"GET /v1/app/installations",
"GET /v1/app/installations/{id}/health",
"GET /v1/app/installations/{id}/repair",
"GET /v1/app/kill-switch",
"GET /v1/drafts/auth/callback",
"GET /v1/drafts/{id}",
"GET /v1/internal/audit-labels",
"GET /v1/internal/calibration",
"GET /v1/internal/calibration-trend",
"GET /v1/internal/calibration/knobs",
"GET /v1/internal/calibration/satisfaction-floor",
"GET /v1/internal/decision",
"GET /v1/internal/fairness/contributors",
"GET /v1/internal/fairness/contributors/{login}",
"GET /v1/internal/fleet/analytics",
"GET /v1/internal/ops/stats",
"GET /v1/internal/orb/installations",
"GET /v1/internal/orb/instances",
"GET /v1/internal/parity",
"GET /v1/internal/predicted-agreement",
"GET /v1/internal/repos/{owner}/{repo}/ai-key",
"GET /v1/internal/repos/{owner}/{repo}/contribution-policy",
"GET /v1/internal/repos/{owner}/{repo}/linear-key",
"GET /v1/internal/retention/preview",
"GET /v1/internal/status",
"GET /v1/mcp/enrichment-analyzers",
"GET /v1/mcp/finding-taxonomy",
"GET /v1/orb/oauth/callback",
"GET /v1/public/repos/{owner}/{repo}/badge.json",
"GET /v1/public/repos/{owner}/{repo}/badge.svg",
"GET /v1/public/subnet-interface",
"GET /v1/repos/{owner}/{repo}/ai-key",
"GET /v1/repos/{owner}/{repo}/linear-key",
"POST /v1/ams/ingest",
"POST /v1/app/fleet/config-push",
"POST /v1/app/installations/{id}/repair/refresh",
"POST /v1/app/kill-switch",
"POST /v1/app/miner-dashboard/refresh",
"POST /v1/contributors/{login}/ams-notifications",
"POST /v1/drafts",
"POST /v1/internal/audit-labels/adjudicate",
"POST /v1/internal/calibration/loosen-satisfaction-floor",
"POST /v1/internal/jobs/backfill-contributor-gate-history/run",
"POST /v1/internal/jobs/backfill-pr-details/run",
"POST /v1/internal/jobs/backfill-registered-repos/run",
"POST /v1/internal/jobs/backfill-repo-segment/run",
"POST /v1/internal/jobs/build-contributor-decision-packs/run",
"POST /v1/internal/jobs/file-upstream-drift-issues/run",
"POST /v1/internal/jobs/generate-review-recap/run",
"POST /v1/internal/jobs/generate-signal-snapshots/run",
"POST /v1/internal/jobs/generate-weekly-value-report/run",
"POST /v1/internal/jobs/rag-index",
"POST /v1/internal/jobs/refresh-contributor-activity",
"POST /v1/internal/jobs/refresh-contributor-activity/run",
"POST /v1/internal/jobs/refresh-installation-health/run",
"POST /v1/internal/jobs/refresh-registry/run",
"POST /v1/internal/jobs/refresh-scoring-model/run",
"POST /v1/internal/jobs/refresh-upstream-drift/run",
"POST /v1/internal/jobs/regate-pr",
"POST /v1/internal/jobs/rollup-product-usage",
"POST /v1/internal/jobs/rollup-product-usage/run",
"POST /v1/internal/orb/enrollments",
"POST /v1/internal/orb/enrollments/{enrollId}/revoke",
"POST /v1/internal/orb/installations/backfill",
"POST /v1/internal/orb/installations/register",
"POST /v1/internal/orb/instances/register",
"POST /v1/internal/queue-intelligence",
"POST /v1/internal/repos/{owner}/{repo}/ai-key",
"POST /v1/internal/repos/{owner}/{repo}/contribution-policy",
"POST /v1/internal/repos/{owner}/{repo}/linear-key",
"POST /v1/internal/repos/{owner}/{repo}/settings",
"POST /v1/local/remediation-plan",
"POST /v1/loop/request-apr-transfer",
"POST /v1/orb/relay",
"POST /v1/orb/relay/pull",
"POST /v1/orb/relay/register",
"POST /v1/orb/token",
"POST /v1/orb/webhook",
"POST /v1/repos/{owner}/{repo}/ai-key",
"POST /v1/repos/{owner}/{repo}/linear-key",
"POST /v1/repos/{owner}/{repo}/pulls/{number}/chat-qa",
"PUT /v1/app/installations/{id}/agent/bulk-settings",
"PUT /v1/repos/{owner}/{repo}/ai-review",
"PUT /v1/repos/{owner}/{repo}/settings"
]
Loading