refactor(server): canonicalize service API (#31049)

This commit is contained in:
Dax
2026-06-06 23:27:28 -04:00
committed by GitHub
parent 53ff1b57c9
commit fe0c4f8c74
388 changed files with 7103 additions and 4092 deletions
@@ -7,8 +7,8 @@ import { ServerAuth } from "../../src/server/auth"
import {
Authorization,
authorizationLayer,
V2Authorization,
v2AuthorizationLayer,
ServerAuthorization,
serverAuthorizationLayer,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { testEffect } from "../lib/effect"
@@ -26,14 +26,14 @@ const Api = HttpApi.make("test-authorization").add(
.middleware(Authorization),
)
const V2Api = HttpApi.make("test-v2-authorization").add(
const ServerApi = HttpApi.make("test-server-authorization").add(
HttpApiGroup.make("test.v2")
.add(
HttpApiEndpoint.get("probe", "/api/probe", {
success: Schema.String,
}),
)
.middleware(V2Authorization),
.middleware(ServerAuthorization),
)
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
@@ -42,7 +42,7 @@ const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
)
const v2Handlers = HttpApiBuilder.group(V2Api, "test.v2", (handlers) =>
const serverHandlers = HttpApiBuilder.group(ServerApi, "test.v2", (handlers) =>
handlers.handle("probe", () => Effect.succeed("ok")),
)
@@ -52,7 +52,7 @@ const apiLayer = HttpRouter.serve(
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
const v2ApiLayer = HttpRouter.serve(
HttpApiBuilder.layer(V2Api).pipe(Layer.provide(v2Handlers), Layer.provide(v2AuthorizationLayer)),
HttpApiBuilder.layer(ServerApi).pipe(Layer.provide(serverHandlers), Layer.provide(serverAuthorizationLayer)),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
@@ -672,6 +672,7 @@ const scenarios: Scenario[] = [
.at((ctx) => ({ path: "/api/fs/read?path=hello.txt", headers: ctx.headers() }))
.json(200, locationData(object)),
http.protected.get("/api/fs/list", "v2.fs.list").json(200, locationData(array)),
http.protected.get("/reference", "reference.list").json(200, array),
http.protected
.get("/api/provider/{providerID}", "v2.provider.get")
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
@@ -687,18 +688,18 @@ const scenarios: Scenario[] = [
array(body.data)
}),
http.protected
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
.get("/api/session/{sessionID}/permission", "v2.session.permission.list")
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
path: route("/api/session/{sessionID}/permission", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, data(array)),
http.protected
.post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
.post("/api/session/{sessionID}/permission/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/permission/request/{requestID}/reply", {
path: route("/api/session/{sessionID}/permission/{requestID}/reply", {
sessionID: ctx.state.id,
requestID: "per_httpapi_missing",
}),
@@ -707,10 +708,10 @@ const scenarios: Scenario[] = [
}))
.json(404, object, "status"),
http.protected
.post("/api/session/{sessionID}/question/request/{requestID}/reply", "v2.session.question.reply")
.post("/api/session/{sessionID}/question/{requestID}/reply", "v2.session.question.reply")
.seeded((ctx) => ctx.session({ title: "Question reply owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/question/request/{requestID}/reply", {
path: route("/api/session/{sessionID}/question/{requestID}/reply", {
sessionID: ctx.state.id,
requestID: "que_httpapi_missing",
}),
@@ -719,10 +720,10 @@ const scenarios: Scenario[] = [
}))
.json(404, object, "status"),
http.protected
.post("/api/session/{sessionID}/question/request/{requestID}/reject", "v2.session.question.reject")
.post("/api/session/{sessionID}/question/{requestID}/reject", "v2.session.question.reject")
.seeded((ctx) => ctx.session({ title: "Question reject owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/question/request/{requestID}/reject", {
path: route("/api/session/{sessionID}/question/{requestID}/reject", {
sessionID: ctx.state.id,
requestID: "que_httpapi_missing",
}),
@@ -840,8 +841,9 @@ const scenarios: Scenario[] = [
.json(404, object, "status"),
http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid")
.seeded((ctx) => ctx.session({ title: "Invalid message cursor owner" }))
.at((ctx) => ({
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
path: `${route("/api/session/{sessionID}/message", { sessionID: ctx.state.id })}?${new URLSearchParams({
cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }),
order: "asc",
})}`,
@@ -850,8 +852,9 @@ const scenarios: Scenario[] = [
.status(400, undefined, "none"),
http.protected
.post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid")
.seeded((ctx) => ctx.session({ title: "Invalid prompt owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/prompt", { sessionID: "ses_httpapi_missing" }),
path: route("/api/session/{sessionID}/prompt", { sessionID: ctx.state.id }),
headers: ctx.headers(),
body: {},
}))
@@ -58,7 +58,9 @@ function componentName(ref: string) {
function componentNames(response: OpenApiResponse | undefined) {
const schema = response?.content?.["application/json"]?.schema
if (!schema) return []
return [schema, ...(schema.anyOf ?? [])].flatMap((item) => (item.$ref ? [componentName(item.$ref)] : []))
return [
...new Set([schema, ...(schema.anyOf ?? [])].flatMap((item) => (item.$ref ? [componentName(item.$ref)] : []))),
]
}
function isBuiltInEndpointError(name: string) {
@@ -110,8 +112,8 @@ describe("PublicApi OpenAPI v2 errors", () => {
for (const path of [
"/api/session/{sessionID}/prompt",
"/api/session/{sessionID}/permission/request/{requestID}/reply",
"/api/session/{sessionID}/question/request/{requestID}/reply",
"/api/session/{sessionID}/permission/{requestID}/reply",
"/api/session/{sessionID}/question/{requestID}/reply",
]) {
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
}
@@ -175,9 +177,7 @@ describe("PublicApi OpenAPI v2 errors", () => {
["get", "/api/session/{sessionID}/context"],
["get", "/api/session/{sessionID}/message"],
] as const) {
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
"SessionNotFoundError",
)
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toContain("SessionNotFoundError")
}
})
@@ -237,12 +237,12 @@ describe("PublicApi OpenAPI v2 errors", () => {
)
}
for (const route of [
["post", "/api/session/{sessionID}/question/request/{requestID}/reply"],
["post", "/api/session/{sessionID}/question/request/{requestID}/reject"],
["post", "/api/session/{sessionID}/question/{requestID}/reply"],
["post", "/api/session/{sessionID}/question/{requestID}/reject"],
] as const) {
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([
"SessionNotFoundError",
"QuestionNotFoundError",
"SessionNotFoundError",
])
}
})
@@ -24,7 +24,7 @@ import {
SessionPaths,
} from "../../src/server/routes/instance/httpapi/groups/session"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { MessagesQuery as V2MessagesQuery } from "@opencode-ai/server/groups/v2/message"
import { SessionMessagesQuery } from "@opencode-ai/server/groups/message"
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -54,7 +54,7 @@ const openApiDriftRoutes = [
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
{ method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
{ method: "get", path: "/api/session/:sessionID/message", query: SessionMessagesQuery },
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
const numericSdkQueryParams = [
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { Global } from "@opencode-ai/core/global"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("reference HttpApi", () => {
test("lists presentation-safe references resolved in the server workspace", async () => {
await using tmp = await tmpdir({
config: {
formatter: false,
lsp: false,
reference: {
docs: "./docs",
effect: { repository: "Effect-TS/effect", branch: "main" },
bad: "not-a-repo",
},
},
})
const response = await Server.Default().app.request("/reference", {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual([
{
name: "docs",
kind: "local",
path: path.join(tmp.path, "docs"),
},
{
name: "effect",
kind: "git",
repository: "Effect-TS/effect",
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
branch: "main",
},
{
name: "bad",
kind: "invalid",
repository: "not-a-repo",
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
},
])
})
})
@@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
import { validateSession } from "../../src/cli/tui/validate-session"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -643,16 +643,16 @@ describe("session HttpApi", () => {
expect(compact.status).toBe(503)
expect(yield* responseJson(compact)).toEqual({
_tag: "ServiceUnavailableError",
message: "V2 session compact is not available yet",
service: "v2.session.compact",
message: "Session compact is not available yet",
service: "session.compact",
})
const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers })
expect(wait.status).toBe(503)
expect(yield* responseJson(wait)).toEqual({
_tag: "ServiceUnavailableError",
message: "V2 session wait is not available yet",
service: "v2.session.wait",
message: "Session wait is not available yet",
service: "session.wait",
})
}),
{ git: true, config: { formatter: false, lsp: false } },