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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ 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
The resolved topic becomes the file name through `safeStem`, the same rule
the registry uses for its doc samples: each run of characters outside
`[A-Za-z0-9._-]` becomes one `.`, with leading and trailing dots dropped
(`soft bounce` → `soft.bounce.json`). A capture therefore overwrites the
doc sample for its event instead of landing beside it.

The resolver and `safeStem` are covered by unit tests in `topic.test.ts`. Run them with
`yarn test`.

`provenance` is optional and records, per version, how that version's
Expand Down
4 changes: 2 additions & 2 deletions providers/relay-messenger/index.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"label": "Relay",
"configs": {
"latest_version": "latest",
"latest_version": "2026-08-30",
"topic_identifier": "event_type"
},
"provenance": {
"latest": {
"2026-08-30": {
"sourced_via": "docs",
"sourced_on": "2026-10-26"
}
Expand Down
4 changes: 2 additions & 2 deletions requestReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import express from "express";
import * as fs from "fs";
import * as path from "path";
import crypto from "crypto";
import { resolveTopic } from "./topic";
import { resolveTopic, safeStem } from "./topic";

const app = express();
const port = process.env.PORT || 9001;
Expand Down Expand Up @@ -85,7 +85,7 @@ const outputToFile = (output: any, provider: string, version: string) => {
"providers",
provider,
version,
`${topic.replace(/[/:\\]/g, ".")}.json`
`${safeStem(topic)}.json`
),
JSON.stringify(output, null, 2)
);
Expand Down
41 changes: 40 additions & 1 deletion topic.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { strict as assert } from "node:assert";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { describe, it } from "node:test";

import { resolveTopic, scalarOrUndefined } from "./topic";
import { resolveTopic, safeStem, scalarOrUndefined } from "./topic";

describe("resolveTopic", () => {
it("resolves a plain key", () => {
Expand Down Expand Up @@ -83,3 +85,40 @@ describe("scalarOrUndefined", () => {
assert.equal(scalarOrUndefined(true), undefined);
});
});

// Each case is a topic whose doc sample is already in providers/ under the registry's
// stem, so a capture of the same event has to be written to that same file.
describe("safeStem", () => {
const cases: [string, string, string][] = [
["pubnub/latest", "pubnub.com/schemas/events/channel.created?v=1.0.0",
"pubnub.com.schemas.events.channel.created.v.1.0.0"],
["mailtrap/latest", "soft bounce", "soft.bounce"],
["descope/latest", "Change Password", "Change.Password"],
["document360/latest", "Extension token deleted", "Extension.token.deleted"],
["flexport/latest", "/shipment_leg#departed", "shipment_leg.departed"],
["castle/v1", "$list_item.created", "list_item.created"],
];

for (const [dir, topic, stem] of cases) {
it(`names ${JSON.stringify(topic)} as the registry does (${dir}/${stem}.json)`, () => {
assert.equal(safeStem(topic), stem);
assert.ok(existsSync(join(__dirname, "providers", dir, `${stem}.json`)));
});
}

it("collapses a run of unsafe characters into one dot", () => {
assert.equal(safeStem("a :/\\ b"), "a.b");
});

it("keeps dots, dashes and underscores", () => {
assert.equal(safeStem("charge.succeeded_v-2"), "charge.succeeded_v-2");
});

it("strips leading and trailing dots", () => {
assert.equal(safeStem("..a.."), "a");
});

it("never returns an empty stem", () => {
assert.equal(safeStem("///"), "_unnamed");
});
});
9 changes: 9 additions & 0 deletions topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,12 @@ export const scalarOrUndefined = (value: any): string | undefined =>
typeof value === "string" || typeof value === "number"
? String(value)
: undefined;

// The file stem for a topic, the same rule hookdeck/webhook-registry's generator
// uses for every sample it exports (safe_stem in generator/harvest_doc_examples.py):
// each run of characters outside [A-Za-z0-9._-] becomes one ".", and leading or
// trailing dots are dropped. A capture has to land on the doc sample's file name,
// otherwise it is written as a duplicate beside it ("soft bounce" -> soft.bounce,
// not "soft bounce"; a PubNub "...channel.created?v=1.0.0" -> "...channel.created.v.1.0.0").
export const safeStem = (topic: string): string =>
topic.replace(/[^A-Za-z0-9._-]+/g, ".").replace(/^\.+|\.+$/g, "") || "_unnamed";
Loading