Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/control.ts
Original file line number Diff line number Diff line change
@@ -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." })),
)
20 changes: 19 additions & 1 deletion packages/opencode/src/server/routes/instance/httpapi/event.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
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" })

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`
}
Expand Down
102 changes: 102 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/global.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/public.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
)
19 changes: 18 additions & 1 deletion packages/opencode/test/server/httpapi-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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")}`
}
Expand Down Expand Up @@ -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")
Expand Down
Loading