openapi.test.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Layer, Option } from "effect"
  3. import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  4. import { CodeMode, OpenAPI, Tool } from "../src/index.js"
  5. import { inputTypeScript, outputTypeScript } from "../src/tool-schema.js"
  6. const baseUrl = "http://localhost:4096"
  7. type Document = OpenAPI.Document
  8. type Recorded = {
  9. readonly method: string
  10. readonly url: string
  11. readonly headers: Record<string, string>
  12. readonly body: unknown
  13. }
  14. const opencodeSpec = async (): Promise<Document> => {
  15. return Bun.file(new URL("./fixtures/opencode-v2-openapi.json", import.meta.url)).json() as Promise<Document>
  16. }
  17. const happyPathSpec = async (): Promise<Document> => {
  18. return Bun.file(new URL("./fixtures/openapi-happy-path.json", import.meta.url)).json() as Promise<Document>
  19. }
  20. const isRecord = (value: unknown): value is Record<string, unknown> =>
  21. typeof value === "object" && value !== null && !Array.isArray(value)
  22. const toolAt = (tools: unknown, name: string) =>
  23. name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
  24. const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
  25. const requests: Array<Recorded> = []
  26. const layer = Layer.succeed(HttpClient.HttpClient)(
  27. HttpClient.make((request) =>
  28. Effect.gen(function* () {
  29. const body =
  30. request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined
  31. const url = Option.map(HttpClientRequest.toUrl(request), (resolved) => resolved.toString())
  32. requests.push({
  33. method: request.method,
  34. url: Option.getOrElse(url, () => request.url),
  35. headers: { ...request.headers },
  36. body,
  37. })
  38. return HttpClientResponse.fromWeb(request, respond(request))
  39. }),
  40. ),
  41. )
  42. return { requests, layer }
  43. }
  44. const json = (value: unknown, status = 200) =>
  45. new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
  46. const singleOperation = (operation: Record<string, unknown>, method = "get"): Document => ({
  47. openapi: "3.1.0",
  48. paths: {
  49. "/test": { [method]: { operationId: "test", responses: { 200: { description: "Success" } }, ...operation } },
  50. },
  51. })
  52. describe("OpenAPI.fromSpec", () => {
  53. test("covers a representative API from generation through execution", async () => {
  54. const resolutions: Array<string> = []
  55. const client = recordingClient((request) => {
  56. const url = Option.getOrElse(HttpClientRequest.toUrl(request), () => new URL(request.url))
  57. if (request.method === "POST") {
  58. return new Response(
  59. JSON.stringify({ id: "user-2", name: "Grace", email: "grace@example.test", role: "admin" }),
  60. { status: 201, headers: { "content-type": "application/vnd.example+json" } },
  61. )
  62. }
  63. if (request.method === "DELETE") return new Response(null, { status: 204 })
  64. if (url.pathname === "/search") {
  65. return new Response("2 matches", { headers: { "content-type": "text/plain" } })
  66. }
  67. return json({ id: "user-1", name: "Ada", email: "ada@example.test", role: "member" })
  68. })
  69. const api = OpenAPI.fromSpec({
  70. spec: await happyPathSpec(),
  71. baseUrl,
  72. auth: {
  73. resolve: ({ name }) => {
  74. resolutions.push(name)
  75. return Effect.succeed(
  76. name === "BearerAuth"
  77. ? { type: "bearer", token: "bearer-secret" }
  78. : { type: "apiKey", value: "api-secret" },
  79. )
  80. },
  81. },
  82. })
  83. const get = toolAt(api.tools, "users.get")
  84. const create = toolAt(api.tools, "users.create")
  85. const search = toolAt(api.tools, "search.run")
  86. const remove = toolAt(api.tools, "users.remove")
  87. expect(api.skipped).toEqual([])
  88. if (
  89. !Tool.isDefinition(get) ||
  90. !Tool.isDefinition(create) ||
  91. !Tool.isDefinition(search) ||
  92. !Tool.isDefinition(remove)
  93. ) {
  94. throw new Error("happy-path fixture did not generate every operation")
  95. }
  96. expect(inputTypeScript(get)).toBe(
  97. '{ userId: string; include?: Array<string>; verbose?: boolean; "X-Trace-ID"?: string }',
  98. )
  99. expect(inputTypeScript(create)).toBe('{ name: string; email: string; role?: "admin" | "member" }')
  100. expect(inputTypeScript(search)).toBe("{ filter?: { query: string; page?: number }; tags?: Array<string> }")
  101. expect(inputTypeScript(remove)).toBe("{ userId: string }")
  102. expect(outputTypeScript(get)).toContain("id: string")
  103. expect(outputTypeScript(create)).toContain('role?: "admin" | "member"')
  104. expect(outputTypeScript(search)).toBe("string")
  105. expect(outputTypeScript(remove)).toBe("null")
  106. const result = await Effect.runPromise(
  107. CodeMode.make({ tools: { api: api.tools } })
  108. .execute(
  109. `
  110. const user = await tools.api.users.get({
  111. userId: "user-1",
  112. include: ["profile", "permissions"],
  113. verbose: true,
  114. "X-Trace-ID": "trace-1",
  115. })
  116. const created = await tools.api.users.create({
  117. name: "Grace",
  118. email: "grace@example.test",
  119. role: "admin",
  120. })
  121. const summary = await tools.api.search.run({
  122. filter: { query: "effect", page: 2 },
  123. tags: ["typescript", "runtime"],
  124. })
  125. const removed = await tools.api.users.remove({ userId: "user-1" })
  126. return { user, created, summary, removed }
  127. `,
  128. )
  129. .pipe(Effect.provide(client.layer)),
  130. )
  131. expect(result).toMatchObject({
  132. ok: true,
  133. value: {
  134. user: { id: "user-1", name: "Ada" },
  135. created: { id: "user-2", name: "Grace" },
  136. summary: "2 matches",
  137. removed: null,
  138. },
  139. })
  140. expect(resolutions).toEqual(["BearerAuth", "ApiKey", "BearerAuth"])
  141. expect(client.requests).toHaveLength(4)
  142. const getUrl = new URL(client.requests[0]!.url)
  143. expect(getUrl.pathname).toBe("/users/user-1")
  144. expect(getUrl.searchParams.get("include")).toBe("profile,permissions")
  145. expect(getUrl.searchParams.get("verbose")).toBe("true")
  146. expect(client.requests[0]!.headers["x-trace-id"]).toBe("trace-1")
  147. expect(client.requests[0]!.headers.authorization).toBe("Bearer bearer-secret")
  148. const createUrl = new URL(client.requests[1]!.url)
  149. expect(createUrl.searchParams.get("api_key")).toBe("api-secret")
  150. expect(client.requests[1]!.body).toEqual({ name: "Grace", email: "grace@example.test", role: "admin" })
  151. const searchUrl = new URL(client.requests[2]!.url)
  152. expect(searchUrl.searchParams.get("filter[query]")).toBe("effect")
  153. expect(searchUrl.searchParams.get("filter[page]")).toBe("2")
  154. expect(searchUrl.searchParams.getAll("tags")).toEqual(["typescript", "runtime"])
  155. expect(client.requests[2]!.headers.authorization).toBeUndefined()
  156. expect(new URL(client.requests[3]!.url).pathname).toBe("/users/user-1")
  157. expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret")
  158. })
  159. test("converts representative opencode operations into the expected tool shape", async () => {
  160. const spec = await opencodeSpec()
  161. const result = OpenAPI.fromSpec({ spec, baseUrl })
  162. expect(result.skipped).toHaveLength(5)
  163. expect(result.skipped).toContainEqual({
  164. method: "GET",
  165. path: "/api/pty/{ptyID}/connect",
  166. reason: "WebSocket operations are not supported",
  167. })
  168. expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3)
  169. expect(result.skipped).toContainEqual({
  170. method: "GET",
  171. path: "/api/fs/read/*",
  172. reason: "binary responses are not supported",
  173. })
  174. expect(toolAt(result.tools, "v2.health.get")).not.toBeUndefined()
  175. expect(toolAt(result.tools, "v2.session.get")).not.toBeUndefined()
  176. expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
  177. const sessionGet = toolAt(result.tools, "v2.session.get")
  178. expect(Tool.isDefinition(sessionGet)).toBe(true)
  179. if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated")
  180. expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
  181. expect(outputTypeScript(sessionGet)).toContain("id: string")
  182. expect(outputTypeScript(sessionGet)).toContain("additions: number")
  183. const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
  184. expect(Tool.isDefinition(switchAgent)).toBe(true)
  185. if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
  186. expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
  187. const contextEntryPut = toolAt(result.tools, "v2.session.contextEntry.put")
  188. expect(Tool.isDefinition(contextEntryPut)).toBe(true)
  189. if (!Tool.isDefinition(contextEntryPut)) throw new Error("v2.session.contextEntry.put was not generated")
  190. expect(inputTypeScript(contextEntryPut)).toBe("{ sessionID: string; key: string; value: unknown }")
  191. expect(toolAt(result.tools, "v2_session_context_entry_put_2")).toBeUndefined()
  192. expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined()
  193. expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
  194. expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
  195. expect(toolAt(result.tools, "v2.event.changes")).toBeUndefined()
  196. expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
  197. expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined()
  198. })
  199. test("preserves operation path sanitization and collision handling", () => {
  200. const response = { responses: { 200: { description: "Success" } } }
  201. const result = OpenAPI.fromSpec({
  202. baseUrl,
  203. spec: {
  204. openapi: "3.1.0",
  205. paths: {
  206. "/first": { get: { ...response, operationId: "group.item" } },
  207. "/second": { get: { ...response, operationId: "group.item" } },
  208. "/third": { get: { ...response, operationId: "group..other" } },
  209. },
  210. },
  211. })
  212. expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true)
  213. expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true)
  214. expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true)
  215. })
  216. test("synthesizes flat operation IDs from methods and paths", () => {
  217. const response = { responses: { 200: { description: "Success" } } }
  218. const tools = OpenAPI.fromSpec({
  219. baseUrl,
  220. spec: {
  221. openapi: "3.1.0",
  222. paths: {
  223. "/users": { get: response, post: response },
  224. "/users/{id}": { get: response, patch: response, delete: response },
  225. "/organizations/{organizationId}/users/{id}": { get: response },
  226. },
  227. },
  228. }).tools
  229. for (const path of [
  230. "getUsers",
  231. "postUsers",
  232. "getUsersById",
  233. "patchUsersById",
  234. "deleteUsersById",
  235. "getOrganizationsByOrganizationidUsersById",
  236. ]) {
  237. expect(Tool.isDefinition(toolAt(tools, path))).toBe(true)
  238. }
  239. })
  240. test("lets operation parameters override matching path parameters", () => {
  241. const tool = toolAt(
  242. OpenAPI.fromSpec({
  243. baseUrl,
  244. spec: {
  245. openapi: "3.1.0",
  246. paths: {
  247. "/test": {
  248. parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
  249. get: {
  250. operationId: "test",
  251. parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
  252. responses: { 200: { description: "Success" } },
  253. },
  254. },
  255. },
  256. },
  257. }).tools,
  258. "test",
  259. )
  260. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  261. expect(inputTypeScript(tool)).toBe("{ limit: number }")
  262. })
  263. test("normalizes OpenAPI 3.0 schemas with Effect", () => {
  264. const result = OpenAPI.fromSpec({
  265. baseUrl,
  266. spec: {
  267. openapi: "3.0.3",
  268. paths: {
  269. "/search": {
  270. get: {
  271. operationId: "search",
  272. parameters: [
  273. {
  274. in: "query",
  275. name: "value",
  276. schema: { type: "string", nullable: true, minLength: 2 },
  277. },
  278. ],
  279. responses: { 200: { description: "Success" } },
  280. },
  281. },
  282. },
  283. },
  284. })
  285. const search = toolAt(result.tools, "search")
  286. expect(Tool.isDefinition(search)).toBe(true)
  287. if (!Tool.isDefinition(search)) throw new Error("search was not generated")
  288. expect(inputTypeScript(search)).toBe("{ value?: string | null }")
  289. const schema: unknown = search.input
  290. const input = isRecord(schema) ? schema : {}
  291. const properties = isRecord(input.properties) ? input.properties : {}
  292. const value = isRecord(properties.value) ? properties.value : {}
  293. expect(value.minLength).toBe(2)
  294. })
  295. test("preserves schema-local definitions alongside component definitions", () => {
  296. const tool = toolAt(
  297. OpenAPI.fromSpec({
  298. baseUrl,
  299. spec: {
  300. openapi: "3.1.0",
  301. paths: {
  302. "/test": {
  303. get: {
  304. operationId: "test",
  305. responses: {
  306. 200: {
  307. description: "Success",
  308. content: {
  309. "application/json": {
  310. schema: { $ref: "#/$defs/Local", $defs: { Local: { type: "string" } } },
  311. },
  312. },
  313. },
  314. },
  315. },
  316. },
  317. },
  318. components: { schemas: { Global: { type: "number" } } },
  319. },
  320. }).tools,
  321. "test",
  322. )
  323. if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
  324. expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
  325. })
  326. test("documents that the opencode fixture is unauthenticated", async () => {
  327. const spec = await opencodeSpec()
  328. const components = isRecord(spec.components) ? spec.components : {}
  329. const result = OpenAPI.fromSpec({ spec, baseUrl })
  330. expect(spec.security).toStrictEqual([])
  331. expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
  332. const health = toolAt(result.tools, "v2.health.get")
  333. const healthInput = isRecord(health) ? health.input : undefined
  334. expect(healthInput).toMatchObject({ type: "object", properties: {} })
  335. const input = isRecord(healthInput) ? healthInput : {}
  336. expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
  337. })
  338. test("exposes real opencode operations through CodeMode discovery", async () => {
  339. const { layer } = recordingClient(() => json({}))
  340. const runtime = CodeMode.make({
  341. tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
  342. })
  343. const result = await Effect.runPromise(
  344. runtime
  345. .execute(
  346. `
  347. return await tools.$codemode.search({ query: "global health", namespace: "opencode", limit: 1 })
  348. `,
  349. )
  350. .pipe(Effect.provide(layer)),
  351. )
  352. expect(result).toMatchObject({ ok: true })
  353. if (!result.ok) return
  354. expect(result.value).toMatchObject({
  355. items: [
  356. {
  357. path: "tools.opencode.v2.health.get",
  358. description: "Check whether the API server is ready to accept requests.",
  359. },
  360. ],
  361. })
  362. expect(JSON.stringify(result.value)).toContain("healthy: true")
  363. })
  364. test("invokes real opencode path parameters and JSON request bodies", async () => {
  365. const { requests, layer } = recordingClient((request) => {
  366. if (request.method === "GET") return json({ id: "ses_123" })
  367. return json({ id: "ses_456" })
  368. })
  369. const runtime = CodeMode.make({
  370. tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
  371. })
  372. const result = await Effect.runPromise(
  373. runtime
  374. .execute(
  375. `
  376. const existing = await tools.opencode.v2.session.get({ sessionID: "ses_123" })
  377. const created = await tools.opencode.v2.session.create({ id: "ses_456" })
  378. return { existing, created }
  379. `,
  380. )
  381. .pipe(Effect.provide(layer)),
  382. )
  383. expect(result).toMatchObject({ ok: true })
  384. expect(requests).toHaveLength(2)
  385. expect(requests[0]).toMatchObject({ method: "GET", body: undefined })
  386. expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_123")
  387. expect(requests[1]).toMatchObject({
  388. method: "POST",
  389. url: "http://localhost:4096/api/session",
  390. body: { id: "ses_456" },
  391. })
  392. })
  393. test("serializes deep-object query parameters from the opencode fixture", async () => {
  394. const client = recordingClient(() => json({ directory: "/tmp" }))
  395. const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
  396. if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated")
  397. await Effect.runPromise(
  398. location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)),
  399. )
  400. const url = new URL(client.requests[0]!.url)
  401. expect(url.searchParams.get("location[directory]")).toBe("/tmp")
  402. expect(url.searchParams.get("location[workspace]")).toBe("workspace-1")
  403. })
  404. test("serializes supported simple and form parameter shapes", async () => {
  405. const client = recordingClient(() => json({ ok: true }))
  406. const result = OpenAPI.fromSpec({
  407. baseUrl,
  408. spec: {
  409. openapi: "3.1.0",
  410. paths: {
  411. "/items/{keys}": {
  412. get: {
  413. operationId: "items",
  414. parameters: [
  415. { name: "keys", in: "path", required: true, schema: { type: "array", items: { type: "string" } } },
  416. { name: "tags", in: "query", style: "form", explode: false, schema: { type: "array" } },
  417. { name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } },
  418. { name: "nullable", in: "query", required: true, schema: { type: ["string", "null"] } },
  419. { name: "constructor", in: "query", schema: { type: "string" } },
  420. { name: "meta", in: "header", style: "simple", explode: true, schema: { type: "object" } },
  421. ],
  422. responses: { 200: { description: "Success" } },
  423. },
  424. },
  425. },
  426. },
  427. })
  428. const tool = toolAt(result.tools, "items")
  429. if (!Tool.isDefinition(tool)) throw new Error("items was not generated")
  430. await Effect.runPromise(
  431. tool
  432. .run({
  433. keys: ["a!", "b*"],
  434. tags: ["x", "y"],
  435. filter: { state: "open", page: 2 },
  436. nullable: null,
  437. constructor_2: "safe",
  438. meta: { a: "b", c: "d" },
  439. })
  440. .pipe(Effect.provide(client.layer)),
  441. )
  442. const url = new URL(client.requests[0]!.url)
  443. expect(url.pathname).toBe("/items/a%21,b%2A")
  444. expect(url.searchParams.get("tags")).toBe("x,y")
  445. expect(url.searchParams.get("state")).toBe("open")
  446. expect(url.searchParams.get("page")).toBe("2")
  447. expect(url.searchParams.get("nullable")).toBe("null")
  448. expect(url.searchParams.get("constructor")).toBe("safe")
  449. expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
  450. await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
  451. "unsupported nested value",
  452. )
  453. })
  454. test("skips unsupported parameter encodings and malformed security", () => {
  455. const result = OpenAPI.fromSpec({
  456. baseUrl,
  457. spec: {
  458. openapi: "3.1.0",
  459. security: [{ bearer: [] }],
  460. paths: {
  461. "/cookie": {
  462. get: {
  463. operationId: "cookie",
  464. parameters: [{ name: "session", in: "cookie", schema: { type: "string" } }],
  465. responses: { 200: { description: "Success" } },
  466. },
  467. },
  468. "/reserved": {
  469. get: {
  470. operationId: "reserved",
  471. parameters: [{ name: "query", in: "query", allowReserved: true, schema: { type: "string" } }],
  472. responses: { 200: { description: "Success" } },
  473. },
  474. },
  475. "/invalid-style": {
  476. get: {
  477. operationId: "invalidStyle",
  478. parameters: [{ name: "query", in: "query", style: 42, schema: { type: "string" } }],
  479. responses: { 200: { description: "Success" } },
  480. },
  481. },
  482. "/security": {
  483. get: { operationId: "security", security: null, responses: { 200: { description: "Success" } } },
  484. },
  485. },
  486. },
  487. })
  488. expect(result.tools).toEqual({})
  489. expect(result.skipped.map((item) => item.reason)).toEqual([
  490. "cookie parameter 'session' is not supported",
  491. "parameter 'query' uses unsupported allowReserved encoding",
  492. "parameter 'query' has an invalid style",
  493. "security declaration is not an array",
  494. ])
  495. })
  496. test("fails closed on prototype-named missing security schemes", () => {
  497. const result = OpenAPI.fromSpec({
  498. baseUrl,
  499. spec: singleOperation({ security: [JSON.parse('{"__proto__":[]}')] }),
  500. })
  501. expect(result.tools).toEqual({})
  502. expect(result.skipped[0]?.reason).toBe("security requirement references missing or malformed scheme: __proto__")
  503. })
  504. test("resolves bearer authentication without exposing it as input", async () => {
  505. const contexts: Array<Parameters<OpenAPI.AuthResolver>[0]> = []
  506. const client = recordingClient(() => json({ ok: true }))
  507. const spec = {
  508. ...singleOperation({ operationId: undefined }),
  509. security: [{ bearer: [] }],
  510. components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } },
  511. } satisfies Document
  512. const tool = toolAt(
  513. OpenAPI.fromSpec({
  514. baseUrl,
  515. spec,
  516. auth: {
  517. resolve: (context) => {
  518. contexts.push(context)
  519. return Effect.succeed({ type: "bearer", token: "secret" })
  520. },
  521. },
  522. }).tools,
  523. "getTest",
  524. )
  525. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  526. await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
  527. expect(inputTypeScript(tool)).toBe("{}")
  528. expect(client.requests[0]!.headers.authorization).toBe("Bearer secret")
  529. expect(contexts).toEqual([
  530. {
  531. name: "bearer",
  532. definition: { type: "http", scheme: "bearer" },
  533. scopes: [],
  534. operation: {
  535. operationId: undefined,
  536. method: "GET",
  537. path: "/test",
  538. summary: undefined,
  539. description: undefined,
  540. },
  541. },
  542. ])
  543. })
  544. test("applies authentication carriers without prototype or collision loss", async () => {
  545. const client = recordingClient(() => json({ ok: true }))
  546. const authenticated = (
  547. security: ReadonlyArray<Record<string, ReadonlyArray<string>>>,
  548. schemes: Record<string, unknown>,
  549. ) =>
  550. OpenAPI.fromSpec({
  551. baseUrl,
  552. spec: { ...singleOperation({}), security, components: { securitySchemes: schemes } },
  553. auth: { resolve: () => Effect.succeed({ type: "apiKey", value: "secret" }) },
  554. })
  555. const prototype = toolAt(
  556. authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools,
  557. "test",
  558. )
  559. if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated")
  560. await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer)))
  561. expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
  562. const duplicate = toolAt(
  563. authenticated([{ first: [], second: [] }], {
  564. first: { type: "apiKey", in: "header", name: "x-key" },
  565. second: { type: "apiKey", in: "header", name: "x-key" },
  566. }).tools,
  567. "test",
  568. )
  569. if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
  570. await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
  571. "multiple credentials",
  572. )
  573. const cookie = authenticated([{ key: [] }], { key: { type: "apiKey", in: "cookie", name: "session" } })
  574. expect(cookie.tools).toEqual({})
  575. expect(cookie.skipped[0]?.reason).toBe("cookie authentication 'key' is not supported")
  576. const alternative = OpenAPI.fromSpec({
  577. baseUrl,
  578. spec: {
  579. ...singleOperation({}),
  580. security: [{ cookie: [] }, { bearer: [] }],
  581. components: {
  582. securitySchemes: {
  583. cookie: { type: "apiKey", in: "cookie", name: "session" },
  584. bearer: { type: "http", scheme: "bearer" },
  585. },
  586. },
  587. },
  588. auth: {
  589. resolve: ({ name }) => Effect.succeed(name === "bearer" ? { type: "bearer", token: "secret" } : undefined),
  590. },
  591. })
  592. const alternativeTool = toolAt(alternative.tools, "test")
  593. if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated")
  594. await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer)))
  595. expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret")
  596. })
  597. test("honors server precedence and rejects ambiguous base URLs", async () => {
  598. const client = recordingClient(() => json({ ok: true }))
  599. const spec = {
  600. ...singleOperation({ servers: [{ url: "https://operation.example/v1" }] }),
  601. servers: [{ url: "https://document.example" }],
  602. } satisfies Document
  603. const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test")
  604. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  605. await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
  606. expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
  607. const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
  608. expect(invalid.tools).toEqual({})
  609. expect(invalid.skipped[0]?.reason).toContain("unsupported query string or fragment")
  610. const malformed = OpenAPI.fromSpec({ spec, baseUrl: "https:/example.com" })
  611. expect(malformed.tools).toEqual({})
  612. expect(malformed.skipped[0]?.reason).toContain("not an absolute HTTP(S) URL")
  613. })
  614. test("resolves chained response refs before detecting unsupported transports", () => {
  615. const result = OpenAPI.fromSpec({
  616. baseUrl,
  617. spec: {
  618. ...singleOperation({ responses: { 200: { $ref: "#/components/responses/First" } } }),
  619. components: {
  620. responses: {
  621. First: { $ref: "#/components/responses/Stream" },
  622. Stream: { content: { "text/event-stream": { schema: { type: "string" } } } },
  623. },
  624. },
  625. },
  626. })
  627. expect(result.tools).toEqual({})
  628. expect(result.skipped[0]?.reason).toBe("SSE operations are not supported")
  629. })
  630. test("resolves response schemas before detecting binary output", () => {
  631. const result = OpenAPI.fromSpec({
  632. baseUrl,
  633. spec: {
  634. ...singleOperation({
  635. responses: {
  636. 200: {
  637. content: { "text/plain": { schema: { $ref: "#/components/schemas/File" } } },
  638. },
  639. },
  640. }),
  641. components: { schemas: { File: { type: "string", format: "binary" } } },
  642. },
  643. })
  644. expect(result.tools).toEqual({})
  645. expect(result.skipped[0]?.reason).toBe("binary responses are not supported")
  646. })
  647. test("validates composite parameters before resolving auth", async () => {
  648. const resolutions: Array<string> = []
  649. const client = recordingClient(() => json({ ok: true }))
  650. const tool = toolAt(
  651. OpenAPI.fromSpec({
  652. baseUrl,
  653. spec: {
  654. ...singleOperation({
  655. parameters: [{ name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } }],
  656. }),
  657. security: [{ bearer: [] }],
  658. components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } },
  659. },
  660. auth: {
  661. resolve: ({ name }) => {
  662. resolutions.push(name)
  663. return Effect.succeed({ type: "bearer", token: "secret" })
  664. },
  665. },
  666. }).tools,
  667. "test",
  668. )
  669. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  670. await expect(
  671. Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
  672. ).rejects.toThrow("unsupported nested value")
  673. expect(resolutions).toEqual([])
  674. expect(client.requests).toEqual([])
  675. })
  676. test("preserves JSON media types and rejects unencodable bodies", async () => {
  677. const client = recordingClient(() => json({ ok: true }))
  678. const tool = toolAt(
  679. OpenAPI.fromSpec({
  680. baseUrl,
  681. spec: singleOperation(
  682. {
  683. requestBody: {
  684. required: true,
  685. content: { "application/merge-patch+json": { schema: { type: "object" } } },
  686. },
  687. },
  688. "post",
  689. ),
  690. }).tools,
  691. "test",
  692. )
  693. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  694. await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
  695. expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json")
  696. const cyclic: Record<string, unknown> = {}
  697. cyclic.self = cyclic
  698. await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
  699. "Invalid JSON body",
  700. )
  701. })
  702. test("rejects oversized and malformed JSON responses", async () => {
  703. const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test")
  704. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  705. const oversized = recordingClient(
  706. () => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
  707. )
  708. const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
  709. const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
  710. await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
  711. "response exceeds 50 MiB",
  712. )
  713. await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
  714. "returned malformed JSON",
  715. )
  716. await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
  717. "response exceeds 50 MiB",
  718. )
  719. })
  720. test("keeps non-JSON responses raw and unions every success output", async () => {
  721. const spec = singleOperation({
  722. responses: {
  723. 200: { description: "Text", content: { "text/plain": { schema: { type: "string" } } } },
  724. 204: { description: "Empty" },
  725. },
  726. })
  727. const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test")
  728. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  729. const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } }))
  730. expect(outputTypeScript(tool)).toBe("string | null")
  731. await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
  732. })
  733. test("fails missing required parameters before auth and network", async () => {
  734. const { requests, layer } = recordingClient(() => json({}))
  735. const runtime = CodeMode.make({
  736. tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
  737. })
  738. const result = await Effect.runPromise(
  739. runtime.execute("return await tools.opencode.v2.session.get({})").pipe(Effect.provide(layer)),
  740. )
  741. expect(result).toMatchObject({ ok: false })
  742. expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'")
  743. expect(requests).toHaveLength(0)
  744. })
  745. test("prefixes cross-location collisions and reconstructs the HTTP request", async () => {
  746. const spec = {
  747. openapi: "3.1.0",
  748. info: { title: "collision", version: "1.0.0" },
  749. paths: {
  750. "/echo": {
  751. post: {
  752. operationId: "echo",
  753. requestBody: {
  754. required: true,
  755. content: { "application/json": { schema: { type: "string" } } },
  756. },
  757. responses: { "204": { description: "Echoed" } },
  758. },
  759. },
  760. "/things/{id}": {
  761. post: {
  762. operationId: "things.update",
  763. parameters: [
  764. { name: "id", in: "path", required: true, schema: { type: "string" } },
  765. { name: "id", in: "query", required: true, schema: { type: "string" } },
  766. { name: "path_id", in: "query", schema: { type: "string" } },
  767. { name: "id", in: "header", required: true, schema: { type: "string" } },
  768. ],
  769. requestBody: {
  770. required: true,
  771. content: {
  772. "application/json": {
  773. schema: {
  774. type: "object",
  775. properties: { id: { type: "string" } },
  776. required: ["id"],
  777. additionalProperties: false,
  778. },
  779. },
  780. },
  781. },
  782. responses: { "204": { description: "Updated" } },
  783. },
  784. },
  785. },
  786. } satisfies Document
  787. const { requests, layer } = recordingClient(() => new Response(null, { status: 204 }))
  788. const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools
  789. const update = toolAt(tools, "things.update")
  790. const echo = toolAt(tools, "echo")
  791. expect(Tool.isDefinition(update)).toBe(true)
  792. if (!Tool.isDefinition(update)) throw new Error("things.update was not generated")
  793. expect(inputTypeScript(update)).toBe(
  794. "{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }",
  795. )
  796. expect(Tool.isDefinition(echo)).toBe(true)
  797. if (!Tool.isDefinition(echo)) throw new Error("echo was not generated")
  798. expect(inputTypeScript(echo)).toBe("{ body: string }")
  799. const runtime = CodeMode.make({ tools })
  800. const result = await Effect.runPromise(
  801. runtime
  802. .execute(
  803. `
  804. const updated = await tools.things.update({ path_id: "path", query_id: "query", path_id_2: "literal", header_id: "header", body_id: "body" })
  805. const echoed = await tools.echo({ body: "hello" })
  806. return { updated, echoed }
  807. `,
  808. )
  809. .pipe(Effect.provide(layer)),
  810. )
  811. expect(result).toMatchObject({ ok: true })
  812. expect(requests).toHaveLength(2)
  813. expect(new URL(requests[0]!.url).pathname).toBe("/things/path")
  814. expect(new URL(requests[0]!.url).searchParams.get("id")).toBe("query")
  815. expect(new URL(requests[0]!.url).searchParams.get("path_id")).toBe("literal")
  816. expect(requests[0]!.headers.id).toBe("header")
  817. expect(requests[0]!.body).toStrictEqual({ id: "body" })
  818. expect(requests[1]!.body).toBe("hello")
  819. })
  820. test("keeps bodies nested when flattening would lose schema semantics", () => {
  821. const body = (schema: Record<string, unknown>, required = true) => ({
  822. required,
  823. content: { "application/json": { schema } },
  824. })
  825. const spec = {
  826. openapi: "3.1.0",
  827. info: { title: "bodies", version: "1.0.0" },
  828. paths: Object.fromEntries(
  829. [
  830. [
  831. "optional",
  832. body(
  833. {
  834. type: "object",
  835. properties: { name: { type: "string" } },
  836. required: ["name"],
  837. additionalProperties: false,
  838. },
  839. false,
  840. ),
  841. ],
  842. ["dictionary", body({ type: "object", additionalProperties: { type: "string" } })],
  843. [
  844. "composed",
  845. body({
  846. type: "object",
  847. allOf: [{ type: "object", properties: { name: { type: "string" } }, required: ["name"] }],
  848. additionalProperties: false,
  849. }),
  850. ],
  851. [
  852. "nullable",
  853. body({
  854. type: ["object", "null"],
  855. properties: { name: { type: "string" } },
  856. additionalProperties: false,
  857. }),
  858. ],
  859. ].map(([name, requestBody]) => [
  860. `/body/${name}`,
  861. {
  862. post: {
  863. operationId: `body.${name}`,
  864. requestBody,
  865. responses: { "204": { description: "Accepted" } },
  866. },
  867. },
  868. ]),
  869. ),
  870. } satisfies Document
  871. const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools
  872. for (const name of ["optional", "dictionary", "composed", "nullable"]) {
  873. const tool = toolAt(tools, `body.${name}`)
  874. expect(Tool.isDefinition(tool)).toBe(true)
  875. if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`)
  876. const input = isRecord(tool.input) ? tool.input : {}
  877. expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"])
  878. }
  879. const optional = toolAt(tools, "body.optional")
  880. if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated")
  881. expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }")
  882. })
  883. })