Просмотр исходного кода

feat(server): Server.openapi() backed by HttpApi spec, parity-checked against Hono output (#25545)

Kit Langton 4 месяцев назад
Родитель
Сommit
0ee3b87289

+ 1 - 1
packages/opencode/script/httpapi-exercise.ts

@@ -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)))

+ 14 - 8
packages/opencode/src/cli/cmd/generate.ts

@@ -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", {
-      type: "boolean",
-      default: false,
-      description: "Generate OpenAPI from the experimental Effect HttpApi contract",
-    }),
+    yargs
+      .option("httpapi", {
+        type: "boolean",
+        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]

+ 25 - 0
packages/opencode/src/server/server.ts

@@ -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

+ 3 - 3
packages/opencode/test/server/httpapi-bridge.test.ts

@@ -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(

+ 1 - 1
packages/opencode/test/server/httpapi-tui.test.ts

@@ -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()

+ 4 - 2
packages/sdk/js/script/build.ts

@@ -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({