httpapi-public-openapi.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import { describe, expect, test } from "bun:test"
  2. import { OpenApi } from "effect/unstable/httpapi"
  3. import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
  4. type Method = "get" | "post" | "put" | "delete" | "patch"
  5. type OpenApiSchema = { readonly $ref?: string }
  6. type OpenApiResponse = {
  7. readonly description?: string
  8. readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
  9. }
  10. type OpenApiOperation = {
  11. readonly parameters?: ReadonlyArray<{
  12. readonly name: string
  13. readonly in: string
  14. readonly required?: boolean
  15. readonly schema?: { readonly type?: string }
  16. }>
  17. readonly responses?: Record<string, OpenApiResponse>
  18. readonly security?: unknown
  19. }
  20. type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
  21. type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
  22. const methods = ["get", "post", "put", "delete", "patch"] as const
  23. const allowedV2BuiltInEndpointErrors: string[] = []
  24. function v2Operations(spec: OpenApiSpec) {
  25. return Object.entries(spec.paths).flatMap(([path, item]) =>
  26. path.startsWith("/api/")
  27. ? methods.flatMap((method) => {
  28. const operation = item[method]
  29. return operation ? [{ method, path, operation }] : []
  30. })
  31. : [],
  32. )
  33. }
  34. function responseRef(response: OpenApiResponse | undefined) {
  35. return response?.content?.["application/json"]?.schema?.$ref
  36. }
  37. function componentName(ref: string) {
  38. return ref.replace("#/components/schemas/", "")
  39. }
  40. function isBuiltInEndpointError(name: string) {
  41. return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
  42. }
  43. describe("PublicApi OpenAPI v2 errors", () => {
  44. test("preserves /api auth responses", () => {
  45. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  46. for (const route of v2Operations(spec)) {
  47. expect(route.operation.responses?.["401"], `${route.method.toUpperCase()} ${route.path}`).toBeDefined()
  48. expect(route.operation.security, `${route.method.toUpperCase()} ${route.path}`).toEqual([])
  49. }
  50. })
  51. test("documents optional project reference aliases for filesystem reads and lists", () => {
  52. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  53. for (const path of ["/api/fs/read", "/api/fs/list"]) {
  54. expect(spec.paths[path]?.get?.parameters, path).toContainEqual({
  55. in: "query",
  56. name: "reference",
  57. required: false,
  58. schema: { type: "string" },
  59. })
  60. }
  61. })
  62. test("does not rewrite /api endpoint errors to legacy error components", () => {
  63. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  64. const refs = v2Operations(spec)
  65. .flatMap((route) =>
  66. Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
  67. const ref = responseRef(response)
  68. return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
  69. }),
  70. )
  71. .filter((entry) => entry.endsWith(" BadRequestError") || entry.endsWith(" NotFoundError"))
  72. expect(refs).toEqual([])
  73. })
  74. test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => {
  75. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  76. const builtInEndpointErrors = v2Operations(spec)
  77. .flatMap((route) =>
  78. Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
  79. if (status === "401") return []
  80. const ref = responseRef(response)
  81. if (!ref) return []
  82. const name = componentName(ref)
  83. return isBuiltInEndpointError(name) ? [`${route.method.toUpperCase()} ${route.path} ${status} ${name}`] : []
  84. }),
  85. )
  86. .sort()
  87. expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors)
  88. })
  89. test("documents v2 provider and model catalog errors", () => {
  90. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  91. expect(componentName(responseRef(spec.paths["/api/provider"]?.get?.responses?.["503"]) ?? "")).toBe(
  92. "ServiceUnavailableError",
  93. )
  94. expect(componentName(responseRef(spec.paths["/api/model"]?.get?.responses?.["503"]) ?? "")).toBe(
  95. "ServiceUnavailableError",
  96. )
  97. expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["404"]) ?? "")).toBe(
  98. "ProviderNotFoundError",
  99. )
  100. expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["503"]) ?? "")).toBe(
  101. "ServiceUnavailableError",
  102. )
  103. })
  104. test("documents v2 session not-found errors", () => {
  105. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  106. for (const route of [
  107. ["post", "/api/session/{sessionID}/prompt"],
  108. ["post", "/api/session/{sessionID}/compact"],
  109. ["post", "/api/session/{sessionID}/wait"],
  110. ["get", "/api/session/{sessionID}/context"],
  111. ["get", "/api/session/{sessionID}/message"],
  112. ] as const) {
  113. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  114. "SessionNotFoundError",
  115. )
  116. }
  117. })
  118. test("documents v2 unfinished session mutation errors", () => {
  119. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  120. for (const route of [
  121. ["post", "/api/session/{sessionID}/prompt"],
  122. ["post", "/api/session/{sessionID}/compact"],
  123. ["post", "/api/session/{sessionID}/wait"],
  124. ] as const) {
  125. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["503"]) ?? "")).toBe(
  126. "ServiceUnavailableError",
  127. )
  128. }
  129. })
  130. test("documents v2 session read data errors", () => {
  131. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  132. for (const route of [
  133. ["get", "/api/session/{sessionID}/context"],
  134. ["get", "/api/session/{sessionID}/message"],
  135. ] as const) {
  136. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["500"]) ?? "")).toMatch(
  137. /^UnknownError\d*$/,
  138. )
  139. }
  140. })
  141. test("documents session busy errors", () => {
  142. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  143. for (const route of [
  144. ["post", "/session/{sessionID}/shell"],
  145. ["post", "/session/{sessionID}/revert"],
  146. ["post", "/session/{sessionID}/unrevert"],
  147. ["delete", "/session/{sessionID}/message/{messageID}"],
  148. ] as const) {
  149. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
  150. "SessionBusyError",
  151. )
  152. }
  153. })
  154. test("documents permission and question not-found errors", () => {
  155. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  156. expect(
  157. componentName(responseRef(spec.paths["/permission/{requestID}/reply"]?.post?.responses?.["404"]) ?? ""),
  158. ).toBe("PermissionNotFoundError")
  159. for (const route of [
  160. ["post", "/question/{requestID}/reply"],
  161. ["post", "/question/{requestID}/reject"],
  162. ] as const) {
  163. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  164. "QuestionNotFoundError",
  165. )
  166. }
  167. })
  168. test("documents MCP server not-found errors", () => {
  169. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  170. for (const route of [
  171. ["post", "/mcp/{name}/auth"],
  172. ["post", "/mcp/{name}/auth/authenticate"],
  173. ["post", "/mcp/{name}/auth/callback"],
  174. ["delete", "/mcp/{name}/auth"],
  175. ["post", "/mcp/{name}/connect"],
  176. ["post", "/mcp/{name}/disconnect"],
  177. ] as const) {
  178. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  179. "McpServerNotFoundError",
  180. )
  181. }
  182. })
  183. test("documents PTY resource and ticket errors", () => {
  184. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  185. for (const route of [
  186. ["get", "/pty/{ptyID}"],
  187. ["put", "/pty/{ptyID}"],
  188. ["delete", "/pty/{ptyID}"],
  189. ["post", "/pty/{ptyID}/connect-token"],
  190. ] as const) {
  191. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  192. "PtyNotFoundError",
  193. )
  194. }
  195. expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
  196. "PtyForbiddenError",
  197. )
  198. expect(
  199. spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
  200. ?.filter((parameter) => parameter.in === "query")
  201. .map((parameter) => parameter.name),
  202. ).toEqual(["directory", "workspace", "cursor", "ticket"])
  203. })
  204. test("documents project not-found errors", () => {
  205. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  206. expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe(
  207. "ProjectNotFoundError",
  208. )
  209. })
  210. })