feat(server): Server.openapi() backed by HttpApi spec, parity-checked against Hono output (#25545)
This commit is contained in:
@@ -1506,7 +1506,7 @@ const main = Effect.gen(function* () {
|
|||||||
const options = parseOptions(Bun.argv.slice(2))
|
const options = parseOptions(Bun.argv.slice(2))
|
||||||
const modules = yield* Effect.promise(() => runtime())
|
const modules = yield* Effect.promise(() => runtime())
|
||||||
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
|
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
|
||||||
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapi()))
|
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono()))
|
||||||
const selected = scenarios.filter((scenario) => matches(options, scenario))
|
const selected = scenarios.filter((scenario) => matches(options, scenario))
|
||||||
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
|
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
|
||||||
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))
|
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import { Server } from "../../server/server"
|
import { Server } from "../../server/server"
|
||||||
import { PublicApi } from "../../server/routes/instance/httpapi/public"
|
|
||||||
import type { CommandModule } from "yargs"
|
import type { CommandModule } from "yargs"
|
||||||
import { OpenApi } from "effect/unstable/httpapi"
|
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
httpapi: boolean
|
httpapi: boolean
|
||||||
|
hono: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GenerateCommand = {
|
export const GenerateCommand = {
|
||||||
command: "generate",
|
command: "generate",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs.option("httpapi", {
|
yargs
|
||||||
type: "boolean",
|
.option("httpapi", {
|
||||||
default: false,
|
type: "boolean",
|
||||||
description: "Generate OpenAPI from the experimental Effect HttpApi contract",
|
default: false,
|
||||||
}),
|
description:
|
||||||
|
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
|
||||||
|
})
|
||||||
|
.option("hono", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
|
||||||
|
}),
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi()
|
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
|
||||||
for (const item of Object.values(specs.paths)) {
|
for (const item of Object.values(specs.paths)) {
|
||||||
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
||||||
const operation = item[method]
|
const operation = item[method]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { lazy } from "@/util/lazy"
|
|||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { WorkspaceID } from "@/control-plane/schema"
|
import { WorkspaceID } from "@/control-plane/schema"
|
||||||
|
import { OpenApi } from "effect/unstable/httpapi"
|
||||||
import { MDNS } from "./mdns"
|
import { MDNS } from "./mdns"
|
||||||
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||||
import { FenceMiddleware } from "./fence"
|
import { FenceMiddleware } from "./fence"
|
||||||
@@ -17,6 +18,7 @@ import { WorkspaceRouterMiddleware } from "./workspace"
|
|||||||
import { InstanceMiddleware } from "./routes/instance/middleware"
|
import { InstanceMiddleware } from "./routes/instance/middleware"
|
||||||
import { WorkspaceRoutes } from "./routes/control/workspace"
|
import { WorkspaceRoutes } from "./routes/control/workspace"
|
||||||
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
|
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
|
||||||
|
import { PublicApi } from "./routes/instance/httpapi/public"
|
||||||
import * as ServerBackend from "./backend"
|
import * as ServerBackend from "./backend"
|
||||||
import type { CorsOptions } from "./cors"
|
import type { CorsOptions } from "./cors"
|
||||||
|
|
||||||
@@ -135,7 +137,30 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the OpenAPI document used by the SDK build.
|
||||||
|
*
|
||||||
|
* Since the Effect HttpApi backend now covers every Hono route (plus the new
|
||||||
|
* `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity
|
||||||
|
* audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`.
|
||||||
|
* `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi`
|
||||||
|
* transform that injects instance query parameters, strips Effect's optional
|
||||||
|
* null arms, normalizes component names, and patches SSE response schemas so
|
||||||
|
* the generated SDK keeps the legacy Hono shape.
|
||||||
|
*
|
||||||
|
* The Hono-derived spec is still reachable via `openapiHono()` so reviewers
|
||||||
|
* can diff the two outputs while the Hono backend lingers; once the Hono
|
||||||
|
* backend is deleted that helper goes with it.
|
||||||
|
*/
|
||||||
export async function openapi() {
|
export async function openapi() {
|
||||||
|
return OpenApi.fromApi(PublicApi)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hono-derived OpenAPI spec, retained for parity diffing only. Delete once
|
||||||
|
* the Hono backend is removed.
|
||||||
|
*/
|
||||||
|
export async function openapiHono() {
|
||||||
// Build a fresh app with all routes registered directly so
|
// Build a fresh app with all routes registered directly so
|
||||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||||
// handlers when the sub-app has a custom errorHandler, which
|
// handlers when the sub-app has a custom errorHandler, which
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ describe("HttpApi server", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
|
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
|
||||||
const honoRoutes = openApiRouteKeys(await Server.openapi())
|
const honoRoutes = openApiRouteKeys(await Server.openapiHono())
|
||||||
const effectRoutes = openApiRouteKeys(effectOpenApi())
|
const effectRoutes = openApiRouteKeys(effectOpenApi())
|
||||||
|
|
||||||
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
|
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
|
||||||
@@ -237,7 +237,7 @@ describe("HttpApi server", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("matches generated OpenAPI route parameters", async () => {
|
test("matches generated OpenAPI route parameters", async () => {
|
||||||
const hono = openApiParameters(await Server.openapi())
|
const hono = openApiParameters(await Server.openapiHono())
|
||||||
const effect = openApiParameters(effectOpenApi())
|
const effect = openApiParameters(effectOpenApi())
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -248,7 +248,7 @@ describe("HttpApi server", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("matches generated OpenAPI request body shape", async () => {
|
test("matches generated OpenAPI request body shape", async () => {
|
||||||
const hono = openApiRequestBodies(await Server.openapi())
|
const hono = openApiRequestBodies(await Server.openapiHono())
|
||||||
const effect = openApiRequestBodies(effectOpenApi())
|
const effect = openApiRequestBodies(effectOpenApi())
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ afterEach(async () => {
|
|||||||
|
|
||||||
describe("tui HttpApi bridge", () => {
|
describe("tui HttpApi bridge", () => {
|
||||||
test("documents legacy bad request responses", async () => {
|
test("documents legacy bad request responses", async () => {
|
||||||
const legacy = await Server.openapi()
|
const legacy = await Server.openapiHono()
|
||||||
const effect = OpenApi.fromApi(TuiApi)
|
const effect = OpenApi.fromApi(TuiApi)
|
||||||
for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) {
|
for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) {
|
||||||
expect(legacy.paths[path].post?.responses?.[400]).toBeDefined()
|
expect(legacy.paths[path].post?.responses?.[400]).toBeDefined()
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { createClient } from "@hey-api/openapi-ts"
|
|||||||
const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi"
|
const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi"
|
||||||
const opencode = path.resolve(dir, "../../opencode")
|
const opencode = path.resolve(dir, "../../opencode")
|
||||||
|
|
||||||
|
// `bun dev generate` now derives the spec from the Effect HttpApi contract by
|
||||||
|
// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs.
|
||||||
if (openapiSource === "httpapi") {
|
if (openapiSource === "httpapi") {
|
||||||
await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode)
|
|
||||||
} else {
|
|
||||||
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
|
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
|
||||||
|
} else {
|
||||||
|
await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode)
|
||||||
}
|
}
|
||||||
|
|
||||||
await createClient({
|
await createClient({
|
||||||
|
|||||||
Reference in New Issue
Block a user