Fix OpenAPI workspace query drift (#26609)
This commit is contained in:
@@ -38,6 +38,10 @@ import { type Scenario } from "./types"
|
||||
|
||||
void (await import("@opencode-ai/core/util/log")).init({ print: false })
|
||||
|
||||
function cursor(input: Record<string, unknown>) {
|
||||
return Buffer.from(JSON.stringify(input)).toString("base64url")
|
||||
}
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
http.protected
|
||||
.get("/global/health", "global.health")
|
||||
@@ -598,6 +602,64 @@ const scenarios: Scenario[] = [
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session", "v2.session.list.filters")
|
||||
.at((ctx) => ({
|
||||
path: `/api/session?${new URLSearchParams({
|
||||
limit: "2",
|
||||
order: "asc",
|
||||
path: ".",
|
||||
roots: "false",
|
||||
start: "0",
|
||||
search: "missing",
|
||||
directory: ctx.directory ?? "",
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session", "v2.session.list.cursor")
|
||||
.at((ctx) => ({
|
||||
path: `/api/session?${new URLSearchParams({
|
||||
limit: "2",
|
||||
directory: ctx.directory ?? "",
|
||||
cursor: cursor({
|
||||
id: "ses_httpapi_missing",
|
||||
time: 0,
|
||||
order: "desc",
|
||||
direction: "next",
|
||||
directory: ctx.directory,
|
||||
}),
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session", "v2.session.list.cursor.invalid")
|
||||
.at((ctx) => ({
|
||||
path: `/api/session?${new URLSearchParams({
|
||||
cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }),
|
||||
search: "not-allowed-with-cursor",
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(400, undefined, "none"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/context", "v2.session.context")
|
||||
.at((ctx) => ({
|
||||
@@ -620,6 +682,53 @@ const scenarios: Scenario[] = [
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/message", "v2.session.messages.params")
|
||||
.at((ctx) => ({
|
||||
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
|
||||
limit: "2",
|
||||
order: "asc",
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor")
|
||||
.at((ctx) => ({
|
||||
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
|
||||
limit: "2",
|
||||
directory: ctx.directory ?? "",
|
||||
cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }),
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(
|
||||
200,
|
||||
(body) => {
|
||||
object(body)
|
||||
array(body.items)
|
||||
object(body.cursor)
|
||||
},
|
||||
"none",
|
||||
),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid")
|
||||
.at((ctx) => ({
|
||||
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
|
||||
cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }),
|
||||
order: "asc",
|
||||
})}`,
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(400, undefined, "none"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid")
|
||||
.at((ctx) => ({
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
|
||||
import {
|
||||
FilePaths,
|
||||
FileQuery,
|
||||
FindFileQuery,
|
||||
FindTextQuery,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import {
|
||||
ExperimentalPaths,
|
||||
SessionListQuery as ExperimentalSessionListQuery,
|
||||
ToolListQuery,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import {
|
||||
ListQuery as SessionListQuery,
|
||||
MessagesQuery,
|
||||
SessionPaths,
|
||||
} from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
|
||||
import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
type Method = "get" | "post" | "put" | "delete" | "patch"
|
||||
type QuerySchema = { readonly fields: Record<string, unknown> }
|
||||
type OpenApiParameter = { readonly name: string; readonly in: string }
|
||||
type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
|
||||
|
||||
const openApiDriftRoutes = [
|
||||
{ method: "get", path: SessionPaths.list, query: SessionListQuery },
|
||||
{ method: "get", path: SessionPaths.messages, query: MessagesQuery },
|
||||
{ method: "get", path: FilePaths.findFile, query: FindFileQuery },
|
||||
{ method: "get", path: FilePaths.findText, query: FindTextQuery },
|
||||
{ method: "get", path: FilePaths.list, query: FileQuery },
|
||||
{ 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", query: V2SessionsQuery },
|
||||
{ method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
|
||||
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
@@ -27,6 +66,29 @@ function withTmp<A, E, R>(
|
||||
).pipe(Effect.flatMap(fn))
|
||||
}
|
||||
|
||||
function openApiPath(path: string) {
|
||||
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
|
||||
}
|
||||
|
||||
function queryParameters(operation: OpenApiOperation | undefined) {
|
||||
return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
|
||||
}
|
||||
|
||||
function assertAdvertisedQueryParamsAreRuntimeFields(input: {
|
||||
readonly method: Method
|
||||
readonly operation: OpenApiOperation | undefined
|
||||
readonly path: string
|
||||
readonly query: QuerySchema
|
||||
}) {
|
||||
const runtimeFields = new Set(Object.keys(input.query.fields))
|
||||
const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name))
|
||||
|
||||
expect(
|
||||
advertisedOnly,
|
||||
`${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`,
|
||||
).toEqual([])
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
@@ -44,6 +106,38 @@ describe("httpapi query schema drift", () => {
|
||||
expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
|
||||
}
|
||||
|
||||
it.effect(
|
||||
"OpenAPI workspace query params are declared by runtime query schemas",
|
||||
Effect.sync(() => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
for (const route of openApiDriftRoutes) {
|
||||
assertAdvertisedQueryParamsAreRuntimeFields({
|
||||
...route,
|
||||
operation: spec.paths[openApiPath(route.path)]?.[route.method],
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"drift assertion catches spec-only workspace query params",
|
||||
Effect.sync(() => {
|
||||
expect(() =>
|
||||
assertAdvertisedQueryParamsAreRuntimeFields({
|
||||
method: "get",
|
||||
operation: {
|
||||
parameters: [
|
||||
{ name: "directory", in: "query" },
|
||||
{ name: "workspace", in: "query" },
|
||||
],
|
||||
},
|
||||
path: "/fixture",
|
||||
query: { fields: {} },
|
||||
}),
|
||||
).toThrow("advertises query params not accepted by runtime schema")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"session list accepts directory and workspace",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
|
||||
Reference in New Issue
Block a user