Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions deno.jsonc
Original file line number Diff line number Diff line change
@@ -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"
}
}
112 changes: 112 additions & 0 deletions scripts/patch_openapi_client.ts
Original file line number Diff line number Diff line change
@@ -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<string, ApiErrorUnauthorized>({\n` +
` path: \`/webhooks/public_key\`,\n` +
` method: "GET",\n` +
` secure: true,\n` +
` ...params,\n` +
` }),`,
replacement: ` fetchPublicKey: (params: RequestParams = {}) =>\n` +
` this.request<string, ApiErrorUnauthorized>({\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");
}
69 changes: 59 additions & 10 deletions tests/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
},
);
Expand All @@ -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,
});
});
Loading