diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6fa7391 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run tests + run: yarn test diff --git a/README.md b/README.md index f921c87..70f2442 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,26 @@ resolves wins, so the most specific one goes first: } ``` +A `topic_identifier` may be a plain key or a path into the body, so +providers that nest the event type are named correctly on capture: + +| Form | Example | Resolves | +|---|---|---| +| plain key | `event` | `body.event`, or the header of that name | +| dotted path | `data.type` | `body.data.type` | +| array segment | `events[].eventType` | first element of `body.events` | +| nested arrays | `entry[].changes[].field` | first element at each level | + +A header or body key whose literal name contains a dot is matched before +the value is treated as a path, so real keys always win. + +Only a scalar can name a file. If a path resolves to an object or array the +sample falls back to `untitled-`, which is a signal that the +identifier is wrong for that payload rather than something to work around. + +The resolver is covered by unit tests in `topic.test.ts`. Run them with +`yarn test`. + `provenance` is optional and records, per version, how that version's samples were obtained: diff --git a/package.json b/package.json index 57c3799..d02891a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "dev:receiver": "nodemon --exec ./node_modules/.bin/ts-node requestReceiver.ts --watch *.ts", "setup:scrapfly": "./node_modules/.bin/ts-node scripts/scrapfly/setup.ts", "capture:scrapfly": "./node_modules/.bin/ts-node scripts/scrapfly/capture.ts", - "generate:ordinal": "./node_modules/.bin/ts-node scripts/ordinal/docs.ts" + "generate:ordinal": "./node_modules/.bin/ts-node scripts/ordinal/docs.ts", + "test": "node --require ts-node/register --test \"*.test.ts\"" }, "devDependencies": { "@types/express": "^4.17.17", diff --git a/requestReceiver.ts b/requestReceiver.ts index 09896c4..59f045e 100644 --- a/requestReceiver.ts +++ b/requestReceiver.ts @@ -2,6 +2,7 @@ import express from "express"; import * as fs from "fs"; import * as path from "path"; import crypto from "crypto"; +import { resolveTopic } from "./topic"; const app = express(); const port = process.env.PORT || 9001; @@ -67,7 +68,7 @@ const outputToFile = (output: any, provider: string, version: string) => { let topic; for (const identifier of topic_identifiers) { - topic = output.headers[identifier] || output.body?.[identifier]; + topic = resolveTopic(output.headers, identifier) ?? resolveTopic(output.body, identifier); if (topic) break; } diff --git a/topic.test.ts b/topic.test.ts new file mode 100644 index 0000000..b399d76 --- /dev/null +++ b/topic.test.ts @@ -0,0 +1,76 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { resolveTopic, scalarOrUndefined } from "./topic"; + +describe("resolveTopic", () => { + it("resolves a plain key", () => { + assert.equal(resolveTopic({ event: "charge.succeeded" }, "event"), "charge.succeeded"); + }); + + it("resolves a dotted path", () => { + assert.equal(resolveTopic({ data: { type: "invoice.paid" } }, "data.type"), "invoice.paid"); + }); + + it("resolves an array segment using the first element", () => { + const body = { events: [{ eventType: "user.created" }, { eventType: "user.deleted" }] }; + assert.equal(resolveTopic(body, "events[].eventType"), "user.created"); + }); + + it("resolves nested array segments", () => { + const body = { entry: [{ changes: [{ field: "messages" }] }] }; + assert.equal(resolveTopic(body, "entry[].changes[].field"), "messages"); + }); + + it("resolves an array segment nested under a dotted path", () => { + const body = { data: { events: [{ eventType: "user.lifecycle.activate" }] } }; + assert.equal(resolveTopic(body, "data.events[].eventType"), "user.lifecycle.activate"); + }); + + // A header called "x-thing.type" is a real key, not a path into "x-thing". + it("prefers a literal key over interpreting it as a path", () => { + const headers = { "data.type": "literal", data: { type: "viaPath" } }; + assert.equal(resolveTopic(headers, "data.type"), "literal"); + }); + + it("returns undefined when the path lands on an object", () => { + assert.equal(resolveTopic({ data: { type: { nested: true } } }, "data.type"), undefined); + }); + + it("returns undefined when an array segment finds no array", () => { + assert.equal(resolveTopic({ events: { eventType: "x" } }, "events[].eventType"), undefined); + }); + + it("returns undefined for an empty array", () => { + assert.equal(resolveTopic({ events: [] }, "events[].eventType"), undefined); + }); + + it("returns undefined when the path breaks part way", () => { + assert.equal(resolveTopic({ data: null }, "data.type"), undefined); + assert.equal(resolveTopic({}, "a.b.c"), undefined); + }); + + it("returns undefined for a null or undefined source", () => { + assert.equal(resolveTopic(null, "event"), undefined); + assert.equal(resolveTopic(undefined, "event"), undefined); + }); + + it("coerces a numeric value to a string", () => { + assert.equal(resolveTopic({ data: { type: 42 } }, "data.type"), "42"); + }); +}); + +describe("scalarOrUndefined", () => { + it("passes through strings and stringifies numbers", () => { + assert.equal(scalarOrUndefined("a"), "a"); + assert.equal(scalarOrUndefined(0), "0"); + }); + + it("rejects anything that would stringify to [object Object]", () => { + assert.equal(scalarOrUndefined({}), undefined); + assert.equal(scalarOrUndefined([]), undefined); + assert.equal(scalarOrUndefined(null), undefined); + assert.equal(scalarOrUndefined(undefined), undefined); + assert.equal(scalarOrUndefined(true), undefined); + }); +}); diff --git a/topic.ts b/topic.ts new file mode 100644 index 0000000..fc83d30 --- /dev/null +++ b/topic.ts @@ -0,0 +1,47 @@ +// Resolve a topic_identifier against a headers or body object. +// +// Most identifiers are a plain key ("x-shopify-topic", "event"), but many +// providers nest the event type ("data.type", "events[].eventType"), so a +// flat lookup alone leaves those captures named "untitled-". +// +// Supported forms: +// event a plain key +// data.type a dotted path +// events[].eventType an array segment; the first element is used +// entry[].changes[].field nested array segments +// +// A literal key that exists is preferred over path interpretation, so a header +// whose real name contains a dot still resolves. +export const resolveTopic = ( + source: any, + identifier: string +): string | undefined => { + if (source == null) return undefined; + + if (typeof source === "object" && source[identifier] !== undefined) { + return scalarOrUndefined(source[identifier]); + } + + let current = source; + for (const segment of identifier.split(".")) { + if (current == null) return undefined; + + const is_array = segment.endsWith("[]"); + current = current[is_array ? segment.slice(0, -2) : segment]; + + if (is_array) { + if (!Array.isArray(current)) return undefined; + current = current[0]; + } + } + + return scalarOrUndefined(current); +}; + +// Only a scalar can name a file. Anything else means the path landed somewhere +// unintended, and falling back to "untitled-" is more honest than +// "[object Object]". +export const scalarOrUndefined = (value: any): string | undefined => + typeof value === "string" || typeof value === "number" + ? String(value) + : undefined;