summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-27 16:51:24 -0400
committerGitHub <[email protected]>2026-04-27 16:51:24 -0400
commitacd15dcc8ab5a01cf8c0f2ef3e21c71e2450a9f2 (patch)
tree4ccddaf5a099b742e9c5e0b29b317d4e6d26fada
parent139c4fd5555022f1a7cecbe1747d2fdfba7b56d6 (diff)
downloadopencode-acd15dcc8ab5a01cf8c0f2ef3e21c71e2450a9f2.tar.gz
opencode-acd15dcc8ab5a01cf8c0f2ef3e21c71e2450a9f2.zip
test(httpapi): cover full OpenAPI route inventory (#24667)
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/control.ts72
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/event.ts20
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/global.ts102
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/pty.ts19
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/public.ts45
-rw-r--r--packages/opencode/test/server/httpapi-bridge.test.ts19
6 files changed, 275 insertions, 2 deletions
diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/control.ts
new file mode 100644
index 000000000..14cbdf7c4
--- /dev/null
+++ b/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." })),
+ )
diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts
index 78113e976..3194210ce 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/event.ts
+++ b/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`
}
diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts
new file mode 100644
index 000000000..44789b12f
--- /dev/null
+++ b/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." })),
+ )
diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts
index 4e46f30df..21a2dec5c 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts
+++ b/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
diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts
new file mode 100644
index 000000000..1a7f675b3
--- /dev/null
+++ b/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",
+ }),
+ )
diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts
index 8f9170d66..d185dee3b 100644
--- a/packages/opencode/test/server/httpapi-bridge.test.ts
+++ b/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")