Просмотр исходного кода

Narrow HTTP API numeric query overrides (#26618)

Kit Langton 3 месяцев назад
Родитель
Сommit
6e78f36a0f

+ 1 - 1
packages/opencode/specs/openapi-translation-cleanup.md

@@ -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:

+ 17 - 15
packages/opencode/src/server/routes/instance/httpapi/public.ts

@@ -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.*" }

+ 37 - 1
packages/opencode/test/server/httpapi-query-schema-drift.test.ts

@@ -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(() => {