httpapi-public-openapi.test.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  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 responses?: Record<string, OpenApiResponse>
  12. readonly security?: unknown
  13. }
  14. type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
  15. type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
  16. const methods = ["get", "post", "put", "delete", "patch"] as const
  17. const allowedV2BuiltInEndpointErrors: string[] = []
  18. function v2Operations(spec: OpenApiSpec) {
  19. return Object.entries(spec.paths).flatMap(([path, item]) =>
  20. path.startsWith("/api/")
  21. ? methods.flatMap((method) => {
  22. const operation = item[method]
  23. return operation ? [{ method, path, operation }] : []
  24. })
  25. : [],
  26. )
  27. }
  28. function responseRef(response: OpenApiResponse | undefined) {
  29. return response?.content?.["application/json"]?.schema?.$ref
  30. }
  31. function componentName(ref: string) {
  32. return ref.replace("#/components/schemas/", "")
  33. }
  34. function isBuiltInEndpointError(name: string) {
  35. return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
  36. }
  37. describe("PublicApi OpenAPI v2 errors", () => {
  38. test("preserves /api auth responses", () => {
  39. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  40. for (const route of v2Operations(spec)) {
  41. expect(route.operation.responses?.["401"], `${route.method.toUpperCase()} ${route.path}`).toBeDefined()
  42. expect(route.operation.security, `${route.method.toUpperCase()} ${route.path}`).toEqual([])
  43. }
  44. })
  45. test("does not rewrite /api endpoint errors to legacy error components", () => {
  46. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  47. const refs = v2Operations(spec)
  48. .flatMap((route) =>
  49. Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
  50. const ref = responseRef(response)
  51. return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
  52. }),
  53. )
  54. .filter((entry) => entry.endsWith(" BadRequestError") || entry.endsWith(" NotFoundError"))
  55. expect(refs).toEqual([])
  56. })
  57. test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => {
  58. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  59. const builtInEndpointErrors = v2Operations(spec)
  60. .flatMap((route) =>
  61. Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
  62. if (status === "401") return []
  63. const ref = responseRef(response)
  64. if (!ref) return []
  65. const name = componentName(ref)
  66. return isBuiltInEndpointError(name) ? [`${route.method.toUpperCase()} ${route.path} ${status} ${name}`] : []
  67. }),
  68. )
  69. .sort()
  70. expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors)
  71. })
  72. test("documents v2 provider and model catalog errors", () => {
  73. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  74. expect(componentName(responseRef(spec.paths["/api/provider"]?.get?.responses?.["503"]) ?? "")).toBe(
  75. "ServiceUnavailableError",
  76. )
  77. expect(componentName(responseRef(spec.paths["/api/model"]?.get?.responses?.["503"]) ?? "")).toBe(
  78. "ServiceUnavailableError",
  79. )
  80. expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["404"]) ?? "")).toBe(
  81. "ProviderNotFoundError",
  82. )
  83. expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["503"]) ?? "")).toBe(
  84. "ServiceUnavailableError",
  85. )
  86. })
  87. test("documents v2 session not-found errors", () => {
  88. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  89. for (const route of [
  90. ["post", "/api/session/{sessionID}/prompt"],
  91. ["post", "/api/session/{sessionID}/compact"],
  92. ["post", "/api/session/{sessionID}/wait"],
  93. ["get", "/api/session/{sessionID}/context"],
  94. ["get", "/api/session/{sessionID}/message"],
  95. ] as const) {
  96. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  97. "SessionNotFoundError",
  98. )
  99. }
  100. })
  101. test("documents v2 unfinished session mutation errors", () => {
  102. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  103. for (const route of [
  104. ["post", "/api/session/{sessionID}/prompt"],
  105. ["post", "/api/session/{sessionID}/compact"],
  106. ["post", "/api/session/{sessionID}/wait"],
  107. ] as const) {
  108. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["503"]) ?? "")).toBe(
  109. "ServiceUnavailableError",
  110. )
  111. }
  112. })
  113. test("documents v2 session read data errors", () => {
  114. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  115. for (const route of [
  116. ["get", "/api/session/{sessionID}/context"],
  117. ["get", "/api/session/{sessionID}/message"],
  118. ] as const) {
  119. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["500"]) ?? "")).toMatch(
  120. /^UnknownError\d*$/,
  121. )
  122. }
  123. })
  124. test("documents session busy errors", () => {
  125. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  126. for (const route of [
  127. ["post", "/session/{sessionID}/shell"],
  128. ["post", "/session/{sessionID}/revert"],
  129. ["post", "/session/{sessionID}/unrevert"],
  130. ["delete", "/session/{sessionID}/message/{messageID}"],
  131. ] as const) {
  132. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
  133. "SessionBusyError",
  134. )
  135. }
  136. })
  137. test("documents permission and question not-found errors", () => {
  138. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  139. expect(
  140. componentName(responseRef(spec.paths["/permission/{requestID}/reply"]?.post?.responses?.["404"]) ?? ""),
  141. ).toBe("PermissionNotFoundError")
  142. for (const route of [
  143. ["post", "/question/{requestID}/reply"],
  144. ["post", "/question/{requestID}/reject"],
  145. ] as const) {
  146. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  147. "QuestionNotFoundError",
  148. )
  149. }
  150. })
  151. test("documents MCP server not-found errors", () => {
  152. const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
  153. for (const route of [
  154. ["post", "/mcp/{name}/auth"],
  155. ["post", "/mcp/{name}/auth/authenticate"],
  156. ["post", "/mcp/{name}/auth/callback"],
  157. ["delete", "/mcp/{name}/auth"],
  158. ["post", "/mcp/{name}/connect"],
  159. ["post", "/mcp/{name}/disconnect"],
  160. ] as const) {
  161. expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
  162. "McpServerNotFoundError",
  163. )
  164. }
  165. })
  166. })