fix(httpapi): align runtime query schemas with workspace routing params (#26581)
Co-authored-by: Developer <temp@example.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import { Schema, SchemaGetter } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const ConsoleStateResponse = Schema.Struct({
|
const ConsoleStateResponse = Schema.Struct({
|
||||||
@@ -43,6 +43,7 @@ const ToolListItem = Schema.Struct({
|
|||||||
}).annotate({ identifier: "ToolListItem" })
|
}).annotate({ identifier: "ToolListItem" })
|
||||||
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
|
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
|
||||||
export const ToolListQuery = Schema.Struct({
|
export const ToolListQuery = Schema.Struct({
|
||||||
|
...WorkspaceRoutingQueryFields,
|
||||||
provider: ProviderID,
|
provider: ProviderID,
|
||||||
model: ModelID,
|
model: ModelID,
|
||||||
})
|
})
|
||||||
@@ -55,7 +56,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
|||||||
)
|
)
|
||||||
const WorktreeList = Schema.Array(Schema.String)
|
const WorktreeList = Schema.Array(Schema.String)
|
||||||
export const SessionListQuery = Schema.Struct({
|
export const SessionListQuery = Schema.Struct({
|
||||||
directory: Schema.optional(Schema.String),
|
...WorkspaceRoutingQueryFields,
|
||||||
roots: Schema.optional(QueryBoolean),
|
roots: Schema.optional(QueryBoolean),
|
||||||
start: Schema.optional(Schema.NumberFromString),
|
start: Schema.optional(Schema.NumberFromString),
|
||||||
cursor: Schema.optional(Schema.NumberFromString),
|
cursor: Schema.optional(Schema.NumberFromString),
|
||||||
|
|||||||
@@ -5,18 +5,21 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
export const FileQuery = Schema.Struct({
|
export const FileQuery = Schema.Struct({
|
||||||
|
...WorkspaceRoutingQueryFields,
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const FindTextQuery = Schema.Struct({
|
export const FindTextQuery = Schema.Struct({
|
||||||
|
...WorkspaceRoutingQueryFields,
|
||||||
pattern: Schema.String,
|
pattern: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const FindFileQuery = Schema.Struct({
|
export const FindFileQuery = Schema.Struct({
|
||||||
|
...WorkspaceRoutingQueryFields,
|
||||||
query: Schema.String,
|
query: Schema.String,
|
||||||
dirs: Schema.optional(Schema.Literals(["true", "false"])),
|
dirs: Schema.optional(Schema.Literals(["true", "false"])),
|
||||||
type: Schema.optional(Schema.Literals(["file", "directory"])),
|
type: Schema.optional(Schema.Literals(["file", "directory"])),
|
||||||
@@ -26,6 +29,7 @@ export const FindFileQuery = Schema.Struct({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const FindSymbolQuery = Schema.Struct({
|
export const FindSymbolQuery = Schema.Struct({
|
||||||
|
...WorkspaceRoutingQueryFields,
|
||||||
query: Schema.String,
|
query: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const PathInfo = Schema.Struct({
|
const PathInfo = Schema.Struct({
|
||||||
@@ -20,6 +20,7 @@ const PathInfo = Schema.Struct({
|
|||||||
}).annotate({ identifier: "Path" })
|
}).annotate({ identifier: "Path" })
|
||||||
|
|
||||||
export const VcsDiffQuery = Schema.Struct({
|
export const VcsDiffQuery = Schema.Struct({
|
||||||
|
...WorkspaceRoutingQueryFields,
|
||||||
mode: Vcs.Mode,
|
mode: Vcs.Mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { Schema } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||||
import { ApiNotFoundError } from "../errors"
|
import { ApiNotFoundError } from "../errors"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/pty"
|
const root = "/pty"
|
||||||
export const Params = Schema.Struct({ ptyID: PtyID })
|
export const Params = Schema.Struct({ ptyID: PtyID })
|
||||||
export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) })
|
export const CursorQuery = Schema.Struct({
|
||||||
|
...WorkspaceRoutingQueryFields,
|
||||||
|
cursor: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
export const ShellItem = Schema.Struct({
|
export const ShellItem = Schema.Struct({
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Schema, SchemaGetter, Struct } from "effect"
|
|||||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "../middleware/authorization"
|
import { Authorization } from "../middleware/authorization"
|
||||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||||
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
|
||||||
import { ApiNotFoundError } from "../errors"
|
import { ApiNotFoundError } from "../errors"
|
||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
@@ -25,12 +25,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
|
|||||||
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const WorkspaceRoutingQuery = {
|
|
||||||
directory: Schema.optional(Schema.String),
|
|
||||||
workspace: Schema.optional(Schema.String),
|
|
||||||
}
|
|
||||||
export const ListQuery = Schema.Struct({
|
export const ListQuery = Schema.Struct({
|
||||||
...WorkspaceRoutingQuery,
|
...WorkspaceRoutingQueryFields,
|
||||||
scope: Schema.optional(Schema.Literals(["project"])),
|
scope: Schema.optional(Schema.Literals(["project"])),
|
||||||
path: Schema.optional(Schema.String),
|
path: Schema.optional(Schema.String),
|
||||||
roots: Schema.optional(QueryBoolean),
|
roots: Schema.optional(QueryBoolean),
|
||||||
@@ -38,9 +34,12 @@ export const ListQuery = Schema.Struct({
|
|||||||
search: Schema.optional(Schema.String),
|
search: Schema.optional(Schema.String),
|
||||||
limit: Schema.optional(Schema.NumberFromString),
|
limit: Schema.optional(Schema.NumberFromString),
|
||||||
})
|
})
|
||||||
export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
|
export const DiffQuery = Schema.Struct({
|
||||||
|
...WorkspaceRoutingQueryFields,
|
||||||
|
...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]),
|
||||||
|
})
|
||||||
export const MessagesQuery = Schema.Struct({
|
export const MessagesQuery = Schema.Struct({
|
||||||
...WorkspaceRoutingQuery,
|
...WorkspaceRoutingQueryFields,
|
||||||
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
|
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
|
||||||
before: Schema.optional(Schema.String),
|
before: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
|
|||||||
+11
-1
@@ -9,11 +9,21 @@ import * as Fence from "@/server/shared/fence"
|
|||||||
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
|
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
|
||||||
import { NotFoundError } from "@/storage/storage"
|
import { NotFoundError } from "@/storage/storage"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Context, Data, Effect, Layer } from "effect"
|
import { Context, Data, Effect, Layer, Schema } from "effect"
|
||||||
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||||
import * as Socket from "effect/unstable/socket/Socket"
|
import * as Socket from "effect/unstable/socket/Socket"
|
||||||
|
|
||||||
|
// Query fields this middleware reads from the URL. Spread into every
|
||||||
|
// endpoint query schema in groups that apply WorkspaceRoutingMiddleware,
|
||||||
|
// otherwise HttpApi rejects requests carrying these params with 400.
|
||||||
|
// HttpApiMiddleware in effect-smol cannot declare query params today —
|
||||||
|
// remove this once upstream supports middleware-declared query schemas.
|
||||||
|
export const WorkspaceRoutingQueryFields = {
|
||||||
|
directory: Schema.optional(Schema.String),
|
||||||
|
workspace: Schema.optional(Schema.String),
|
||||||
|
}
|
||||||
|
|
||||||
type RemoteTarget = Extract<Target, { type: "remote" }>
|
type RemoteTarget = Extract<Target, { type: "remote" }>
|
||||||
|
|
||||||
type RequestPlan = Data.TaggedEnum<{
|
type RequestPlan = Data.TaggedEnum<{
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { Server } from "../../src/server/server"
|
||||||
|
import { SessionID } from "../../src/session/schema"
|
||||||
|
import { resetDatabase } from "../fixture/db"
|
||||||
|
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||||
|
import { it } from "../lib/effect"
|
||||||
|
|
||||||
|
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||||
|
|
||||||
|
function app() {
|
||||||
|
return Server.Default().app
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(url: string, init?: RequestInit) {
|
||||||
|
return Effect.promise(async () => app().request(url, init))
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTmp<A, E, R>(
|
||||||
|
options: Parameters<typeof tmpdir>[0],
|
||||||
|
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
|
||||||
|
) {
|
||||||
|
return Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir(options)),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
).pipe(Effect.flatMap(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||||
|
await disposeAllInstances()
|
||||||
|
await resetDatabase()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression for the "OpenAPI advertises ?directory&workspace, runtime
|
||||||
|
// rejects them" drift class. Each affected route must accept both params
|
||||||
|
// without 400.
|
||||||
|
describe("httpapi query schema drift", () => {
|
||||||
|
const routingParams = (dir: string) =>
|
||||||
|
`directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}`
|
||||||
|
|
||||||
|
const expectNotSchemaRejection = (status: number, url: string) => {
|
||||||
|
expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"session list accepts directory and workspace",
|
||||||
|
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const url = `/session?${routingParams(tmp.path)}`
|
||||||
|
const response = yield* request(url)
|
||||||
|
expectNotSchemaRejection(response.status, url)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"session messages accepts directory and workspace",
|
||||||
|
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}`
|
||||||
|
const response = yield* request(url)
|
||||||
|
expectNotSchemaRejection(response.status, url)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"file find/file accepts directory and workspace",
|
||||||
|
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const url = `/find/file?query=foo&${routingParams(tmp.path)}`
|
||||||
|
const response = yield* request(url)
|
||||||
|
expectNotSchemaRejection(response.status, url)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"file find/text accepts directory and workspace",
|
||||||
|
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const url = `/find?pattern=foo&${routingParams(tmp.path)}`
|
||||||
|
const response = yield* request(url)
|
||||||
|
expectNotSchemaRejection(response.status, url)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"file read accepts directory and workspace",
|
||||||
|
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const url = `/file?path=foo&${routingParams(tmp.path)}`
|
||||||
|
const response = yield* request(url)
|
||||||
|
expectNotSchemaRejection(response.status, url)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"experimental session list accepts directory and workspace",
|
||||||
|
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const url = `/experimental/session?${routingParams(tmp.path)}`
|
||||||
|
const response = yield* request(url)
|
||||||
|
expectNotSchemaRejection(response.status, url)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"experimental tool list accepts directory and workspace",
|
||||||
|
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}`
|
||||||
|
const response = yield* request(url)
|
||||||
|
expectNotSchemaRejection(response.status, url)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live(
|
||||||
|
"vcs diff accepts directory and workspace",
|
||||||
|
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}`
|
||||||
|
const response = yield* request(url)
|
||||||
|
expectNotSchemaRejection(response.status, url)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user