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

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