refactor(server): canonicalize service API (#31049)
This commit is contained in:
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user