Răsfoiți Sursa

test(httpapi): cover full OpenAPI route inventory (#24667)

Kit Langton 3 luni în urmă
părinte
comite
acd15dcc8a

+ 72 - 0
packages/opencode/src/server/routes/instance/httpapi/control.ts

@@ -0,0 +1,72 @@
+import { Auth } from "@/auth"
+import { ProviderID } from "@/provider/schema"
+import { Schema } from "effect"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+
+const AuthParams = Schema.Struct({
+  providerID: ProviderID,
+})
+
+const LogQuery = Schema.Struct({
+  directory: Schema.optional(Schema.String),
+  workspace: Schema.optional(Schema.String),
+})
+
+const LogInput = Schema.Struct({
+  service: Schema.String.annotate({ description: "Service name for the log entry" }),
+  level: Schema.Union([
+    Schema.Literal("debug"),
+    Schema.Literal("info"),
+    Schema.Literal("error"),
+    Schema.Literal("warn"),
+  ]).annotate({ description: "Log level" }),
+  message: Schema.String.annotate({ description: "Log message" }),
+  extra: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({
+    description: "Additional metadata for the log entry",
+  }),
+}).annotate({ identifier: "AppLogInput" })
+
+export const ControlPaths = {
+  auth: "/auth/:providerID",
+  log: "/log",
+} as const
+
+export const ControlApi = HttpApi.make("control")
+  .add(
+    HttpApiGroup.make("control")
+      .add(
+        HttpApiEndpoint.put("authSet", ControlPaths.auth, {
+          params: AuthParams,
+          payload: Auth.Info,
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "auth.set",
+            summary: "Set auth credentials",
+            description: "Set authentication credentials",
+          }),
+        ),
+        HttpApiEndpoint.delete("authRemove", ControlPaths.auth, {
+          params: AuthParams,
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "auth.remove",
+            summary: "Remove auth credentials",
+            description: "Remove authentication credentials",
+          }),
+        ),
+        HttpApiEndpoint.post("log", ControlPaths.log, {
+          query: LogQuery,
+          payload: LogInput,
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "app.log",
+            summary: "Write log",
+            description: "Write a log entry to the server logs with specified level and metadata.",
+          }),
+        ),
+      )
+      .annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })),
+  )

+ 19 - 1
packages/opencode/src/server/routes/instance/httpapi/event.ts

@@ -1,8 +1,9 @@
 import { Bus } from "@/bus"
 import * as Log from "@opencode-ai/core/util/log"
-import { Effect } from "effect"
+import { Effect, Schema } from "effect"
 import * as Stream from "effect/Stream"
 import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 
 const log = Log.create({ service: "server" })
 
@@ -10,6 +11,23 @@ export const EventPaths = {
   event: "/event",
 } as const
 
+export const EventApi = HttpApi.make("event")
+  .add(
+    HttpApiGroup.make("event")
+      .add(
+        HttpApiEndpoint.get("subscribe", EventPaths.event, {
+          success: Schema.Unknown,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "event.subscribe",
+            summary: "Subscribe to events",
+            description: "Get events",
+          }),
+        ),
+      )
+      .annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
+  )
+
 function eventData(data: unknown) {
   return `data: ${JSON.stringify(data)}\n\n`
 }

+ 102 - 0
packages/opencode/src/server/routes/instance/httpapi/global.ts

@@ -0,0 +1,102 @@
+import { Config } from "@/config/config"
+import { Schema } from "effect"
+import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+
+const GlobalHealth = Schema.Struct({
+  healthy: Schema.Literal(true),
+  version: Schema.String,
+}).annotate({ identifier: "GlobalHealth" })
+
+const GlobalEvent = Schema.Struct({
+  directory: Schema.String,
+  project: Schema.optional(Schema.String),
+  workspace: Schema.optional(Schema.String),
+  payload: Schema.Unknown,
+}).annotate({ identifier: "GlobalEvent" })
+
+const GlobalUpgradeInput = Schema.Struct({
+  target: Schema.optional(Schema.String),
+}).annotate({ identifier: "GlobalUpgradeInput" })
+
+const GlobalUpgradeResult = Schema.Union([
+  Schema.Struct({
+    success: Schema.Literal(true),
+    version: Schema.String,
+  }),
+  Schema.Struct({
+    success: Schema.Literal(false),
+    error: Schema.String,
+  }),
+]).annotate({ identifier: "GlobalUpgradeResult" })
+
+export const GlobalPaths = {
+  health: "/global/health",
+  event: "/global/event",
+  config: "/global/config",
+  dispose: "/global/dispose",
+  upgrade: "/global/upgrade",
+} as const
+
+export const GlobalApi = HttpApi.make("global")
+  .add(
+    HttpApiGroup.make("global")
+      .add(
+        HttpApiEndpoint.get("health", GlobalPaths.health, {
+          success: GlobalHealth,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "global.health",
+            summary: "Get health",
+            description: "Get health information about the OpenCode server.",
+          }),
+        ),
+        HttpApiEndpoint.get("event", GlobalPaths.event, {
+          success: GlobalEvent,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "global.event",
+            summary: "Get global events",
+            description: "Subscribe to global events from the OpenCode system using server-sent events.",
+          }),
+        ),
+        HttpApiEndpoint.get("configGet", GlobalPaths.config, {
+          success: Config.Info,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "global.config.get",
+            summary: "Get global configuration",
+            description: "Retrieve the current global OpenCode configuration settings and preferences.",
+          }),
+        ),
+        HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
+          payload: Config.Info,
+          success: Config.Info,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "global.config.update",
+            summary: "Update global configuration",
+            description: "Update global OpenCode configuration settings and preferences.",
+          }),
+        ),
+        HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "global.dispose",
+            summary: "Dispose instance",
+            description: "Clean up and dispose all OpenCode instances, releasing all resources.",
+          }),
+        ),
+        HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
+          payload: GlobalUpgradeInput,
+          success: GlobalUpgradeResult,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "global.upgrade",
+            summary: "Upgrade opencode",
+            description: "Upgrade opencode to the specified version or latest if not specified.",
+          }),
+        ),
+      )
+      .annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
+  )

