Narrow HTTP API numeric query overrides (#26618)

This commit is contained in:
Kit Langton
2026-05-09 22:02:51 -04:00
committed by GitHub
parent 16866e1180
commit 6e78f36a0f
3 changed files with 55 additions and 17 deletions
@@ -85,7 +85,7 @@ Verification:
Concrete first targets: Concrete first targets:
- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. - `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform.
- Replace `start` / `cursor` / `limit` reliance on `QueryNumberParameters` with explicit route schema constraints where missing. - `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter.
- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. - Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly.
Verification: Verification:
@@ -54,28 +54,34 @@ type OpenApiResponse = {
// Query schemas describe decoded Effect values, but the generated SDK needs the // Query schemas describe decoded Effect values, but the generated SDK needs the
// public call shape. These keep SDK callers passing numbers/booleans while the // public call shape. These keep SDK callers passing numbers/booleans while the
// server still decodes string query params at runtime. // server still decodes string query params at runtime.
const QueryNumberParameters = new Set(["start", "limit", "method"])
const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryBooleanParameters = new Set(["roots", "archived"])
const QueryParameterSchemas = { const QueryParameterSchemas: Record<string, OpenApiSchema> = {
"GET /experimental/session start": { type: "number" },
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
"GET /experimental/session cursor": { type: "number" }, "GET /experimental/session cursor": { type: "number" },
"GET /experimental/session limit": { type: "number" },
"GET /session start": { type: "number" },
"GET /session limit": { type: "number" },
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
} satisfies Record<string, OpenApiSchema> "GET /api/session limit": { type: "number" },
"GET /api/session start": { type: "number" },
"GET /api/session/{sessionID}/message limit": { type: "number" },
}
const PathParameterSchemas = { const PathParameterSchemas: Record<string, OpenApiSchema> = {
sessionID: { type: "string", pattern: "^ses.*" }, sessionID: { type: "string", pattern: "^ses.*" },
messageID: { type: "string", pattern: "^msg.*" }, messageID: { type: "string", pattern: "^msg.*" },
partID: { type: "string", pattern: "^prt.*" }, partID: { type: "string", pattern: "^prt.*" },
permissionID: { type: "string", pattern: "^per.*" }, permissionID: { type: "string", pattern: "^per.*" },
ptyID: { type: "string", pattern: "^pty.*" }, ptyID: { type: "string", pattern: "^pty.*" },
} satisfies Record<string, OpenApiSchema> }
const LegacyComponentDescriptions = { const LegacyComponentDescriptions: Record<string, string> = {
LogLevel: "Log level", LogLevel: "Log level",
ServerConfig: "Server configuration for opencode serve and web commands", ServerConfig: "Server configuration for opencode serve and web commands",
LayoutConfig: "@deprecated Always uses stretch layout.", LayoutConfig: "@deprecated Always uses stretch layout.",
} satisfies Record<string, string> }
function matchLegacyOpenApi(input: Record<string, unknown>) { function matchLegacyOpenApi(input: Record<string, unknown>) {
const spec = input as OpenApiSpec const spec = input as OpenApiSpec
@@ -269,7 +275,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) {
function normalizeComponentDescriptions(spec: OpenApiSpec) { function normalizeComponentDescriptions(spec: OpenApiSpec) {
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions] const description = LegacyComponentDescriptions[name]
if (description) { if (description) {
schema.description = description schema.description = description
continue continue
@@ -415,7 +421,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) {
} }
} }
// Simplest fix: generate the raw spec (without transform) to get correct schemas // Simplest fix: generate the raw spec (without transform) to get correct schemas
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi)
const rawSchemas = raw.components?.schemas const rawSchemas = raw.components?.schemas
if (!rawSchemas) return if (!rawSchemas) return
for (const name of selfRefs) { for (const name of selfRefs) {
@@ -484,15 +490,11 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
return return
} }
if (param.in === "query") { if (param.in === "query") {
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] const override = QueryParameterSchemas[`${route} ${param.name}`]
if (override) { if (override) {
param.schema = override param.schema = override
return return
} }
if (QueryNumberParameters.has(param.name)) {
param.schema = { type: "number" }
return
}
if (QueryBooleanParameters.has(param.name)) { if (QueryBooleanParameters.has(param.name)) {
param.schema = { param.schema = {
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
@@ -504,7 +506,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
} }
function pathParameterSchema(route: string, name: string) { function pathParameterSchema(route: string, name: string) {
if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] if (name in PathParameterSchemas) return PathParameterSchemas[name]
if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" }
@@ -33,7 +33,8 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
type Method = "get" | "post" | "put" | "delete" | "patch" type Method = "get" | "post" | "put" | "delete" | "patch"
type QuerySchema = { readonly fields: Record<string, unknown> } type QuerySchema = { readonly fields: Record<string, unknown> }
type OpenApiParameter = { readonly name: string; readonly in: string } type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string }
type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema }
type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
const openApiDriftRoutes = [ const openApiDriftRoutes = [
@@ -49,6 +50,24 @@ const openApiDriftRoutes = [
{ method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
] satisfies Array<{ method: Method; path: string; query: QuerySchema }> ] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
const numericSdkQueryParams = [
{ method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } },
{ method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } },
{ method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } },
{ method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
{ method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } },
{ method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } },
{
method: "get",
path: SessionPaths.messages,
name: "limit",
schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
},
{ method: "get", path: "/api/session", name: "limit", schema: { type: "number" } },
{ method: "get", path: "/api/session", name: "start", schema: { type: "number" } },
{ method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
function app() { function app() {
return Server.Default().app return Server.Default().app
} }
@@ -75,6 +94,10 @@ function queryParameters(operation: OpenApiOperation | undefined) {
return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
} }
function queryParameter(operation: OpenApiOperation | undefined, name: string) {
return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name)
}
function assertAdvertisedQueryParamsAreRuntimeFields(input: { function assertAdvertisedQueryParamsAreRuntimeFields(input: {
readonly method: Method readonly method: Method
readonly operation: OpenApiOperation | undefined readonly operation: OpenApiOperation | undefined
@@ -137,6 +160,19 @@ describe("httpapi query schema drift", () => {
}), }),
) )
it.effect(
"OpenAPI numeric query params preserve generated SDK call shapes",
Effect.sync(() => {
const spec = OpenApi.fromApi(PublicApi)
for (const expected of numericSdkQueryParams) {
expect(
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
).toEqual(expected.schema)
}
}),
)
it.effect( it.effect(
"drift assertion catches spec-only workspace query params", "drift assertion catches spec-only workspace query params",
Effect.sync(() => { Effect.sync(() => {