openapi.test.ts 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596
  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. const directionalSpec = (openapi: string): Document => ({
  53. openapi,
  54. paths: {
  55. "/users": {
  56. post: {
  57. operationId: "users.create",
  58. requestBody: {
  59. required: true,
  60. content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } },
  61. },
  62. responses: {
  63. 200: {
  64. description: "Created",
  65. content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } },
  66. },
  67. },
  68. },
  69. },
  70. },
  71. components: {
  72. schemas: {
  73. ReadOnlyID: { type: "string", readOnly: true },
  74. User: {
  75. type: "object",
  76. additionalProperties: false,
  77. required: ["id", "name", "password", "profile", "generated"],
  78. properties: {
  79. id: { type: "string", readOnly: true },
  80. name: { type: "string" },
  81. password: { type: "string", writeOnly: true },
  82. profile: {
  83. type: "object",
  84. additionalProperties: false,
  85. required: ["createdAt", "secret", "label"],
  86. properties: {
  87. createdAt: { type: "string", readOnly: true },
  88. secret: { type: "string", writeOnly: true },
  89. label: { type: "string" },
  90. },
  91. },
  92. generated: { $ref: "#/components/schemas/ReadOnlyID" },
  93. },
  94. },
  95. },
  96. },
  97. })
  98. describe("OpenAPI.fromSpec", () => {
  99. test("covers a representative API from generation through execution", async () => {
  100. const resolutions: Array<string> = []
  101. const client = recordingClient((request) => {
  102. const url = Option.getOrElse(HttpClientRequest.toUrl(request), () => new URL(request.url))
  103. if (request.method === "POST") {
  104. return new Response(
  105. JSON.stringify({ id: "user-2", name: "Grace", email: "grace@example.test", role: "admin" }),
  106. { status: 201, headers: { "content-type": "application/vnd.example+json" } },
  107. )
  108. }
  109. if (request.method === "DELETE") return new Response(null, { status: 204 })
  110. if (url.pathname === "/search") {
  111. return new Response("2 matches", { headers: { "content-type": "text/plain" } })
  112. }
  113. return json({ id: "user-1", name: "Ada", email: "ada@example.test", role: "member" })
  114. })
  115. const api = OpenAPI.fromSpec({
  116. spec: await happyPathSpec(),
  117. baseUrl,
  118. auth: {
  119. resolve: ({ name }) => {
  120. resolutions.push(name)
  121. return Effect.succeed(
  122. name === "BearerAuth"
  123. ? { type: "bearer", token: "bearer-secret" }
  124. : { type: "apiKey", value: "api-secret" },
  125. )
  126. },
  127. },
  128. })
  129. const get = toolAt(api.tools, "users.get")
  130. const create = toolAt(api.tools, "users.create")
  131. const search = toolAt(api.tools, "search.run")
  132. const remove = toolAt(api.tools, "users.remove")
  133. expect(api.skipped).toEqual([])
  134. if (
  135. !Tool.isDefinition(get) ||
  136. !Tool.isDefinition(create) ||
  137. !Tool.isDefinition(search) ||
  138. !Tool.isDefinition(remove)
  139. ) {
  140. throw new Error("happy-path fixture did not generate every operation")
  141. }
  142. expect(inputTypeScript(get)).toBe(
  143. '{ userId: string; include?: Array<string>; verbose?: boolean; "X-Trace-ID"?: string }',
  144. )
  145. expect(inputTypeScript(create)).toBe('{ name: string; email: string; role?: "admin" | "member" }')
  146. expect(inputTypeScript(search)).toBe("{ filter?: { query: string; page?: number }; tags?: Array<string> }")
  147. expect(inputTypeScript(remove)).toBe("{ userId: string }")
  148. expect(outputTypeScript(get)).toContain("id: string")
  149. expect(outputTypeScript(create)).toContain('role?: "admin" | "member"')
  150. expect(outputTypeScript(search)).toBe("string")
  151. expect(outputTypeScript(remove)).toBe("null")
  152. const result = await Effect.runPromise(
  153. CodeMode.make({ tools: { api: api.tools } })
  154. .execute(
  155. `
  156. const user = await tools.api.users.get({
  157. userId: "user-1",
  158. include: ["profile", "permissions"],
  159. verbose: true,
  160. "X-Trace-ID": "trace-1",
  161. })
  162. const created = await tools.api.users.create({
  163. name: "Grace",
  164. email: "grace@example.test",
  165. role: "admin",
  166. })
  167. const summary = await tools.api.search.run({
  168. filter: { query: "effect", page: 2 },
  169. tags: ["typescript", "runtime"],
  170. })
  171. const removed = await tools.api.users.remove({ userId: "user-1" })
  172. return { user, created, summary, removed }
  173. `,
  174. )
  175. .pipe(Effect.provide(client.layer)),
  176. )
  177. expect(result).toMatchObject({
  178. ok: true,
  179. value: {
  180. user: { id: "user-1", name: "Ada" },
  181. created: { id: "user-2", name: "Grace" },
  182. summary: "2 matches",
  183. removed: null,
  184. },
  185. })
  186. expect(resolutions).toEqual(["BearerAuth", "ApiKey", "BearerAuth"])
  187. expect(client.requests).toHaveLength(4)
  188. const getUrl = new URL(client.requests[0]!.url)
  189. expect(getUrl.pathname).toBe("/users/user-1")
  190. expect(getUrl.searchParams.get("include")).toBe("profile,permissions")
  191. expect(getUrl.searchParams.get("verbose")).toBe("true")
  192. expect(client.requests[0]!.headers["x-trace-id"]).toBe("trace-1")
  193. expect(client.requests[0]!.headers.authorization).toBe("Bearer bearer-secret")
  194. const createUrl = new URL(client.requests[1]!.url)
  195. expect(createUrl.searchParams.get("api_key")).toBe("api-secret")
  196. expect(client.requests[1]!.body).toEqual({ name: "Grace", email: "grace@example.test", role: "admin" })
  197. const searchUrl = new URL(client.requests[2]!.url)
  198. expect(searchUrl.searchParams.get("filter[query]")).toBe("effect")
  199. expect(searchUrl.searchParams.get("filter[page]")).toBe("2")
  200. expect(searchUrl.searchParams.getAll("tags")).toEqual(["typescript", "runtime"])
  201. expect(client.requests[2]!.headers.authorization).toBeUndefined()
  202. expect(new URL(client.requests[3]!.url).pathname).toBe("/users/user-1")
  203. expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret")
  204. })
  205. test("converts representative opencode operations into the expected tool shape", async () => {
  206. const spec = await opencodeSpec()
  207. const result = OpenAPI.fromSpec({ spec, baseUrl })
  208. expect(result.skipped).toHaveLength(4)
  209. expect(result.skipped).toContainEqual({
  210. method: "GET",
  211. path: "/api/pty/{ptyID}/connect",
  212. reason: "WebSocket operations are not supported",
  213. })
  214. expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
  215. expect(result.skipped).toContainEqual({
  216. method: "GET",
  217. path: "/api/fs/read/*",
  218. reason: "binary responses are not supported",
  219. })
  220. expect(toolAt(result.tools, "v2.health.get")).not.toBeUndefined()
  221. expect(toolAt(result.tools, "v2.session.get")).not.toBeUndefined()
  222. expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
  223. const sessionGet = toolAt(result.tools, "v2.session.get")
  224. expect(Tool.isDefinition(sessionGet)).toBe(true)
  225. if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated")
  226. expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
  227. expect(outputTypeScript(sessionGet)).toContain("id: string")
  228. expect(outputTypeScript(sessionGet)).toContain("additions: number")
  229. const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
  230. expect(Tool.isDefinition(switchAgent)).toBe(true)
  231. if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
  232. expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
  233. const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
  234. expect(Tool.isDefinition(instructionPut)).toBe(true)
  235. if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
  236. expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
  237. expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
  238. expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
  239. expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
  240. expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
  241. expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
  242. expect(toolAt(result.tools, "v2.pty.connect.token")).not.toBeUndefined()
  243. })
  244. test("preserves operation path sanitization and collision handling", () => {
  245. const response = { responses: { 200: { description: "Success" } } }
  246. const result = OpenAPI.fromSpec({
  247. baseUrl,
  248. spec: {
  249. openapi: "3.1.0",
  250. paths: {
  251. "/first": { get: { ...response, operationId: "group.item" } },
  252. "/second": { get: { ...response, operationId: "group.item" } },
  253. "/third": { get: { ...response, operationId: "group..other" } },
  254. },
  255. },
  256. })
  257. expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true)
  258. expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true)
  259. expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true)
  260. })
  261. test("synthesizes flat operation IDs from methods and paths", () => {
  262. const response = { responses: { 200: { description: "Success" } } }
  263. const tools = OpenAPI.fromSpec({
  264. baseUrl,
  265. spec: {
  266. openapi: "3.1.0",
  267. paths: {
  268. "/users": { get: response, post: response },
  269. "/users/{id}": { get: response, patch: response, delete: response },
  270. "/organizations/{organizationId}/users/{id}": { get: response },
  271. },
  272. },
  273. }).tools
  274. for (const path of [
  275. "getUsers",
  276. "postUsers",
  277. "getUsersById",
  278. "patchUsersById",
  279. "deleteUsersById",
  280. "getOrganizationsByOrganizationidUsersById",
  281. ]) {
  282. expect(Tool.isDefinition(toolAt(tools, path))).toBe(true)
  283. }
  284. })
  285. test("lets operation parameters override matching path parameters", () => {
  286. const tool = toolAt(
  287. OpenAPI.fromSpec({
  288. baseUrl,
  289. spec: {
  290. openapi: "3.1.0",
  291. paths: {
  292. "/test": {
  293. parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
  294. get: {
  295. operationId: "test",
  296. parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
  297. responses: { 200: { description: "Success" } },
  298. },
  299. },
  300. },
  301. },
  302. }).tools,
  303. "test",
  304. )
  305. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  306. expect(inputTypeScript(tool)).toBe("{ limit: number }")
  307. })
  308. test("normalizes OpenAPI 3.0 schemas with Effect", () => {
  309. const result = OpenAPI.fromSpec({
  310. baseUrl,
  311. spec: {
  312. openapi: "3.0.3",
  313. paths: {
  314. "/search": {
  315. get: {
  316. operationId: "search",
  317. parameters: [
  318. {
  319. in: "query",
  320. name: "value",
  321. schema: { type: "string", nullable: true, minLength: 2 },
  322. },
  323. ],
  324. responses: { 200: { description: "Success" } },
  325. },
  326. },
  327. },
  328. },
  329. })
  330. const search = toolAt(result.tools, "search")
  331. expect(Tool.isDefinition(search)).toBe(true)
  332. if (!Tool.isDefinition(search)) throw new Error("search was not generated")
  333. expect(inputTypeScript(search)).toBe("{ value?: string | null }")
  334. const schema: unknown = search.input
  335. const input = isRecord(schema) ? schema : {}
  336. const properties = isRecord(input.properties) ? input.properties : {}
  337. const value = isRecord(properties.value) ? properties.value : {}
  338. expect(value.minLength).toBe(2)
  339. })
  340. test("preserves schema-local definitions alongside component definitions", () => {
  341. const tool = toolAt(
  342. OpenAPI.fromSpec({
  343. baseUrl,
  344. spec: {
  345. openapi: "3.1.0",
  346. paths: {
  347. "/test": {
  348. get: {
  349. operationId: "test",
  350. responses: {
  351. 200: {
  352. description: "Success",
  353. content: {
  354. "application/json": {
  355. schema: { $ref: "#/$defs/Local", $defs: { Local: { type: "string" } } },
  356. },
  357. },
  358. },
  359. },
  360. },
  361. },
  362. },
  363. components: { schemas: { Global: { type: "number" } } },
  364. },
  365. }).tools,
  366. "test",
  367. )
  368. if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated")
  369. expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } })
  370. })
  371. test("projects read-only and write-only properties by schema direction", () => {
  372. for (const version of ["3.0.3", "3.1.0"]) {
  373. const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create")
  374. if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) {
  375. throw new Error(`users.create was not generated for OpenAPI ${version}`)
  376. }
  377. expect(inputTypeScript(tool)).toBe(
  378. "{ name: string; password: string; profile: { secret: string; label: string } }",
  379. )
  380. expect(outputTypeScript(tool)).toBe(
  381. "{ id: string; name: string; profile: { createdAt: string; label: string }; generated: string }",
  382. )
  383. const requestDefinitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
  384. const responseDefinitions = isRecord(tool.output.$defs) ? tool.output.$defs : {}
  385. const requestUser = isRecord(requestDefinitions.User) ? requestDefinitions.User : {}
  386. const responseUser = isRecord(responseDefinitions.User) ? responseDefinitions.User : {}
  387. expect(Object.keys(isRecord(requestUser.properties) ? requestUser.properties : {})).toEqual([
  388. "name",
  389. "password",
  390. "profile",
  391. ])
  392. expect(requestUser.required).toEqual(["name", "password", "profile"])
  393. expect(Object.keys(isRecord(responseUser.properties) ? responseUser.properties : {})).toEqual([
  394. "id",
  395. "name",
  396. "profile",
  397. "generated",
  398. ])
  399. expect(responseUser.required).toEqual(["id", "name", "profile", "generated"])
  400. }
  401. })
  402. test("projects directional annotations through local refs and allOf composition", () => {
  403. const tool = toolAt(
  404. OpenAPI.fromSpec({
  405. baseUrl,
  406. spec: singleOperation(
  407. {
  408. requestBody: {
  409. required: true,
  410. content: {
  411. "application/json": {
  412. schema: {
  413. type: "object",
  414. additionalProperties: false,
  415. required: ["local", "composed", "name"],
  416. properties: {
  417. local: { $ref: "#/$defs/ReadOnlyValue" },
  418. composed: { allOf: [{ $ref: "#/$defs/ReadOnlyValue" }] },
  419. name: { type: "string" },
  420. },
  421. $defs: {
  422. ReadOnlyValue: { type: "string", readOnly: true },
  423. },
  424. },
  425. },
  426. },
  427. },
  428. },
  429. "post",
  430. ),
  431. }).tools,
  432. "test",
  433. )
  434. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  435. expect(inputTypeScript(tool)).toBe("{ name: string }")
  436. })
  437. test("honors declarations that are siblings of a $ref", () => {
  438. const tool = toolAt(
  439. OpenAPI.fromSpec({
  440. baseUrl,
  441. spec: {
  442. openapi: "3.1.0",
  443. paths: {
  444. "/test": {
  445. post: {
  446. operationId: "test",
  447. responses: { 200: { description: "Success" } },
  448. requestBody: {
  449. required: true,
  450. content: {
  451. "application/json": {
  452. schema: {
  453. type: "object",
  454. additionalProperties: false,
  455. required: ["record"],
  456. properties: {
  457. record: {
  458. $ref: "#/components/schemas/Base",
  459. properties: { extra: { type: "string", readOnly: true }, note: { type: "string" } },
  460. required: ["extra", "note", "id"],
  461. },
  462. },
  463. },
  464. },
  465. },
  466. },
  467. },
  468. },
  469. },
  470. components: {
  471. schemas: {
  472. Base: {
  473. type: "object",
  474. required: ["id", "name"],
  475. properties: { id: { type: "string", readOnly: true }, name: { type: "string" } },
  476. },
  477. },
  478. },
  479. },
  480. }).tools,
  481. "test",
  482. )
  483. if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
  484. const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
  485. const record = isRecord(properties.record) ? properties.record : {}
  486. const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
  487. const base = isRecord(definitions.Base) ? definitions.Base : {}
  488. expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["note"])
  489. expect(record.required).toEqual(["note"])
  490. expect(Object.keys(isRecord(base.properties) ? base.properties : {})).toEqual(["name"])
  491. expect(base.required).toEqual(["name"])
  492. })
  493. test("honors directional declarations on intermediate reference hops", () => {
  494. const tool = toolAt(
  495. OpenAPI.fromSpec({
  496. baseUrl,
  497. spec: {
  498. ...singleOperation(
  499. {
  500. requestBody: {
  501. required: true,
  502. content: {
  503. "application/json": {
  504. schema: {
  505. type: "object",
  506. additionalProperties: false,
  507. required: ["secret", "name"],
  508. properties: {
  509. // Hidden only by the sibling declaration on the middle hop.
  510. secret: { $ref: "#/components/schemas/Middle" },
  511. name: { type: "string" },
  512. },
  513. },
  514. },
  515. },
  516. },
  517. },
  518. "post",
  519. ),
  520. components: {
  521. schemas: {
  522. Middle: { $ref: "#/components/schemas/Plain", readOnly: true },
  523. Plain: { type: "string" },
  524. },
  525. },
  526. },
  527. }).tools,
  528. "test",
  529. )
  530. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  531. expect(inputTypeScript(tool)).toBe("{ name: string }")
  532. })
  533. test("projects cyclic component references without hanging", () => {
  534. const tool = toolAt(
  535. OpenAPI.fromSpec({
  536. baseUrl,
  537. spec: {
  538. openapi: "3.1.0",
  539. paths: {
  540. "/test": {
  541. post: {
  542. operationId: "test",
  543. responses: { 200: { description: "Success" } },
  544. requestBody: {
  545. required: true,
  546. content: { "application/json": { schema: { $ref: "#/components/schemas/Node" } } },
  547. },
  548. },
  549. },
  550. },
  551. components: {
  552. schemas: {
  553. Node: {
  554. type: "object",
  555. required: ["id", "name", "child"],
  556. properties: {
  557. id: { type: "string", readOnly: true },
  558. name: { type: "string" },
  559. child: { $ref: "#/components/schemas/Node" },
  560. },
  561. },
  562. },
  563. },
  564. },
  565. }).tools,
  566. "test",
  567. )
  568. if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
  569. const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
  570. const node = isRecord(definitions.Node) ? definitions.Node : {}
  571. expect(Object.keys(isRecord(node.properties) ? node.properties : {})).toEqual(["name", "child"])
  572. expect(node.required).toEqual(["name", "child"])
  573. })
  574. test("projects diamond-shaped reference graphs in linear time", () => {
  575. // Each component references the next twice; without memoized hidden-ness this is 2^30 work.
  576. const depth = 30
  577. const schemas = Object.fromEntries(
  578. Array.from({ length: depth }, (_, index) => [
  579. `C${index}`,
  580. index === depth - 1
  581. ? { type: "object", properties: { id: { type: "string", readOnly: true }, name: { type: "string" } } }
  582. : { allOf: [{ $ref: `#/components/schemas/C${index + 1}` }, { $ref: `#/components/schemas/C${index + 1}` }] },
  583. ]),
  584. )
  585. const tool = toolAt(
  586. OpenAPI.fromSpec({
  587. baseUrl,
  588. spec: {
  589. openapi: "3.1.0",
  590. paths: {
  591. "/test": {
  592. post: {
  593. operationId: "test",
  594. responses: { 200: { description: "Success" } },
  595. requestBody: {
  596. required: true,
  597. content: { "application/json": { schema: { $ref: "#/components/schemas/C0" } } },
  598. },
  599. },
  600. },
  601. },
  602. components: { schemas },
  603. },
  604. }).tools,
  605. "test",
  606. )
  607. if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
  608. const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {}
  609. const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {}
  610. expect(Object.keys(isRecord(leaf.properties) ? leaf.properties : {})).toEqual(["name"])
  611. })
  612. test("resolves hiding through reference cycles regardless of evaluation order", () => {
  613. // `Wrap` is hidden only through the cycle member `Loop`; evaluating a property that
  614. // enters the cycle at `Loop` first must not freeze a provisional result for `Wrap`.
  615. const schemas = {
  616. Wrap: { allOf: [{ $ref: "#/components/schemas/Loop" }] },
  617. Loop: { allOf: [{ $ref: "#/components/schemas/Wrap" }, { readOnly: true }] },
  618. }
  619. const body = (properties: Record<string, unknown>) => ({
  620. required: true,
  621. content: {
  622. "application/json": {
  623. schema: {
  624. type: "object",
  625. additionalProperties: false,
  626. required: [...Object.keys(properties), "name"],
  627. properties: { ...properties, name: { type: "string" } },
  628. },
  629. },
  630. },
  631. })
  632. for (const properties of [
  633. { a: { $ref: "#/components/schemas/Loop" }, b: { $ref: "#/components/schemas/Wrap" } },
  634. { a: { $ref: "#/components/schemas/Wrap" }, b: { $ref: "#/components/schemas/Loop" } },
  635. ]) {
  636. const tool = toolAt(
  637. OpenAPI.fromSpec({
  638. baseUrl,
  639. spec: { ...singleOperation({ requestBody: body(properties) }, "post"), components: { schemas } },
  640. }).tools,
  641. "test",
  642. )
  643. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  644. expect(inputTypeScript(tool)).toBe("{ name: string }")
  645. }
  646. })
  647. test("keeps not, if, and contains subschemas unprojected", () => {
  648. const tool = toolAt(
  649. OpenAPI.fromSpec({
  650. baseUrl,
  651. spec: singleOperation(
  652. {
  653. requestBody: {
  654. required: true,
  655. content: {
  656. "application/json": {
  657. schema: {
  658. type: "object",
  659. additionalProperties: false,
  660. required: ["record"],
  661. properties: {
  662. record: {
  663. type: "object",
  664. // Removing `secret` here would turn `not` unsatisfiable and
  665. // flip which branch of `if` applies; both must pass through.
  666. not: { required: ["secret"], properties: { secret: { type: "string", readOnly: true } } },
  667. if: { required: ["kind"], properties: { kind: { type: "string", readOnly: true } } },
  668. },
  669. },
  670. },
  671. },
  672. },
  673. },
  674. },
  675. "post",
  676. ),
  677. }).tools,
  678. "test",
  679. )
  680. if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
  681. const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
  682. const record: Record<string, unknown> = isRecord(properties.record) ? properties.record : {}
  683. expect(record.not).toEqual({ required: ["secret"], properties: { secret: { type: "string", readOnly: true } } })
  684. expect(record.if).toEqual({ required: ["kind"], properties: { kind: { type: "string", readOnly: true } } })
  685. })
  686. test("does not hide properties whose direction is declared only in anyOf or oneOf alternatives", () => {
  687. // Deliberate scope bound: alternatives may apply, so a directional declaration on
  688. // one alternative does not hide the property; the annotation is preserved as-is.
  689. const tool = toolAt(
  690. OpenAPI.fromSpec({
  691. baseUrl,
  692. spec: singleOperation(
  693. {
  694. requestBody: {
  695. required: true,
  696. content: {
  697. "application/json": {
  698. schema: {
  699. type: "object",
  700. additionalProperties: false,
  701. required: ["choice", "pick"],
  702. properties: {
  703. choice: { anyOf: [{ type: "string", readOnly: true }, { type: "number" }] },
  704. pick: { oneOf: [{ type: "string", readOnly: true }, { type: "number" }] },
  705. },
  706. },
  707. },
  708. },
  709. },
  710. },
  711. "post",
  712. ),
  713. }).tools,
  714. "test",
  715. )
  716. if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
  717. const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
  718. const choice: Record<string, unknown> = isRecord(properties.choice) ? properties.choice : {}
  719. const pick: Record<string, unknown> = isRecord(properties.pick) ? properties.pick : {}
  720. expect(Object.keys(properties)).toEqual(["choice", "pick"])
  721. expect(choice.anyOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }])
  722. expect(pick.oneOf).toEqual([{ type: "string", readOnly: true }, { type: "number" }])
  723. })
  724. test("does not misresolve shadowed local $defs when flattening body fields", () => {
  725. const tool = toolAt(
  726. OpenAPI.fromSpec({
  727. baseUrl,
  728. spec: singleOperation(
  729. {
  730. requestBody: {
  731. required: true,
  732. content: {
  733. "application/json": {
  734. schema: {
  735. type: "object",
  736. additionalProperties: false,
  737. required: ["record"],
  738. $defs: { Value: { type: "string" } },
  739. properties: {
  740. record: {
  741. type: "object",
  742. required: ["x"],
  743. properties: { x: { $ref: "#/$defs/Value" } },
  744. // Shadows the body-level Value; must not affect the body-rooted projection.
  745. $defs: { Value: { type: "string", readOnly: true } },
  746. },
  747. },
  748. },
  749. },
  750. },
  751. },
  752. },
  753. "post",
  754. ),
  755. }).tools,
  756. "test",
  757. )
  758. if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
  759. const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
  760. const record = isRecord(properties.record) ? properties.record : {}
  761. expect(Object.keys(isRecord(record.properties) ? record.properties : {})).toEqual(["x"])
  762. expect(record.required).toEqual(["x"])
  763. })
  764. test("projects directional annotations inside parameter schemas", () => {
  765. const tool = toolAt(
  766. OpenAPI.fromSpec({
  767. baseUrl,
  768. spec: singleOperation({
  769. parameters: [
  770. {
  771. name: "filter",
  772. in: "query",
  773. required: true,
  774. schema: {
  775. type: "object",
  776. required: ["state", "id"],
  777. properties: { state: { type: "string" }, id: { type: "string", readOnly: true } },
  778. },
  779. },
  780. ],
  781. }),
  782. }).tools,
  783. "test",
  784. )
  785. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  786. expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }")
  787. })
  788. test("ignores inherited directional annotations", () => {
  789. const inherited: Record<string, unknown> = { type: "string" }
  790. Object.setPrototypeOf(inherited, { readOnly: true })
  791. const tool = toolAt(
  792. OpenAPI.fromSpec({
  793. baseUrl,
  794. spec: singleOperation({
  795. parameters: [
  796. {
  797. name: "filter",
  798. in: "query",
  799. required: true,
  800. schema: {
  801. type: "object",
  802. // The own annotation on `id` keeps projection active for the document,
  803. // so `value` pins that prototype-inherited annotations are not read.
  804. properties: { value: inherited, id: { type: "string", readOnly: true } },
  805. required: ["value", "id"],
  806. },
  807. },
  808. ],
  809. }),
  810. }).tools,
  811. "test",
  812. )
  813. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  814. expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }")
  815. })
  816. test("cleans required properties across allOf branches", () => {
  817. const tool = toolAt(
  818. OpenAPI.fromSpec({
  819. baseUrl,
  820. spec: singleOperation(
  821. {
  822. requestBody: {
  823. required: true,
  824. content: {
  825. "application/json": {
  826. schema: {
  827. type: "object",
  828. required: ["id", "name"],
  829. allOf: [
  830. {
  831. type: "object",
  832. required: ["id", "name"],
  833. properties: { id: { type: "string", readOnly: true }, name: { type: "string" } },
  834. },
  835. ],
  836. },
  837. },
  838. },
  839. },
  840. },
  841. "post",
  842. ),
  843. }).tools,
  844. "test",
  845. )
  846. if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated")
  847. const properties = isRecord(tool.input.properties) ? tool.input.properties : {}
  848. const body = isRecord(properties.body) ? properties.body : {}
  849. const allOf = Array.isArray(body.allOf) ? body.allOf : []
  850. const branch = isRecord(allOf[0]) ? allOf[0] : {}
  851. expect(body.required).toEqual(["name"])
  852. expect(branch.required).toEqual(["name"])
  853. expect(Object.keys(isRecord(branch.properties) ? branch.properties : {})).toEqual(["name"])
  854. })
  855. test("keeps directional schemas model-facing while preserving runtime pass-through", async () => {
  856. const client = recordingClient(() =>
  857. json({
  858. id: "server-id",
  859. name: "Ada",
  860. password: "returned-by-server",
  861. profile: { createdAt: "today", secret: "returned-secret", label: "primary" },
  862. generated: "generated-id",
  863. }),
  864. )
  865. const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create")
  866. if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated")
  867. const result = await Effect.runPromise(
  868. tool
  869. .run({
  870. id: "ignored-top-level",
  871. generated: "ignored-generated",
  872. name: "Ada",
  873. password: "request-secret",
  874. profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" },
  875. })
  876. .pipe(Effect.provide(client.layer)),
  877. )
  878. expect(client.requests[0]?.body).toEqual({
  879. name: "Ada",
  880. password: "request-secret",
  881. profile: { createdAt: "sent-nested", secret: "nested-secret", label: "primary" },
  882. })
  883. expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
  884. })
  885. test("documents that the opencode fixture is unauthenticated", async () => {
  886. const spec = await opencodeSpec()
  887. const components = isRecord(spec.components) ? spec.components : {}
  888. const result = OpenAPI.fromSpec({ spec, baseUrl })
  889. expect(spec.security).toStrictEqual([])
  890. expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
  891. const health = toolAt(result.tools, "v2.health.get")
  892. const healthInput = isRecord(health) ? health.input : undefined
  893. expect(healthInput).toMatchObject({ type: "object", properties: {} })
  894. const input = isRecord(healthInput) ? healthInput : {}
  895. expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
  896. })
  897. test("exposes real opencode operations through CodeMode discovery", async () => {
  898. const { layer } = recordingClient(() => json({}))
  899. const runtime = CodeMode.make({
  900. tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
  901. })
  902. const result = await Effect.runPromise(
  903. runtime
  904. .execute(
  905. `
  906. return search({ query: "global health", namespace: "opencode", limit: 1 })
  907. `,
  908. )
  909. .pipe(Effect.provide(layer)),
  910. )
  911. expect(result).toMatchObject({ ok: true })
  912. if (!result.ok) return
  913. expect(result.value).toMatchObject({
  914. items: [
  915. {
  916. path: "tools.opencode.v2.health.get",
  917. description: "Check whether the API server is ready to accept requests.",
  918. },
  919. ],
  920. })
  921. expect(JSON.stringify(result.value)).toContain("healthy: true")
  922. })
  923. test("invokes real opencode path parameters and JSON request bodies", async () => {
  924. const { requests, layer } = recordingClient((request) => {
  925. if (request.method === "GET") return json({ id: "ses_123" })
  926. return json({ id: "ses_456" })
  927. })
  928. const runtime = CodeMode.make({
  929. tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
  930. })
  931. const result = await Effect.runPromise(
  932. runtime
  933. .execute(
  934. `
  935. const existing = await tools.opencode.v2.session.get({ sessionID: "ses_123" })
  936. const created = await tools.opencode.v2.session.create({ id: "ses_456" })
  937. return { existing, created }
  938. `,
  939. )
  940. .pipe(Effect.provide(layer)),
  941. )
  942. expect(result).toMatchObject({ ok: true })
  943. expect(requests).toHaveLength(2)
  944. expect(requests[0]).toMatchObject({ method: "GET", body: undefined })
  945. expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_123")
  946. expect(requests[1]).toMatchObject({
  947. method: "POST",
  948. url: "http://localhost:4096/api/session",
  949. body: { id: "ses_456" },
  950. })
  951. })
  952. test("serializes deep-object query parameters from the opencode fixture", async () => {
  953. const client = recordingClient(() => json({ directory: "/tmp" }))
  954. const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
  955. if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated")
  956. await Effect.runPromise(
  957. location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)),
  958. )
  959. const url = new URL(client.requests[0]!.url)
  960. expect(url.searchParams.get("location[directory]")).toBe("/tmp")
  961. expect(url.searchParams.get("location[workspace]")).toBe("workspace-1")
  962. })
  963. test("serializes supported simple and form parameter shapes", async () => {
  964. const client = recordingClient(() => json({ ok: true }))
  965. const result = OpenAPI.fromSpec({
  966. baseUrl,
  967. spec: {
  968. openapi: "3.1.0",
  969. paths: {
  970. "/items/{keys}": {
  971. get: {
  972. operationId: "items",
  973. parameters: [
  974. { name: "keys", in: "path", required: true, schema: { type: "array", items: { type: "string" } } },
  975. { name: "tags", in: "query", style: "form", explode: false, schema: { type: "array" } },
  976. { name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } },
  977. { name: "nullable", in: "query", required: true, schema: { type: ["string", "null"] } },
  978. { name: "constructor", in: "query", schema: { type: "string" } },
  979. { name: "meta", in: "header", style: "simple", explode: true, schema: { type: "object" } },
  980. ],
  981. responses: { 200: { description: "Success" } },
  982. },
  983. },
  984. },
  985. },
  986. })
  987. const tool = toolAt(result.tools, "items")
  988. if (!Tool.isDefinition(tool)) throw new Error("items was not generated")
  989. await Effect.runPromise(
  990. tool
  991. .run({
  992. keys: ["a!", "b*"],
  993. tags: ["x", "y"],
  994. filter: { state: "open", page: 2 },
  995. nullable: null,
  996. constructor_2: "safe",
  997. meta: { a: "b", c: "d" },
  998. })
  999. .pipe(Effect.provide(client.layer)),
  1000. )
  1001. const url = new URL(client.requests[0]!.url)
  1002. expect(url.pathname).toBe("/items/a%21,b%2A")
  1003. expect(url.searchParams.get("tags")).toBe("x,y")
  1004. expect(url.searchParams.get("state")).toBe("open")
  1005. expect(url.searchParams.get("page")).toBe("2")
  1006. expect(url.searchParams.get("nullable")).toBe("null")
  1007. expect(url.searchParams.get("constructor")).toBe("safe")
  1008. expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
  1009. await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
  1010. "unsupported nested value",
  1011. )
  1012. })
  1013. test("preserves ordered exploded and deep-object query parameters", async () => {
  1014. const client = recordingClient(() => json({ ok: true }))
  1015. const tool = toolAt(
  1016. OpenAPI.fromSpec({
  1017. baseUrl,
  1018. spec: singleOperation({
  1019. parameters: [
  1020. { name: "tags", in: "query", style: "form", explode: true, schema: { type: "array" } },
  1021. { name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } },
  1022. { name: "location", in: "query", style: "deepObject", explode: true, schema: { type: "object" } },
  1023. ],
  1024. }),
  1025. }).tools,
  1026. "test",
  1027. )
  1028. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  1029. await Effect.runPromise(
  1030. tool
  1031. .run({
  1032. tags: ["first value", "second&value"],
  1033. filter: { state: "open now", page: 2 },
  1034. location: { directory: "/tmp/a b", workspace: "work&1" },
  1035. })
  1036. .pipe(Effect.provide(client.layer)),
  1037. )
  1038. expect(client.requests[0]?.url).toBe(
  1039. `${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`,
  1040. )
  1041. await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
  1042. "Parameter 'tags' contains an unsupported nested value.",
  1043. )
  1044. await expect(
  1045. Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))),
  1046. ).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.")
  1047. await expect(
  1048. Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))),
  1049. ).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.")
  1050. expect(client.requests).toHaveLength(1)
  1051. })
  1052. test("skips unsupported parameter encodings and malformed security", () => {
  1053. const result = OpenAPI.fromSpec({
  1054. baseUrl,
  1055. spec: {
  1056. openapi: "3.1.0",
  1057. security: [{ bearer: [] }],
  1058. paths: {
  1059. "/cookie": {
  1060. get: {
  1061. operationId: "cookie",
  1062. parameters: [{ name: "session", in: "cookie", schema: { type: "string" } }],
  1063. responses: { 200: { description: "Success" } },
  1064. },
  1065. },
  1066. "/reserved": {
  1067. get: {
  1068. operationId: "reserved",
  1069. parameters: [{ name: "query", in: "query", allowReserved: true, schema: { type: "string" } }],
  1070. responses: { 200: { description: "Success" } },
  1071. },
  1072. },
  1073. "/invalid-style": {
  1074. get: {
  1075. operationId: "invalidStyle",
  1076. parameters: [{ name: "query", in: "query", style: 42, schema: { type: "string" } }],
  1077. responses: { 200: { description: "Success" } },
  1078. },
  1079. },
  1080. "/security": {
  1081. get: { operationId: "security", security: null, responses: { 200: { description: "Success" } } },
  1082. },
  1083. },
  1084. },
  1085. })
  1086. expect(result.tools).toEqual({})
  1087. expect(result.skipped.map((item) => item.reason)).toEqual([
  1088. "cookie parameter 'session' is not supported",
  1089. "parameter 'query' uses unsupported allowReserved encoding",
  1090. "parameter 'query' has an invalid style",
  1091. "security declaration is not an array",
  1092. ])
  1093. })
  1094. test("fails closed on prototype-named missing security schemes", () => {
  1095. const result = OpenAPI.fromSpec({
  1096. baseUrl,
  1097. spec: singleOperation({ security: [JSON.parse('{"__proto__":[]}')] }),
  1098. })
  1099. expect(result.tools).toEqual({})
  1100. expect(result.skipped[0]?.reason).toBe("security requirement references missing or malformed scheme: __proto__")
  1101. })
  1102. test("resolves bearer authentication without exposing it as input", async () => {
  1103. const contexts: Array<Parameters<OpenAPI.AuthResolver>[0]> = []
  1104. const client = recordingClient(() => json({ ok: true }))
  1105. const spec = {
  1106. ...singleOperation({ operationId: undefined }),
  1107. security: [{ bearer: [] }],
  1108. components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } },
  1109. } satisfies Document
  1110. const tool = toolAt(
  1111. OpenAPI.fromSpec({
  1112. baseUrl,
  1113. spec,
  1114. auth: {
  1115. resolve: (context) => {
  1116. contexts.push(context)
  1117. return Effect.succeed({ type: "bearer", token: "secret" })
  1118. },
  1119. },
  1120. }).tools,
  1121. "getTest",
  1122. )
  1123. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  1124. await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
  1125. expect(inputTypeScript(tool)).toBe("{}")
  1126. expect(client.requests[0]!.headers.authorization).toBe("Bearer secret")
  1127. expect(contexts).toEqual([
  1128. {
  1129. name: "bearer",
  1130. definition: { type: "http", scheme: "bearer" },
  1131. scopes: [],
  1132. operation: {
  1133. operationId: undefined,
  1134. method: "GET",
  1135. path: "/test",
  1136. summary: undefined,
  1137. description: undefined,
  1138. },
  1139. },
  1140. ])
  1141. })
  1142. test("applies authentication carriers without prototype or collision loss", async () => {
  1143. const client = recordingClient(() => json({ ok: true }))
  1144. const authenticated = (
  1145. security: ReadonlyArray<Record<string, ReadonlyArray<string>>>,
  1146. schemes: Record<string, unknown>,
  1147. ) =>
  1148. OpenAPI.fromSpec({
  1149. baseUrl,
  1150. spec: { ...singleOperation({}), security, components: { securitySchemes: schemes } },
  1151. auth: { resolve: () => Effect.succeed({ type: "apiKey", value: "secret" }) },
  1152. })
  1153. const prototype = toolAt(
  1154. authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools,
  1155. "test",
  1156. )
  1157. if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated")
  1158. await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer)))
  1159. expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
  1160. const duplicate = toolAt(
  1161. authenticated([{ first: [], second: [] }], {
  1162. first: { type: "apiKey", in: "header", name: "x-key" },
  1163. second: { type: "apiKey", in: "header", name: "x-key" },
  1164. }).tools,
  1165. "test",
  1166. )
  1167. if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
  1168. await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow(
  1169. "multiple credentials",
  1170. )
  1171. const cookie = authenticated([{ key: [] }], { key: { type: "apiKey", in: "cookie", name: "session" } })
  1172. expect(cookie.tools).toEqual({})
  1173. expect(cookie.skipped[0]?.reason).toBe("cookie authentication 'key' is not supported")
  1174. const alternative = OpenAPI.fromSpec({
  1175. baseUrl,
  1176. spec: {
  1177. ...singleOperation({}),
  1178. security: [{ cookie: [] }, { bearer: [] }],
  1179. components: {
  1180. securitySchemes: {
  1181. cookie: { type: "apiKey", in: "cookie", name: "session" },
  1182. bearer: { type: "http", scheme: "bearer" },
  1183. },
  1184. },
  1185. },
  1186. auth: {
  1187. resolve: ({ name }) => Effect.succeed(name === "bearer" ? { type: "bearer", token: "secret" } : undefined),
  1188. },
  1189. })
  1190. const alternativeTool = toolAt(alternative.tools, "test")
  1191. if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated")
  1192. await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer)))
  1193. expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret")
  1194. })
  1195. test("honors server precedence and rejects ambiguous base URLs", async () => {
  1196. const client = recordingClient(() => json({ ok: true }))
  1197. const spec = {
  1198. ...singleOperation({ servers: [{ url: "https://operation.example/v1" }] }),
  1199. servers: [{ url: "https://document.example" }],
  1200. } satisfies Document
  1201. const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test")
  1202. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  1203. await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))
  1204. expect(client.requests[0]?.url).toBe("https://operation.example/v1/test")
  1205. const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" })
  1206. expect(invalid.tools).toEqual({})
  1207. expect(invalid.skipped[0]?.reason).toContain("unsupported query string or fragment")
  1208. const malformed = OpenAPI.fromSpec({ spec, baseUrl: "https:/example.com" })
  1209. expect(malformed.tools).toEqual({})
  1210. expect(malformed.skipped[0]?.reason).toContain("not an absolute HTTP(S) URL")
  1211. })
  1212. test("resolves chained response refs before detecting unsupported transports", () => {
  1213. const result = OpenAPI.fromSpec({
  1214. baseUrl,
  1215. spec: {
  1216. ...singleOperation({ responses: { 200: { $ref: "#/components/responses/First" } } }),
  1217. components: {
  1218. responses: {
  1219. First: { $ref: "#/components/responses/Stream" },
  1220. Stream: { content: { "text/event-stream": { schema: { type: "string" } } } },
  1221. },
  1222. },
  1223. },
  1224. })
  1225. expect(result.tools).toEqual({})
  1226. expect(result.skipped[0]?.reason).toBe("SSE operations are not supported")
  1227. })
  1228. test("resolves response schemas before detecting binary output", () => {
  1229. const result = OpenAPI.fromSpec({
  1230. baseUrl,
  1231. spec: {
  1232. ...singleOperation({
  1233. responses: {
  1234. 200: {
  1235. content: { "text/plain": { schema: { $ref: "#/components/schemas/File" } } },
  1236. },
  1237. },
  1238. }),
  1239. components: { schemas: { File: { type: "string", format: "binary" } } },
  1240. },
  1241. })
  1242. expect(result.tools).toEqual({})
  1243. expect(result.skipped[0]?.reason).toBe("binary responses are not supported")
  1244. })
  1245. test("validates composite parameters before resolving auth", async () => {
  1246. const resolutions: Array<string> = []
  1247. const client = recordingClient(() => json({ ok: true }))
  1248. const tool = toolAt(
  1249. OpenAPI.fromSpec({
  1250. baseUrl,
  1251. spec: {
  1252. ...singleOperation({
  1253. parameters: [{ name: "filter", in: "query", style: "form", explode: true, schema: { type: "object" } }],
  1254. }),
  1255. security: [{ bearer: [] }],
  1256. components: { securitySchemes: { bearer: { type: "http", scheme: "bearer" } } },
  1257. },
  1258. auth: {
  1259. resolve: ({ name }) => {
  1260. resolutions.push(name)
  1261. return Effect.succeed({ type: "bearer", token: "secret" })
  1262. },
  1263. },
  1264. }).tools,
  1265. "test",
  1266. )
  1267. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  1268. await expect(
  1269. Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))),
  1270. ).rejects.toThrow("unsupported nested value")
  1271. expect(resolutions).toEqual([])
  1272. expect(client.requests).toEqual([])
  1273. })
  1274. test("preserves JSON media types and rejects unencodable bodies", async () => {
  1275. const client = recordingClient(() => json({ ok: true }))
  1276. const tool = toolAt(
  1277. OpenAPI.fromSpec({
  1278. baseUrl,
  1279. spec: singleOperation(
  1280. {
  1281. requestBody: {
  1282. required: true,
  1283. content: { "application/merge-patch+json": { schema: { type: "object" } } },
  1284. },
  1285. },
  1286. "post",
  1287. ),
  1288. }).tools,
  1289. "test",
  1290. )
  1291. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  1292. await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer)))
  1293. expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json")
  1294. const cyclic: Record<string, unknown> = {}
  1295. cyclic.self = cyclic
  1296. await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
  1297. "Invalid JSON body",
  1298. )
  1299. })
  1300. test("rejects oversized and malformed JSON responses", async () => {
  1301. const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test")
  1302. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  1303. const oversized = recordingClient(
  1304. () => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
  1305. )
  1306. const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
  1307. const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
  1308. await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(
  1309. "response exceeds 50 MiB",
  1310. )
  1311. await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow(
  1312. "returned malformed JSON",
  1313. )
  1314. await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow(
  1315. "response exceeds 50 MiB",
  1316. )
  1317. })
  1318. test("keeps non-JSON responses raw and unions every success output", async () => {
  1319. const spec = singleOperation({
  1320. responses: {
  1321. 200: { description: "Text", content: { "text/plain": { schema: { type: "string" } } } },
  1322. 204: { description: "Empty" },
  1323. },
  1324. })
  1325. const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test")
  1326. if (!Tool.isDefinition(tool)) throw new Error("test was not generated")
  1327. const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } }))
  1328. expect(outputTypeScript(tool)).toBe("string | null")
  1329. await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123")
  1330. })
  1331. test("fails missing required parameters before auth and network", async () => {
  1332. const { requests, layer } = recordingClient(() => json({}))
  1333. const runtime = CodeMode.make({
  1334. tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
  1335. })
  1336. const result = await Effect.runPromise(
  1337. runtime.execute("return await tools.opencode.v2.session.get({})").pipe(Effect.provide(layer)),
  1338. )
  1339. expect(result).toMatchObject({ ok: false })
  1340. expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'")
  1341. expect(requests).toHaveLength(0)
  1342. })
  1343. test("prefixes cross-location collisions and reconstructs the HTTP request", async () => {
  1344. const spec = {
  1345. openapi: "3.1.0",
  1346. info: { title: "collision", version: "1.0.0" },
  1347. paths: {
  1348. "/echo": {
  1349. post: {
  1350. operationId: "echo",
  1351. requestBody: {
  1352. required: true,
  1353. content: { "application/json": { schema: { type: "string" } } },
  1354. },
  1355. responses: { "204": { description: "Echoed" } },
  1356. },
  1357. },
  1358. "/things/{id}": {
  1359. post: {
  1360. operationId: "things.update",
  1361. parameters: [
  1362. { name: "id", in: "path", required: true, schema: { type: "string" } },
  1363. { name: "id", in: "query", required: true, schema: { type: "string" } },
  1364. { name: "path_id", in: "query", schema: { type: "string" } },
  1365. { name: "id", in: "header", required: true, schema: { type: "string" } },
  1366. ],
  1367. requestBody: {
  1368. required: true,
  1369. content: {
  1370. "application/json": {
  1371. schema: {
  1372. type: "object",
  1373. properties: { id: { type: "string" } },
  1374. required: ["id"],
  1375. additionalProperties: false,
  1376. },
  1377. },
  1378. },
  1379. },
  1380. responses: { "204": { description: "Updated" } },
  1381. },
  1382. },
  1383. },
  1384. } satisfies Document
  1385. const { requests, layer } = recordingClient(() => new Response(null, { status: 204 }))
  1386. const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools
  1387. const update = toolAt(tools, "things.update")
  1388. const echo = toolAt(tools, "echo")
  1389. expect(Tool.isDefinition(update)).toBe(true)
  1390. if (!Tool.isDefinition(update)) throw new Error("things.update was not generated")
  1391. expect(inputTypeScript(update)).toBe(
  1392. "{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }",
  1393. )
  1394. expect(Tool.isDefinition(echo)).toBe(true)
  1395. if (!Tool.isDefinition(echo)) throw new Error("echo was not generated")
  1396. expect(inputTypeScript(echo)).toBe("{ body: string }")
  1397. const runtime = CodeMode.make({ tools })
  1398. const result = await Effect.runPromise(
  1399. runtime
  1400. .execute(
  1401. `
  1402. const updated = await tools.things.update({ path_id: "path", query_id: "query", path_id_2: "literal", header_id: "header", body_id: "body" })
  1403. const echoed = await tools.echo({ body: "hello" })
  1404. return { updated, echoed }
  1405. `,
  1406. )
  1407. .pipe(Effect.provide(layer)),
  1408. )
  1409. expect(result).toMatchObject({ ok: true })
  1410. expect(requests).toHaveLength(2)
  1411. expect(new URL(requests[0]!.url).pathname).toBe("/things/path")
  1412. expect(new URL(requests[0]!.url).searchParams.get("id")).toBe("query")
  1413. expect(new URL(requests[0]!.url).searchParams.get("path_id")).toBe("literal")
  1414. expect(requests[0]!.headers.id).toBe("header")
  1415. expect(requests[0]!.body).toStrictEqual({ id: "body" })
  1416. expect(requests[1]!.body).toBe("hello")
  1417. })
  1418. test("keeps bodies nested when flattening would lose schema semantics", () => {
  1419. const body = (schema: Record<string, unknown>, required = true) => ({
  1420. required,
  1421. content: { "application/json": { schema } },
  1422. })
  1423. const spec = {
  1424. openapi: "3.1.0",
  1425. info: { title: "bodies", version: "1.0.0" },
  1426. paths: Object.fromEntries(
  1427. [
  1428. [
  1429. "optional",
  1430. body(
  1431. {
  1432. type: "object",
  1433. properties: { name: { type: "string" } },
  1434. required: ["name"],
  1435. additionalProperties: false,
  1436. },
  1437. false,
  1438. ),
  1439. ],
  1440. ["dictionary", body({ type: "object", additionalProperties: { type: "string" } })],
  1441. [
  1442. "composed",
  1443. body({
  1444. type: "object",
  1445. allOf: [{ type: "object", properties: { name: { type: "string" } }, required: ["name"] }],
  1446. additionalProperties: false,
  1447. }),
  1448. ],
  1449. [
  1450. "nullable",
  1451. body({
  1452. type: ["object", "null"],
  1453. properties: { name: { type: "string" } },
  1454. additionalProperties: false,
  1455. }),
  1456. ],
  1457. ].map(([name, requestBody]) => [
  1458. `/body/${name}`,
  1459. {
  1460. post: {
  1461. operationId: `body.${name}`,
  1462. requestBody,
  1463. responses: { "204": { description: "Accepted" } },
  1464. },
  1465. },
  1466. ]),
  1467. ),
  1468. } satisfies Document
  1469. const tools = OpenAPI.fromSpec({ spec, baseUrl }).tools
  1470. for (const name of ["optional", "dictionary", "composed", "nullable"]) {
  1471. const tool = toolAt(tools, `body.${name}`)
  1472. expect(Tool.isDefinition(tool)).toBe(true)
  1473. if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`)
  1474. const input = isRecord(tool.input) ? tool.input : {}
  1475. expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"])
  1476. }
  1477. const optional = toolAt(tools, "body.optional")
  1478. if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated")
  1479. expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }")
  1480. })
  1481. })