Compare commits
3 commits
dev
...
kit/httpap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38b26abfbb | ||
|
|
70169e66bd | ||
|
|
bc63d7a9db |
4 changed files with 125 additions and 1 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { Pty } from "@/pty"
|
import { Pty } from "@/pty"
|
||||||
import { PtyID } from "@/pty/schema"
|
import { PtyID } from "@/pty/schema"
|
||||||
|
import { Shell } from "@/shell/shell"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
|
@ -14,8 +15,14 @@ const Params = Schema.Struct({
|
||||||
const CursorQuery = Schema.Struct({
|
const CursorQuery = Schema.Struct({
|
||||||
cursor: Schema.optional(Schema.String),
|
cursor: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
|
const ShellItem = Schema.Struct({
|
||||||
|
path: Schema.String,
|
||||||
|
name: Schema.String,
|
||||||
|
acceptable: Schema.Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
export const PtyPaths = {
|
export const PtyPaths = {
|
||||||
|
shells: `${root}/shells`,
|
||||||
list: root,
|
list: root,
|
||||||
create: root,
|
create: root,
|
||||||
get: `${root}/:ptyID`,
|
get: `${root}/:ptyID`,
|
||||||
|
|
@ -28,6 +35,15 @@ export const PtyApi = HttpApi.make("pty")
|
||||||
.add(
|
.add(
|
||||||
HttpApiGroup.make("pty")
|
HttpApiGroup.make("pty")
|
||||||
.add(
|
.add(
|
||||||
|
HttpApiEndpoint.get("shells", PtyPaths.shells, {
|
||||||
|
success: Schema.Array(ShellItem),
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "pty.shells",
|
||||||
|
summary: "List available shells",
|
||||||
|
description: "Get a list of available shells on the system.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
HttpApiEndpoint.get("list", PtyPaths.list, {
|
HttpApiEndpoint.get("list", PtyPaths.list, {
|
||||||
success: Schema.Array(Pty.Info),
|
success: Schema.Array(Pty.Info),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
|
|
@ -101,6 +117,10 @@ export const ptyHandlers = Layer.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pty = yield* Pty.Service
|
const pty = yield* Pty.Service
|
||||||
|
|
||||||
|
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
|
||||||
|
return yield* Effect.promise(() => Shell.list())
|
||||||
|
})
|
||||||
|
|
||||||
const list = Effect.fn("PtyHttpApi.list")(function* () {
|
const list = Effect.fn("PtyHttpApi.list")(function* () {
|
||||||
return yield* pty.list()
|
return yield* pty.list()
|
||||||
})
|
})
|
||||||
|
|
@ -143,6 +163,7 @@ export const ptyHandlers = Layer.unwrap(
|
||||||
|
|
||||||
return HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
|
return HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
|
||||||
handlers
|
handlers
|
||||||
|
.handle("shells", shells)
|
||||||
.handle("list", list)
|
.handle("list", list)
|
||||||
.handle("create", create)
|
.handle("create", create)
|
||||||
.handle("get", get)
|
.handle("get", get)
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||||
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
|
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
|
||||||
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
|
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
|
||||||
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
|
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
|
||||||
|
app.get(PtyPaths.shells, (c) => handler(c.req.raw, context))
|
||||||
app.get(PtyPaths.list, (c) => handler(c.req.raw, context))
|
app.get(PtyPaths.list, (c) => handler(c.req.raw, context))
|
||||||
app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
|
app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
|
||||||
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
|
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,24 @@ import type { UpgradeWebSocket } from "hono/ws"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
|
import { WorkspaceRoutes } from "../../src/server/routes/control/workspace"
|
||||||
|
import { ConfigApi } from "../../src/server/routes/instance/httpapi/config"
|
||||||
|
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
|
||||||
|
import { ExperimentalApi } from "../../src/server/routes/instance/httpapi/experimental"
|
||||||
|
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
|
||||||
|
import { InstanceApi } from "../../src/server/routes/instance/httpapi/instance"
|
||||||
|
import { McpApi } from "../../src/server/routes/instance/httpapi/mcp"
|
||||||
|
import { PermissionApi } from "../../src/server/routes/instance/httpapi/permission"
|
||||||
|
import { ProjectApi } from "../../src/server/routes/instance/httpapi/project"
|
||||||
|
import { ProviderApi } from "../../src/server/routes/instance/httpapi/provider"
|
||||||
|
import { PtyApi, PtyPaths } from "../../src/server/routes/instance/httpapi/pty"
|
||||||
|
import { QuestionApi } from "../../src/server/routes/instance/httpapi/question"
|
||||||
|
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 * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
|
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi"
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
|
@ -25,6 +41,43 @@ function app(input?: { password?: string; username?: string }) {
|
||||||
return InstanceRoutes(websocket)
|
return InstanceRoutes(websocket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeKey(route: ReturnType<typeof InstanceRoutes>["routes"][number]) {
|
||||||
|
return `${route.method} ${route.path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function reflectedHttpApiRoutes() {
|
||||||
|
const routes = [
|
||||||
|
`GET ${EventPaths.event}`,
|
||||||
|
`GET ${PtyPaths.connect}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
function addRoutes<Id extends string, Groups extends HttpApiGroup.Any>(api: HttpApi.HttpApi<Id, Groups>) {
|
||||||
|
HttpApi.reflect(api, {
|
||||||
|
onGroup() {},
|
||||||
|
onEndpoint({ endpoint }) {
|
||||||
|
routes.push(`${endpoint.method} ${endpoint.path}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addRoutes(ConfigApi)
|
||||||
|
addRoutes(ExperimentalApi)
|
||||||
|
addRoutes(FileApi)
|
||||||
|
addRoutes(InstanceApi)
|
||||||
|
addRoutes(McpApi)
|
||||||
|
addRoutes(PermissionApi)
|
||||||
|
addRoutes(ProjectApi)
|
||||||
|
addRoutes(ProviderApi)
|
||||||
|
addRoutes(PtyApi)
|
||||||
|
addRoutes(QuestionApi)
|
||||||
|
addRoutes(SessionApi)
|
||||||
|
addRoutes(SyncApi)
|
||||||
|
addRoutes(TuiApi)
|
||||||
|
addRoutes(WorkspaceApi)
|
||||||
|
|
||||||
|
return [...new Set(routes)]
|
||||||
|
}
|
||||||
|
|
||||||
function authorization(username: string, password: string) {
|
function authorization(username: string, password: string) {
|
||||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +99,39 @@ afterEach(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("HttpApi Hono bridge", () => {
|
describe("HttpApi Hono bridge", () => {
|
||||||
|
test("mounts experimental handlers for every legacy instance route", () => {
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
|
||||||
|
const legacy = InstanceRoutes(websocket)
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||||
|
const experimental = InstanceRoutes(websocket)
|
||||||
|
|
||||||
|
const bridge = experimental.routes.slice(0, experimental.routes.length - legacy.routes.length)
|
||||||
|
const workspaceRoutes = WorkspaceRoutes().routes.map((route) => ({
|
||||||
|
...route,
|
||||||
|
path: `/experimental/workspace${route.path === "/" ? "" : route.path}`,
|
||||||
|
}))
|
||||||
|
const legacyRoutes = [...new Set([...legacy.routes, ...workspaceRoutes].map(routeKey))]
|
||||||
|
const bridgeRoutes = new Set(bridge.map(routeKey))
|
||||||
|
|
||||||
|
expect(legacyRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([])
|
||||||
|
expect([...bridgeRoutes].filter((route) => !legacyRoutes.includes(route)).sort()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("mounts every Effect HttpApi route through the Hono bridge", () => {
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
|
||||||
|
const legacy = InstanceRoutes(websocket)
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||||
|
const experimental = InstanceRoutes(websocket)
|
||||||
|
|
||||||
|
const bridgeRoutes = new Set(
|
||||||
|
experimental.routes.slice(0, experimental.routes.length - legacy.routes.length).map(routeKey),
|
||||||
|
)
|
||||||
|
const httpApiRoutes = reflectedHttpApiRoutes()
|
||||||
|
|
||||||
|
expect(httpApiRoutes.filter((route) => !bridgeRoutes.has(route))).toEqual([])
|
||||||
|
expect([...bridgeRoutes].filter((route) => !httpApiRoutes.includes(route)).sort()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test("allows requests when auth is disabled", async () => {
|
test("allows requests when auth is disabled", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
await Bun.write(`${tmp.path}/hello.txt`, "hello")
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,22 @@ afterEach(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("pty HttpApi bridge", () => {
|
describe("pty HttpApi bridge", () => {
|
||||||
|
test("serves available shell list through experimental Effect routes", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const response = await app().request(PtyPaths.shells, { headers: { "x-opencode-directory": tmp.path } })
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
path: expect.any(String),
|
||||||
|
name: expect.any(String),
|
||||||
|
acceptable: expect.any(Boolean),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
testPty("serves PTY JSON routes through experimental Effect routes", async () => {
|
testPty("serves PTY JSON routes through experimental Effect routes", async () => {
|
||||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
const headers = { "x-opencode-directory": tmp.path }
|
const headers = { "x-opencode-directory": tmp.path }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue