httpapi-query-schema-drift.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { OpenApi } from "effect/unstable/httpapi"
  4. import { Flag } from "@opencode-ai/core/flag/flag"
  5. import { Server } from "../../src/server/server"
  6. import { SessionID } from "../../src/session/schema"
  7. import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
  8. import {
  9. FilePaths,
  10. FileQuery,
  11. FindFileQuery,
  12. FindTextQuery,
  13. } from "../../src/server/routes/instance/httpapi/groups/file"
  14. import {
  15. ExperimentalPaths,
  16. SessionListQuery as ExperimentalSessionListQuery,
  17. ToolListQuery,
  18. } from "../../src/server/routes/instance/httpapi/groups/experimental"
  19. import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance"
  20. import {
  21. ListQuery as SessionListQuery,
  22. MessagesQuery,
  23. SessionPaths,
  24. } from "../../src/server/routes/instance/httpapi/groups/session"
  25. import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
  26. import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session"
  27. import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query"
  28. import { resetDatabase } from "../fixture/db"
  29. import { disposeAllInstances, tmpdir } from "../fixture/fixture"
  30. import { it } from "../lib/effect"
  31. const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
  32. type Method = "get" | "post" | "put" | "delete" | "patch"
  33. type QuerySchema = { readonly fields: Record<string, unknown> }
  34. type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string }
  35. type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema }
  36. type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
  37. const openApiDriftRoutes = [
  38. { method: "get", path: SessionPaths.list, query: SessionListQuery },
  39. { method: "get", path: SessionPaths.messages, query: MessagesQuery },
  40. { method: "get", path: FilePaths.findFile, query: FindFileQuery },
  41. { method: "get", path: FilePaths.findText, query: FindTextQuery },
  42. { method: "get", path: FilePaths.list, query: FileQuery },
  43. { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
  44. { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
  45. { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
  46. { method: "get", path: "/api/session", query: V2SessionsQuery },
  47. { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
  48. ] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
  49. const numericSdkQueryParams = [
  50. { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } },
  51. { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } },
  52. { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } },
  53. { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
  54. { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } },
  55. { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } },
  56. {
  57. method: "get",
  58. path: SessionPaths.messages,
  59. name: "limit",
  60. schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
  61. },
  62. { method: "get", path: "/api/session", name: "limit", schema: { type: "number" } },
  63. { method: "get", path: "/api/session", name: "start", schema: { type: "number" } },
  64. { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
  65. ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
  66. function app() {
  67. return Server.Default().app
  68. }
  69. function request(url: string, init?: RequestInit) {
  70. return Effect.promise(async () => app().request(url, init))
  71. }
  72. function withTmp<A, E, R>(
  73. options: Parameters<typeof tmpdir>[0],
  74. fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
  75. ) {
  76. return Effect.acquireRelease(
  77. Effect.promise(() => tmpdir(options)),
  78. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  79. ).pipe(Effect.flatMap(fn))
  80. }
  81. function openApiPath(path: string) {
  82. return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
  83. }
  84. function queryParameters(operation: OpenApiOperation | undefined) {
  85. return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
  86. }
  87. function queryParameter(operation: OpenApiOperation | undefined, name: string) {
  88. return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name)
  89. }
  90. function assertAdvertisedQueryParamsAreRuntimeFields(input: {
  91. readonly method: Method
  92. readonly operation: OpenApiOperation | undefined
  93. readonly path: string
  94. readonly query: QuerySchema
  95. }) {
  96. const runtimeFields = new Set(Object.keys(input.query.fields))
  97. const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name))
  98. expect(
  99. advertisedOnly,
  100. `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`,
  101. ).toEqual([])
  102. }
  103. afterEach(async () => {
  104. Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
  105. await disposeAllInstances()
  106. await resetDatabase()
  107. })
  108. // Regression for the "OpenAPI advertises ?directory&workspace, runtime
  109. // rejects them" drift class. Each affected route must accept both params
  110. // without 400.
  111. describe("httpapi query schema drift", () => {
  112. const routingParams = (dir: string) =>
  113. `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}`
  114. const expectNotSchemaRejection = (status: number, url: string) => {
  115. expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
  116. }
  117. it.effect(
  118. "boolean query schema accepts only true and false strings",
  119. Effect.sync(() => {
  120. const decode = Schema.decodeUnknownSync(QueryBoolean)
  121. const encode = Schema.encodeUnknownSync(QueryBoolean)
  122. expect(decode("true")).toBe(true)
  123. expect(decode("false")).toBe(false)
  124. expect(encode(true)).toBe("true")
  125. expect(encode(false)).toBe("false")
  126. for (const input of ["1", "yes", "True", "", true, false]) {
  127. expect(() => decode(input)).toThrow()
  128. }
  129. }),
  130. )
  131. it.effect(
  132. "OpenAPI workspace query params are declared by runtime query schemas",
  133. Effect.sync(() => {
  134. const spec = OpenApi.fromApi(PublicApi)
  135. for (const route of openApiDriftRoutes) {
  136. assertAdvertisedQueryParamsAreRuntimeFields({
  137. ...route,
  138. operation: spec.paths[openApiPath(route.path)]?.[route.method],
  139. })
  140. }
  141. }),
  142. )
  143. it.effect(
  144. "OpenAPI numeric query params preserve generated SDK call shapes",
  145. Effect.sync(() => {
  146. const spec = OpenApi.fromApi(PublicApi)
  147. for (const expected of numericSdkQueryParams) {
  148. expect(
  149. queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
  150. `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
  151. ).toEqual(expected.schema)
  152. }
  153. }),
  154. )
  155. it.effect(
  156. "drift assertion catches spec-only workspace query params",
  157. Effect.sync(() => {
  158. expect(() =>
  159. assertAdvertisedQueryParamsAreRuntimeFields({
  160. method: "get",
  161. operation: {
  162. parameters: [
  163. { name: "directory", in: "query" },
  164. { name: "workspace", in: "query" },
  165. ],
  166. },
  167. path: "/fixture",
  168. query: { fields: {} },
  169. }),
  170. ).toThrow("advertises query params not accepted by runtime schema")
  171. }),
  172. )
  173. it.live(
  174. "session list accepts directory and workspace",
  175. withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
  176. Effect.gen(function* () {
  177. const url = `/session?${routingParams(tmp.path)}`
  178. const response = yield* request(url)
  179. expectNotSchemaRejection(response.status, url)
  180. }),
  181. ),
  182. )
  183. it.live(
  184. "session messages accepts directory and workspace",
  185. withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
  186. Effect.gen(function* () {
  187. const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}`
  188. const response = yield* request(url)
  189. expectNotSchemaRejection(response.status, url)
  190. }),
  191. ),
  192. )
  193. it.live(
  194. "file find/file accepts directory and workspace",
  195. withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
  196. Effect.gen(function* () {
  197. const url = `/find/file?query=foo&${routingParams(tmp.path)}`
  198. const response = yield* request(url)
  199. expectNotSchemaRejection(response.status, url)
  200. }),
  201. ),
  202. )
  203. it.live(
  204. "file find/text accepts directory and workspace",
  205. withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
  206. Effect.gen(function* () {
  207. const url = `/find?pattern=foo&${routingParams(tmp.path)}`
  208. const response = yield* request(url)
  209. expectNotSchemaRejection(response.status, url)
  210. }),
  211. ),
  212. )
  213. it.live(
  214. "file read accepts directory and workspace",
  215. withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
  216. Effect.gen(function* () {
  217. const url = `/file?path=foo&${routingParams(tmp.path)}`
  218. const response = yield* request(url)
  219. expectNotSchemaRejection(response.status, url)
  220. }),
  221. ),
  222. )
  223. it.live(
  224. "experimental session list accepts directory and workspace",
  225. withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
  226. Effect.gen(function* () {
  227. const url = `/experimental/session?${routingParams(tmp.path)}`
  228. const response = yield* request(url)
  229. expectNotSchemaRejection(response.status, url)
  230. }),
  231. ),
  232. )
  233. it.live(
  234. "experimental tool list accepts directory and workspace",
  235. withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
  236. Effect.gen(function* () {
  237. const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}`
  238. const response = yield* request(url)
  239. expectNotSchemaRejection(response.status, url)
  240. }),
  241. ),
  242. )
  243. it.live(
  244. "vcs diff accepts directory and workspace",
  245. withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
  246. Effect.gen(function* () {
  247. const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}`
  248. const response = yield* request(url)
  249. expectNotSchemaRejection(response.status, url)
  250. }),
  251. ),
  252. )
  253. })