diff --git a/deno.jsonc b/deno.jsonc index c040e07..e9b8769 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,12 +1,14 @@ { - "tasks": { - "build": "deno task generate:openapi && deno task generate:webhooks && deno task generate:npm-package", - "generate:npm-package": "deno run -A scripts/build_npm.ts", - "generate:openapi": "npx -y swagger-typescript-api@13.0.2 -p https://swagger.getlago.com/openapi.yaml --union-enums -o ./openapi -n client.ts", - // Generates ./openapi/webhooks.ts containing typed webhook payload schemas from the OpenAPI 3.1 `webhooks:` key. swagger-typescript-api does not handle that key, so we use openapi-typescript here instead. The output is consumed by ../webhook_types.ts (hand-written) which exposes the user-facing helpers. - "generate:webhooks": "npx -y openapi-typescript@7.13.0 https://swagger.getlago.com/openapi.yaml -o ./openapi/webhooks.ts", - // Typechecks the public surface and the webhook type tests. Cheap (no runtime, no network), suitable for CI. Run after `generate:openapi` and `generate:webhooks` so the generated files exist on disk. - "typecheck": "deno check mod.ts webhook_types.ts tests/webhook_types.test.ts", - "test": "deno test ./tests --parallel" - } + "tasks": { + "build": "deno task generate:openapi && deno task generate:webhooks && deno task generate:npm-package", + "generate:npm-package": "deno run -A scripts/build_npm.ts", + // swagger-typescript-api can't map text/plain, so fetchPublicKey needs a + // patch after generation (scripts/patch_openapi_client.ts). + "generate:openapi": "npx -y swagger-typescript-api@13.0.2 -p https://swagger.getlago.com/openapi.yaml --union-enums -o ./openapi -n client.ts && deno run -A scripts/patch_openapi_client.ts", + // Generates ./openapi/webhooks.ts containing typed webhook payload schemas from the OpenAPI 3.1 `webhooks:` key. swagger-typescript-api does not handle that key, so we use openapi-typescript here instead. The output is consumed by ../webhook_types.ts (hand-written) which exposes the user-facing helpers. + "generate:webhooks": "npx -y openapi-typescript@7.13.0 https://swagger.getlago.com/openapi.yaml -o ./openapi/webhooks.ts", + // Typechecks the public surface and the webhook type tests. Cheap (no runtime, no network), suitable for CI. Run after `generate:openapi` and `generate:webhooks` so the generated files exist on disk. + "typecheck": "deno check mod.ts webhook_types.ts tests/webhook_types.test.ts", + "test": "deno test ./tests --parallel" + } } diff --git a/scripts/patch_openapi_client.ts b/scripts/patch_openapi_client.ts new file mode 100644 index 0000000..74b7942 --- /dev/null +++ b/scripts/patch_openapi_client.ts @@ -0,0 +1,112 @@ +// fetchPublicKey (deprecated, text/plain) generates with no `format`, so +// `.data` is never populated. Patches it to `format: "text"` after +// generation. fetchJsonPublicKey is unaffected, it's a normal JSON endpoint. +// Fails loudly (rather than silently) if the generator's output ever +// changes shape. + +const CLIENT_PATH = "./openapi/client.ts"; + +type TextEndpointPatch = { + /** Text used to locate the generated request block. Must match exactly one place. */ + match: string; + /** Replacement including the injected `format: "text",` line. */ + replacement: string; +}; + +const patches: TextEndpointPatch[] = [ + { + match: ` fetchPublicKey: (params: RequestParams = {}) =>\n` + + ` this.request({\n` + + ` path: \`/webhooks/public_key\`,\n` + + ` method: "GET",\n` + + ` secure: true,\n` + + ` ...params,\n` + + ` }),`, + replacement: ` fetchPublicKey: (params: RequestParams = {}) =>\n` + + ` this.request({\n` + + ` path: \`/webhooks/public_key\`,\n` + + ` method: "GET",\n` + + ` secure: true,\n` + + ` format: "text",\n` + + ` ...params,\n` + + ` }),`, + }, + { + // Error bodies are always JSON even for text/plain success responses, + // so parse `r.error` as JSON when `responseFormat` is "text". + match: ` const data = !responseFormat\n` + + ` ? r\n` + + ` : await response[responseFormat]()\n` + + ` .then((data) => {\n` + + ` if (r.ok) {\n` + + ` r.data = data;\n` + + ` } else {\n` + + ` r.error = data;\n` + + ` }\n` + + ` return r;\n` + + ` })\n` + + ` .catch((e) => {\n` + + ` r.error = e;\n` + + ` return r;\n` + + ` });`, + replacement: ` const data = !responseFormat\n` + + ` ? r\n` + + ` : await response[responseFormat]()\n` + + ` .then((data) => {\n` + + ` if (r.ok) {\n` + + ` r.data = data;\n` + + ` } else if (responseFormat === "text") {\n` + + ` try {\n` + + ` r.error = JSON.parse(data as unknown as string);\n` + + ` } catch {\n` + + ` r.error = data;\n` + + ` }\n` + + ` } else {\n` + + ` r.error = data;\n` + + ` }\n` + + ` return r;\n` + + ` })\n` + + ` .catch((e) => {\n` + + ` r.error = e;\n` + + ` return r;\n` + + ` });`, + }, +]; + +const source = await Deno.readTextFile(CLIENT_PATH); +let patched = source; +const errors: string[] = []; + +for (const { match, replacement } of patches) { + const occurrences = patched.split(match).length - 1; + + if (occurrences === 0) { + // Already patched, or the generator's output changed shape. + if (patched.includes(replacement)) continue; + errors.push( + `Could not find expected generated block to patch:\n${match}`, + ); + continue; + } + + if (occurrences > 1) { + errors.push( + `Expected exactly one match, found ${occurrences} for:\n${match}`, + ); + continue; + } + + patched = patched.replace(match, replacement); +} + +if (errors.length > 0) { + console.error("patch_openapi_client.ts failed:\n" + errors.join("\n\n")); + Deno.exit(1); +} + +if (patched !== source) { + await Deno.writeTextFile(CLIENT_PATH, patched); + console.log("patch_openapi_client.ts: patched openapi/client.ts"); +} else { + console.log("patch_openapi_client.ts: no changes needed"); +} diff --git a/tests/webhook.test.ts b/tests/webhook.test.ts index 771bced..b9ff2bc 100644 --- a/tests/webhook.test.ts +++ b/tests/webhook.test.ts @@ -1,16 +1,30 @@ -import { lagoTest, unauthorizedErrorResponse } from "./utils.ts"; +import { assertEquals } from "../dev_deps.ts"; +import { + lagoTest, + setupMockClient, + unauthorizedErrorResponse, +} from "./utils.ts"; Deno.test( - "Successfully sent webhook public key request responds with 2xx", + "Successfully sent webhook public key request responds with 2xx and the key as data", async (t) => { - await lagoTest({ - t, - testType: "200", - route: "GET@/api/v1/webhooks/public_key", - clientPath: ["webhooks", "fetchPublicKey"], - inputParams: [], - responseObject: "aGVsbG8=", - status: 200, + // text/plain body, exercised directly instead of via lagoTest. + const publicKey = "aGVsbG8="; + + const client = setupMockClient( + "GET@/api/v1/webhooks/public_key", + () => + new Response(publicKey, { + status: 200, + headers: { "Content-Type": "text/plain" }, + }), + ); + + await t.step("returns 200 response with the key in data", async () => { + const response = await client.webhooks.fetchPublicKey(); + + assertEquals(response.status, 200); + assertEquals(response.data, publicKey); }); }, ); @@ -26,3 +40,38 @@ Deno.test("Status code is not 2xx", async (t) => { status: 422, }); }); + +Deno.test( + "Successfully sent webhook json public key request responds with 2xx and the key as data", + async (t) => { + const responseObject = { webhook: { public_key: "aGVsbG8=" } }; + + const client = setupMockClient( + "GET@/api/v1/webhooks/json_public_key", + () => + new Response(JSON.stringify(responseObject), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await t.step("returns 200 response with the key in data", async () => { + const response = await client.webhooks.fetchJsonPublicKey(); + + assertEquals(response.status, 200); + assertEquals(response.data.webhook.public_key, "aGVsbG8="); + }); + }, +); + +Deno.test("Json public key status code is not 2xx", async (t) => { + await lagoTest({ + t, + testType: "error", + route: "GET@/api/v1/webhooks/json_public_key", + clientPath: ["webhooks", "fetchJsonPublicKey"], + inputParams: [], + responseObject: unauthorizedErrorResponse, + status: 422, + }); +});