Skip to content
Open
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
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>`, 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:

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion requestReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
76 changes: 76 additions & 0 deletions topic.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
47 changes: 47 additions & 0 deletions topic.ts
Original file line number Diff line number Diff line change
@@ -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-<hash>".
//
// 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;
Loading