diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bb6db7..9b0533b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,5 @@ jobs: deno-version: v1.x - name: Build project run: deno task build + - name: Typecheck webhook types + run: deno task typecheck diff --git a/README.md b/README.md index 776433c..6c4bb13 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,44 @@ try { } ``` +### Webhook payload types + +The SDK exposes TypeScript types for every webhook event Lago emits. The types are generated from the OpenAPI spec, so they stay in sync with the API. + +Indexed lookup by `webhook_type`: + +```typescript +import type { LagoWebhookPayloads } from 'lago-javascript-client'; + +app.post('/webhooks', (req, res) => { + const event = req.body as LagoWebhookPayloads['alert.triggered']; + // event.triggered_alert is fully typed + console.log(event.triggered_alert.external_customer_id); + res.sendStatus(200); +}); +``` + +Discriminated union for handlers that cover multiple events: + +```typescript +import type { LagoWebhookPayload } from 'lago-javascript-client'; + +function handle(event: LagoWebhookPayload) { + switch (event.webhook_type) { + case 'alert.triggered': + // event.triggered_alert is typed + return event.triggered_alert; + case 'invoice.created': + // event.invoice is typed + return event.invoice; + case 'subscription.terminated': + return event.subscription; + } +} +``` + +A `WebhookOf` helper is also exported for picking a single payload type by name, and `LagoWebhookType` gives the union of all event-type strings. + ## Development Uses [dnt](https://github.com/denoland/dnt) to build and test for Deno and Node. diff --git a/deno.jsonc b/deno.jsonc index dcd79aa..c040e07 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,8 +1,12 @@ { "tasks": { - "build": "deno task generate:openapi && deno task generate:npm-package", + "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" } } diff --git a/mod.ts b/mod.ts index 7ff7151..f445b49 100644 --- a/mod.ts +++ b/mod.ts @@ -83,3 +83,11 @@ export { } from "./logging_rate_limit_observer.ts"; export * from "./openapi/client.ts"; + +// Webhook payload types (see webhook_types.ts and openapi/webhooks.ts) +export type { + LagoWebhookPayload, + LagoWebhookPayloads, + LagoWebhookType, + WebhookOf, +} from "./webhook_types.ts"; diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index e1613ba..a3a19ad 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -32,7 +32,7 @@ await build({ // package.json properties name: "lago-javascript-client", sideEffects: false, - version: "v1.47.0", + version: "v1.47.1", description: "Lago JavaScript API Client", repository: { type: "git", diff --git a/tests/webhook_types.test.ts b/tests/webhook_types.test.ts new file mode 100644 index 0000000..5267368 --- /dev/null +++ b/tests/webhook_types.test.ts @@ -0,0 +1,195 @@ +// Type-level tests for the webhook payload types exported from mod.ts. +// +// These assertions run at compile time. The Deno.test() wrappers exist so +// the file is picked up by `deno task test` (which typechecks the file by +// default) and any compile-time failure surfaces as a Deno typecheck error. +// The runtime bodies are deliberately trivial. +// +// Scope: we verify the envelope shape (webhook_type, object_type, +// organization_id), the presence of the nested object on each event, and +// the discriminated-union narrowing. We deliberately do NOT assert +// structural equality between a webhook's nested object and the SDK's +// component types (e.g. `CustomerObject`), because those come from a +// different generator that handles nullability slightly differently. See +// the note in ../webhook_types.ts for details. + +import { assertEquals } from "../dev_deps.ts"; +import type { + LagoWebhookPayload, + LagoWebhookPayloads, + LagoWebhookType, + WebhookOf, +} from "../mod.ts"; + +// --------------------------------------------------------------------------- +// Type-level test helpers +// --------------------------------------------------------------------------- + +/** Compiles only when T is `true`. Use to anchor an Equal<> assertion. */ +type Expect = T; + +/** Strict structural equality (catches optional/readonly differences). */ +type Equal = (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false; + +/** Looser check: A is assignable to B. */ +type Extends = A extends B ? true : false; + +// --------------------------------------------------------------------------- +// 1. Indexed lookup keyed by the dotted webhook_type +// --------------------------------------------------------------------------- + +Deno.test("LagoWebhookPayloads keys events by dotted webhook_type", () => { + // Each of these resolves to a concrete payload type; a missing key is a compile error. + type _Alert = LagoWebhookPayloads["alert.triggered"]; + type _Invoice = LagoWebhookPayloads["invoice.created"]; + type _Customer = LagoWebhookPayloads["customer.created"]; + type _Subscription = LagoWebhookPayloads["subscription.terminated"]; + type _Wallet = LagoWebhookPayloads["wallet.depleted_ongoing_balance"]; + type _Payment = LagoWebhookPayloads["payment.succeeded"]; + type _CreditNote = LagoWebhookPayloads["credit_note.created"]; + + assertEquals(true, true); +}); + +Deno.test("alert.triggered envelope is correctly typed", () => { + type Payload = LagoWebhookPayloads["alert.triggered"]; + type _C1 = Expect>; + type _C2 = Expect>; + type _C3 = Expect>; + // `triggered_alert` is the nested object; we only check it exists and is non-nullable. + type _C4 = Expect>; + + assertEquals(true, true); +}); + +Deno.test("invoice.created envelope is correctly typed", () => { + type Payload = LagoWebhookPayloads["invoice.created"]; + type _C1 = Expect>; + type _C2 = Expect>; + type _C3 = Expect>; + type _C4 = Expect>; + + assertEquals(true, true); +}); + +Deno.test("customer.created envelope is correctly typed", () => { + type Payload = LagoWebhookPayloads["customer.created"]; + type _C1 = Expect>; + type _C2 = Expect>; + type _C3 = Expect>; + + assertEquals(true, true); +}); + +Deno.test("subscription.terminated envelope is correctly typed", () => { + type Payload = LagoWebhookPayloads["subscription.terminated"]; + type _C1 = Expect>; + type _C2 = Expect>; + type _C3 = Expect>; + + assertEquals(true, true); +}); + +Deno.test("wallet.depleted_ongoing_balance envelope is correctly typed", () => { + type Payload = LagoWebhookPayloads["wallet.depleted_ongoing_balance"]; + type _C1 = Expect>; + type _C2 = Expect>; + type _C3 = Expect>; + + assertEquals(true, true); +}); + +// --------------------------------------------------------------------------- +// 2. WebhookOf helper +// --------------------------------------------------------------------------- + +Deno.test("WebhookOf matches LagoWebhookPayloads[T]", () => { + type _C1 = Expect< + Equal, LagoWebhookPayloads["alert.triggered"]> + >; + type _C2 = Expect< + Equal, LagoWebhookPayloads["invoice.created"]> + >; + type _C3 = Expect< + Equal< + WebhookOf<"subscription.terminated">, + LagoWebhookPayloads["subscription.terminated"] + > + >; + + assertEquals(true, true); +}); + +// --------------------------------------------------------------------------- +// 3. LagoWebhookType union +// --------------------------------------------------------------------------- + +Deno.test("LagoWebhookType union contains documented events", () => { + const _v1: LagoWebhookType = "alert.triggered"; + const _v2: LagoWebhookType = "invoice.created"; + const _v3: LagoWebhookType = "customer.created"; + const _v4: LagoWebhookType = "subscription.terminated"; + const _v5: LagoWebhookType = "wallet.depleted_ongoing_balance"; + const _v6: LagoWebhookType = "payment.succeeded"; + const _v7: LagoWebhookType = "credit_note.created"; + + assertEquals(true, true); +}); + +// --------------------------------------------------------------------------- +// 4. LagoWebhookPayload discriminated-union narrowing +// --------------------------------------------------------------------------- + +Deno.test("LagoWebhookPayload narrows correctly via webhook_type", () => { + function handle(event: LagoWebhookPayload): unknown { + switch (event.webhook_type) { + case "alert.triggered": + // After narrowing, the alert-specific field is accessible. + return event.triggered_alert; + case "invoice.created": + return event.invoice; + case "customer.created": + return event.customer; + case "subscription.terminated": + return event.subscription; + default: + return null; + } + } + // Reference the function so TS does not elide it. + assertEquals(typeof handle, "function"); +}); + +// --------------------------------------------------------------------------- +// 5. Negative tests — these MUST fail to compile if @ts-expect-error is removed +// --------------------------------------------------------------------------- + +Deno.test("Unknown webhook event names are rejected at compile time", () => { + // @ts-expect-error - "not.a.real.event" is not a key of LagoWebhookPayloads + type _Bad1 = LagoWebhookPayloads["not.a.real.event"]; + + // @ts-expect-error - WebhookOf only accepts LagoWebhookType keys + type _Bad2 = WebhookOf<"not.a.real.event">; + + // @ts-expect-error - "definitely.not.a.lago.event" is not in the union + const _bad: LagoWebhookType = "definitely.not.a.lago.event"; + + assertEquals(true, true); +}); + +Deno.test("Narrowed branches reject fields belonging to other events", () => { + function _check(event: LagoWebhookPayload) { + if (event.webhook_type === "alert.triggered") { + // @ts-expect-error - `invoice` is not present on the alert.triggered branch + const _x = event.invoice; + return _x; + } + if (event.webhook_type === "invoice.created") { + // @ts-expect-error - `triggered_alert` is not present on the invoice.created branch + const _y = event.triggered_alert; + return _y; + } + } + assertEquals(true, true); +}); diff --git a/webhook_types.ts b/webhook_types.ts new file mode 100644 index 0000000..3dcb2a1 --- /dev/null +++ b/webhook_types.ts @@ -0,0 +1,90 @@ +// User-facing TypeScript types for Lago webhook payloads. +// +// Backed by ./openapi/webhooks.ts, which is auto-generated from the Lago +// OpenAPI 3.1 spec (`webhooks:` key) via `deno task generate:webhooks`. +// This file is hand-written and is safe across regenerations. +// +// The generator output keys events by their snake_case operationId +// (e.g. `alert_triggered`). We re-key here by the dotted `webhook_type` +// literal (e.g. `alert.triggered`) so that the public API matches the +// string customers actually see on the wire. +// +// Note on nested types: the payload types here are produced by +// `openapi-typescript`, while the SDK's component exports (e.g. +// `CustomerObject`, `InvoiceObjectExtended`) are produced by +// `swagger-typescript-api`. The two generators interpret the same schema +// slightly differently for nullable fields, so a value typed as +// `LagoWebhookPayloads['customer.created']['customer']` is not always +// structurally equal to `CustomerObject` from the main SDK exports. Both +// are valid views of the same wire shape; the openapi-typescript view is +// the more accurate one for what actually arrives over the network. If +// you pass webhook objects into helpers typed with SDK components, a +// narrowing cast may be required. + +import type { webhooks } from "./openapi/webhooks.ts"; + +/** Extract the `application/json` body of a webhook's POST request. */ +type _PayloadOf = webhooks[K] extends { + post: { requestBody?: { content: { "application/json": infer P } } }; +} ? P + : never; + +/** Extract the `webhook_type` literal (e.g. `"alert.triggered"`) from a payload. */ +type _WebhookTypeOf = _PayloadOf extends + { webhook_type: infer T } ? T extends string ? T : never : never; + +/** + * Map of webhook event name to its typed payload. + * + * @example + * ```ts + * import type { LagoWebhookPayloads } from "lago-javascript-client"; + * + * app.post("/webhooks", (req, res) => { + * const event = req.body as LagoWebhookPayloads["alert.triggered"]; + * console.log(event.triggered_alert.external_customer_id); + * }); + * ``` + */ +export type LagoWebhookPayloads = { + [K in keyof webhooks as _WebhookTypeOf]: _PayloadOf; +}; + +/** + * Discriminated union of every webhook payload. Narrow with the `webhook_type` + * field in a `switch` to get a fully typed branch. + * + * @example + * ```ts + * import type { LagoWebhookPayload } from "lago-javascript-client"; + * + * function handle(event: LagoWebhookPayload) { + * switch (event.webhook_type) { + * case "alert.triggered": + * // event is narrowed; event.triggered_alert is fully typed + * break; + * case "invoice.created": + * // event.invoice is fully typed + * break; + * } + * } + * ``` + */ +export type LagoWebhookPayload = LagoWebhookPayloads[keyof LagoWebhookPayloads]; + +/** + * Union of every `webhook_type` string emitted by Lago, e.g. + * `"alert.triggered" | "invoice.created" | ...`. + */ +export type LagoWebhookType = keyof LagoWebhookPayloads; + +/** + * Helper to look up a single payload type by its `webhook_type` string. + * + * @example + * ```ts + * import type { WebhookOf } from "lago-javascript-client"; + * type InvoiceCreated = WebhookOf<"invoice.created">; + * ``` + */ +export type WebhookOf = LagoWebhookPayloads[T];