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

This commit is contained in:
Kit Langton
2026-04-27 16:51:24 -04:00
committed by GitHub
parent 139c4fd555
commit acd15dcc8a
6 changed files with 275 additions and 2 deletions

View File

@@ -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." })),
)

View File

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

View File

@@ -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." })),
)

View File

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

View File

@@ -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",
}),
)

View File

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