Unify declared instance HTTP API endpoints under typed middleware routing, including event streaming and PTY WebSocket connect handling.\n\nPreserve PTY connect compatibility by checking missing PTYs before parsing optional cursor and ticket query fields, with regression coverage.
226 lines
8.1 KiB
TypeScript
226 lines
8.1 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { OpenApi } from "effect/unstable/httpapi"
|
|
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
|
|
|
type Method = "get" | "post" | "put" | "delete" | "patch"
|
|
type OpenApiSchema = { readonly $ref?: string }
|
|
type OpenApiResponse = {
|
|
readonly description?: string
|
|
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
|
|
}
|
|
type OpenApiOperation = {
|
|
readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }>
|
|
readonly responses?: Record<string, OpenApiResponse>
|
|
readonly security?: unknown
|
|
}
|
|
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
|
|
type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
|
|
|
|
const methods = ["get", "post", "put", "delete", "patch"] as const
|
|
|
|
const allowedV2BuiltInEndpointErrors: string[] = []
|
|
|
|
function v2Operations(spec: OpenApiSpec) {
|
|
return Object.entries(spec.paths).flatMap(([path, item]) =>
|
|
path.startsWith("/api/")
|
|
? methods.flatMap((method) => {
|
|
const operation = item[method]
|
|
return operation ? [{ method, path, operation }] : []
|
|
})
|
|
: [],
|
|
)
|
|
}
|
|
|
|
function responseRef(response: OpenApiResponse | undefined) {
|
|
return response?.content?.["application/json"]?.schema?.$ref
|
|
}
|
|
|
|
function componentName(ref: string) {
|
|
return ref.replace("#/components/schemas/", "")
|
|
}
|
|
|
|
function isBuiltInEndpointError(name: string) {
|
|
return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
|
|
}
|
|
|
|
describe("PublicApi OpenAPI v2 errors", () => {
|
|
test("preserves /api auth responses", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
for (const route of v2Operations(spec)) {
|
|
expect(route.operation.responses?.["401"], `${route.method.toUpperCase()} ${route.path}`).toBeDefined()
|
|
expect(route.operation.security, `${route.method.toUpperCase()} ${route.path}`).toEqual([])
|
|
}
|
|
})
|
|
|
|
test("does not rewrite /api endpoint errors to legacy error components", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
const refs = v2Operations(spec)
|
|
.flatMap((route) =>
|
|
Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
|
|
const ref = responseRef(response)
|
|
return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
|
|
}),
|
|
)
|
|
.filter((entry) => entry.endsWith(" BadRequestError") || entry.endsWith(" NotFoundError"))
|
|
|
|
expect(refs).toEqual([])
|
|
})
|
|
|
|
test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
const builtInEndpointErrors = v2Operations(spec)
|
|
.flatMap((route) =>
|
|
Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
|
|
if (status === "401") return []
|
|
const ref = responseRef(response)
|
|
if (!ref) return []
|
|
const name = componentName(ref)
|
|
return isBuiltInEndpointError(name) ? [`${route.method.toUpperCase()} ${route.path} ${status} ${name}`] : []
|
|
}),
|
|
)
|
|
.sort()
|
|
|
|
expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors)
|
|
})
|
|
|
|
test("documents v2 provider and model catalog errors", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
expect(componentName(responseRef(spec.paths["/api/provider"]?.get?.responses?.["503"]) ?? "")).toBe(
|
|
"ServiceUnavailableError",
|
|
)
|
|
expect(componentName(responseRef(spec.paths["/api/model"]?.get?.responses?.["503"]) ?? "")).toBe(
|
|
"ServiceUnavailableError",
|
|
)
|
|
expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["404"]) ?? "")).toBe(
|
|
"ProviderNotFoundError",
|
|
)
|
|
expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["503"]) ?? "")).toBe(
|
|
"ServiceUnavailableError",
|
|
)
|
|
})
|
|
|
|
test("documents v2 session not-found errors", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
for (const route of [
|
|
["post", "/api/session/{sessionID}/prompt"],
|
|
["post", "/api/session/{sessionID}/compact"],
|
|
["post", "/api/session/{sessionID}/wait"],
|
|
["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",
|
|
)
|
|
}
|
|
})
|
|
|
|
test("documents v2 unfinished session mutation errors", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
for (const route of [
|
|
["post", "/api/session/{sessionID}/prompt"],
|
|
["post", "/api/session/{sessionID}/compact"],
|
|
["post", "/api/session/{sessionID}/wait"],
|
|
] as const) {
|
|
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["503"]) ?? "")).toBe(
|
|
"ServiceUnavailableError",
|
|
)
|
|
}
|
|
})
|
|
|
|
test("documents v2 session read data errors", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
for (const route of [
|
|
["get", "/api/session/{sessionID}/context"],
|
|
["get", "/api/session/{sessionID}/message"],
|
|
] as const) {
|
|
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["500"]) ?? "")).toMatch(
|
|
/^UnknownError\d*$/,
|
|
)
|
|
}
|
|
})
|
|
|
|
test("documents session busy errors", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
for (const route of [
|
|
["post", "/session/{sessionID}/shell"],
|
|
["post", "/session/{sessionID}/revert"],
|
|
["post", "/session/{sessionID}/unrevert"],
|
|
["delete", "/session/{sessionID}/message/{messageID}"],
|
|
] as const) {
|
|
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
|
|
"SessionBusyError",
|
|
)
|
|
}
|
|
})
|
|
|
|
test("documents permission and question not-found errors", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
expect(
|
|
componentName(responseRef(spec.paths["/permission/{requestID}/reply"]?.post?.responses?.["404"]) ?? ""),
|
|
).toBe("PermissionNotFoundError")
|
|
for (const route of [
|
|
["post", "/question/{requestID}/reply"],
|
|
["post", "/question/{requestID}/reject"],
|
|
] as const) {
|
|
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
|
"QuestionNotFoundError",
|
|
)
|
|
}
|
|
})
|
|
|
|
test("documents MCP server not-found errors", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
for (const route of [
|
|
["post", "/mcp/{name}/auth"],
|
|
["post", "/mcp/{name}/auth/authenticate"],
|
|
["post", "/mcp/{name}/auth/callback"],
|
|
["delete", "/mcp/{name}/auth"],
|
|
["post", "/mcp/{name}/connect"],
|
|
["post", "/mcp/{name}/disconnect"],
|
|
] as const) {
|
|
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
|
"McpServerNotFoundError",
|
|
)
|
|
}
|
|
})
|
|
|
|
test("documents PTY resource and ticket errors", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
for (const route of [
|
|
["get", "/pty/{ptyID}"],
|
|
["put", "/pty/{ptyID}"],
|
|
["delete", "/pty/{ptyID}"],
|
|
["post", "/pty/{ptyID}/connect-token"],
|
|
] as const) {
|
|
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
|
|
"PtyNotFoundError",
|
|
)
|
|
}
|
|
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
|
|
"PtyForbiddenError",
|
|
)
|
|
expect(
|
|
spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
|
|
?.filter((parameter) => parameter.in === "query")
|
|
.map((parameter) => parameter.name),
|
|
).toEqual(["directory", "workspace", "cursor", "ticket"])
|
|
})
|
|
|
|
test("documents project not-found errors", () => {
|
|
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
|
|
|
expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe(
|
|
"ProjectNotFoundError",
|
|
)
|
|
})
|
|
})
|