From f7c893af57e01a691ea06612e2a3a5b08054c0d3 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Mon, 6 Jul 2026 18:58:21 +0200 Subject: [PATCH 1/2] fix: add fetchJsonPublicKey, fix fetchPublicKey text parsing --- deno.jsonc | 25 ++++--- scripts/patch_openapi_client.ts | 128 ++++++++++++++++++++++++++++++++ tests/webhook.test.ts | 71 +++++++++++++++--- 3 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 scripts/patch_openapi_client.ts diff --git a/deno.jsonc b/deno.jsonc index c040e07..123a629 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,12 +1,17 @@ { - "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", + // The deprecated fetchPublicKey endpoint responds text/plain, which + // swagger-typescript-api can't map to a response format, so it comes out + // with no `format` and `.data` is never populated. This patch step fixes + // that endpoint up after generation (see scripts/patch_openapi_client.ts). + // fetchJsonPublicKey needs no patch, it's a normal JSON endpoint. + "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..951e11a --- /dev/null +++ b/scripts/patch_openapi_client.ts @@ -0,0 +1,128 @@ +// The deprecated GET /webhooks/public_key endpoint (see webhooks.fetchPublicKey) +// responds with `text/plain`, not JSON. swagger-typescript-api has no mapping +// for that content type, so the generated method comes out with no `format` +// at all, and `.data` is never populated (see openapi/client.ts's +// HttpClient#request: `responseFormat` stays falsy and `r.data` is never +// assigned). +// +// The real fix for new integrations is GET /webhooks/json_public_key +// (see webhooks.fetchJsonPublicKey), which is now documented in the spec and +// needs no patch at all — it generates a normal `format: "json"` method like +// every other endpoint. This script only exists so the deprecated method +// stops silently returning null for anyone still calling it. +// +// Runs right after `generate:openapi` (wired in deno.jsonc) so the fix +// survives regeneration from the live spec. Fails loudly if +// swagger-typescript-api's output for this endpoint ever changes shape, +// instead of silently doing nothing. + +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` + + ` }),`, + }, + { + // `format: "text"` above means a failed request (e.g. 401) also reads + // its body with `response.text()` instead of `response.json()`, but + // Lago's error bodies are always JSON regardless of the success + // content type. Without this, `r.error` would be a raw JSON string + // instead of a parsed object, unlike every other endpoint in the SDK. + 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) { + // Either already patched (unlikely, generator always overwrites) or + // swagger-typescript-api changed how it emits this endpoint. + 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..67b78a3 100644 --- a/tests/webhook.test.ts +++ b/tests/webhook.test.ts @@ -1,16 +1,32 @@ -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, + // This endpoint responds with a raw `text/plain` body (not JSON), so it + // is exercised directly instead of through `lagoTest`, which always + // JSON-encodes `responseObject`. + 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 +42,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, + }); +}); From be65ab7aef122670089f7c3a50973a8d087ef673 Mon Sep 17 00:00:00 2001 From: Lago Developer Date: Wed, 8 Jul 2026 11:13:16 +0200 Subject: [PATCH 2/2] chore: trim comments --- deno.jsonc | 7 ++----- scripts/patch_openapi_client.ts | 32 ++++++++------------------------ tests/webhook.test.ts | 4 +--- 3 files changed, 11 insertions(+), 32 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index 123a629..e9b8769 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -2,11 +2,8 @@ "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", - // The deprecated fetchPublicKey endpoint responds text/plain, which - // swagger-typescript-api can't map to a response format, so it comes out - // with no `format` and `.data` is never populated. This patch step fixes - // that endpoint up after generation (see scripts/patch_openapi_client.ts). - // fetchJsonPublicKey needs no patch, it's a normal JSON endpoint. + // 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", diff --git a/scripts/patch_openapi_client.ts b/scripts/patch_openapi_client.ts index 951e11a..74b7942 100644 --- a/scripts/patch_openapi_client.ts +++ b/scripts/patch_openapi_client.ts @@ -1,20 +1,8 @@ -// The deprecated GET /webhooks/public_key endpoint (see webhooks.fetchPublicKey) -// responds with `text/plain`, not JSON. swagger-typescript-api has no mapping -// for that content type, so the generated method comes out with no `format` -// at all, and `.data` is never populated (see openapi/client.ts's -// HttpClient#request: `responseFormat` stays falsy and `r.data` is never -// assigned). -// -// The real fix for new integrations is GET /webhooks/json_public_key -// (see webhooks.fetchJsonPublicKey), which is now documented in the spec and -// needs no patch at all — it generates a normal `format: "json"` method like -// every other endpoint. This script only exists so the deprecated method -// stops silently returning null for anyone still calling it. -// -// Runs right after `generate:openapi` (wired in deno.jsonc) so the fix -// survives regeneration from the live spec. Fails loudly if -// swagger-typescript-api's output for this endpoint ever changes shape, -// instead of silently doing nothing. +// 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"; @@ -44,11 +32,8 @@ const patches: TextEndpointPatch[] = [ ` }),`, }, { - // `format: "text"` above means a failed request (e.g. 401) also reads - // its body with `response.text()` instead of `response.json()`, but - // Lago's error bodies are always JSON regardless of the success - // content type. Without this, `r.error` would be a raw JSON string - // instead of a parsed object, unlike every other endpoint in the SDK. + // 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` + @@ -96,8 +81,7 @@ for (const { match, replacement } of patches) { const occurrences = patched.split(match).length - 1; if (occurrences === 0) { - // Either already patched (unlikely, generator always overwrites) or - // swagger-typescript-api changed how it emits this endpoint. + // 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}`, diff --git a/tests/webhook.test.ts b/tests/webhook.test.ts index 67b78a3..b9ff2bc 100644 --- a/tests/webhook.test.ts +++ b/tests/webhook.test.ts @@ -8,9 +8,7 @@ import { Deno.test( "Successfully sent webhook public key request responds with 2xx and the key as data", async (t) => { - // This endpoint responds with a raw `text/plain` body (not JSON), so it - // is exercised directly instead of through `lagoTest`, which always - // JSON-encodes `responseObject`. + // text/plain body, exercised directly instead of via lagoTest. const publicKey = "aGVsbG8="; const client = setupMockClient(