+ 19 - 0
packages/opencode/src/server/routes/instance/httpapi/pty.ts

@@ -113,6 +113,25 @@ export const PtyApi = HttpApi.make("pty")
     }),
   )
 
+export const PtyConnectApi = HttpApi.make("pty-connect")
+  .add(
+    HttpApiGroup.make("pty-connect")
+      .add(
+        HttpApiEndpoint.get("connect", PtyPaths.connect, {
+          params: Params,
+          query: CursorQuery,
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "pty.connect",
+            summary: "Connect to PTY session",
+            description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
+          }),
+        ),
+      )
+      .annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
+  )
+
 export const ptyHandlers = Layer.unwrap(
   Effect.gen(function* () {
     const pty = yield* Pty.Service

+ 45 - 0
packages/opencode/src/server/routes/instance/httpapi/public.ts

@@ -0,0 +1,45 @@
+import { HttpApi, OpenApi } from "effect/unstable/httpapi"
+import { ConfigApi } from "./config"
+import { ControlApi } from "./control"
+import { EventApi } from "./event"
+import { ExperimentalApi } from "./experimental"
+import { FileApi } from "./file"
+import { GlobalApi } from "./global"
+import { InstanceApi } from "./instance"
+import { McpApi } from "./mcp"
+import { PermissionApi } from "./permission"
+import { ProjectApi } from "./project"
+import { ProviderApi } from "./provider"
+import { PtyApi, PtyConnectApi } from "./pty"
+import { QuestionApi } from "./question"
+import { SessionApi } from "./session"
+import { SyncApi } from "./sync"
+import { TuiApi } from "./tui"
+import { WorkspaceApi } from "./workspace"
+
+export const PublicApi = HttpApi.make("opencode")
+  .addHttpApi(ControlApi)
+  .addHttpApi(GlobalApi)
+  .addHttpApi(EventApi)
+  .addHttpApi(ConfigApi)
+  .addHttpApi(ExperimentalApi)
+  .addHttpApi(FileApi)
+  .addHttpApi(InstanceApi)
+  .addHttpApi(McpApi)
+  .addHttpApi(PermissionApi)
+  .addHttpApi(ProjectApi)
+  .addHttpApi(ProviderApi)
+  .addHttpApi(PtyApi)
+  .addHttpApi(PtyConnectApi)
+  .addHttpApi(QuestionApi)
+  .addHttpApi(SessionApi)
+  .addHttpApi(SyncApi)
+  .addHttpApi(TuiApi)
+  .addHttpApi(WorkspaceApi)
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "opencode",
+      version: "1.0.0",
+      description: "opencode api",
+    }),
+  )

+ 18 - 1
packages/opencode/test/server/httpapi-bridge.test.ts

@@ -19,8 +19,10 @@ import { SessionApi } from "../../src/server/routes/instance/httpapi/session"
 import { SyncApi } from "../../src/server/routes/instance/httpapi/sync"
 import { TuiApi } from "../../src/server/routes/instance/httpapi/tui"
 import { WorkspaceApi } from "../../src/server/routes/instance/httpapi/workspace"
+import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
+import { Server } from "../../src/server/server"
 import * as Log from "@opencode-ai/core/util/log"
-import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { resetDatabase } from "../fixture/db"
 import { tmpdir } from "../fixture/fixture"
 
@@ -33,6 +35,7 @@ const original = {
 }
 
 const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
+const methods = ["get", "post", "put", "delete", "patch"] as const
 
 function app(input?: { password?: string; username?: string }) {
   Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
@@ -75,6 +78,12 @@ function reflectedHttpApiRoutes() {
   return [...new Set(routes)]
 }
 
+function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
+  return Object.entries(spec.paths)
+    .flatMap(([path, item]) => methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`))
+    .sort()
+}
+
 function authorization(username: string, password: string) {
   return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
 }
@@ -129,6 +138,14 @@ describe("HttpApi Hono bridge", () => {
     expect([...bridgeRoutes].filter((route) => !httpApiRoutes.includes(route)).sort()).toEqual([])
   })
 
+  test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
+    const honoRoutes = openApiRouteKeys(await Server.openapi())
+    const effectRoutes = openApiRouteKeys(OpenApi.fromApi(PublicApi))
+
+    expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
+    expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([])
+  })
+
   test("allows requests when auth is disabled", async () => {
     await using tmp = await tmpdir({ git: true })
     await Bun.write(`${tmp.path}/hello.txt`, "hello")