Skip to content

Commit 43acb66

Browse files
feat(docs): add WebhookPayloadSnippet component for embedding webhook payload examples (#6105)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Catherine Deskur <[email protected]> Co-authored-by: Catherine Deskur <[email protected]>
1 parent 914eb0a commit 43acb66

File tree

11 files changed

+318
-1
lines changed

11 files changed

+318
-1
lines changed

packages/commons/docs-loader/src/editable-docs-loader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class EditableDocsLoader implements DocsLoader {
3737
getEndpointByLocator = (method: HttpMethod, path: string, example?: string) =>
3838
this.readOnlyDocsLoader.getEndpointByLocator(method, path, example);
3939

40+
getWebhookByLocator = (webhookId: string) => this.readOnlyDocsLoader.getWebhookByLocator(webhookId);
41+
4042
getRoot = () => this.readOnlyDocsLoader.getRoot();
4143

4244
getNavigationNode = (id: string) => this.readOnlyDocsLoader.getNavigationNode(id);

packages/commons/docs-loader/src/prefetched-docs-loader.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,16 @@ export class PrefetchedDocsLoader implements DocsLoader<false> {
140140
return this.notSupported("getEndpointByLocator");
141141
}
142142

143+
getWebhookByLocator(_webhookId: string):
144+
| {
145+
apiDefinitionId: ApiDefinition.ApiDefinitionId;
146+
webhook: ApiDefinition.WebhookDefinition;
147+
slug: Slug | undefined;
148+
}
149+
| undefined {
150+
return this.notSupported("getWebhookByLocator");
151+
}
152+
143153
getRoot(): FernNavigation.RootNode {
144154
return this.notSupported("getRoot");
145155
}

packages/commons/docs-loader/src/readonly-docs-loader.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type DynamicIRsByLanguage,
88
type FernFonts,
99
findEndpoint,
10+
findWebhook,
1011
generateFernColorPalette,
1112
generateFonts,
1213
getDocsUrlMetadata,
@@ -631,6 +632,52 @@ const getEndpointByLocator = async (
631632
notFound();
632633
};
633634

635+
const getWebhookByLocator = async (
636+
domainKey: string,
637+
webhookId: string
638+
): Promise<
639+
| {
640+
apiDefinitionId: ApiDefinition.ApiDefinitionId;
641+
webhook: ApiDefinition.WebhookDefinition;
642+
slug: Slug | undefined;
643+
}
644+
| undefined
645+
> => {
646+
const root = await unsafe_getFullRoot(domainKey);
647+
648+
const apiIds = new Set<string>();
649+
FernNavigation.traverseBF(root, (node) => {
650+
if (FernNavigation.hasMetadata(node) && "apiDefinitionId" in node && node.apiDefinitionId) {
651+
apiIds.add(node.apiDefinitionId);
652+
}
653+
return CONTINUE;
654+
});
655+
656+
for (const apiId of apiIds) {
657+
const api = await getApi(domainKey, apiId);
658+
const webhook = findWebhook({
659+
apiDefinition: api,
660+
webhookId
661+
});
662+
if (webhook != null) {
663+
const webhookNode = FernNavigation.NodeCollector.collect(root)
664+
.getNodesInOrder()
665+
.filter(FernNavigation.hasMetadata)
666+
.find(
667+
(node) =>
668+
node.type === "webhook" && node.apiDefinitionId === api.id && node.webhookId === webhook.id
669+
);
670+
return {
671+
apiDefinitionId: api.id,
672+
webhook,
673+
slug: webhookNode?.slug
674+
};
675+
}
676+
}
677+
console.error(`Could not find webhook ${webhookId}`);
678+
return undefined;
679+
};
680+
634681
export function convertResponseToRootNode(response: DocsV2Read.LoadDocsForUrlResponse, edgeFlags: EdgeFlags) {
635682
let root: FernNavigation.RootNode | undefined;
636683
if (response.definition.config.root) {
@@ -1434,6 +1481,13 @@ const createCachedDocsLoaderImpl = async (
14341481
{ tags: [domainKey, "endpointByLocator"] }
14351482
)
14361483
),
1484+
getWebhookByLocator: cache(
1485+
unstable_cache(
1486+
(webhookId: string) => getWebhookByLocator(domainKey, webhookId),
1487+
[domainKey, config.cacheKeySuffix],
1488+
{ tags: [domainKey, "webhookByLocator"] }
1489+
)
1490+
),
14371491
getRoot: async () => {
14381492
return getRootCached(config)(domainKey, await getAuthState(), await authConfig);
14391493
},

packages/commons/docs-server/src/docs-loader.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,19 @@ export interface DocsLoader<IsAsync extends boolean = true> {
104104
IsAsync
105105
>;
106106

107+
/**
108+
* @returns the webhook definition for the given webhook locator (ID or path), or undefined if not found
109+
*/
110+
getWebhookByLocator: (webhookId: string) => MaybePromise<
111+
| {
112+
apiDefinitionId: ApiDefinition.ApiDefinitionId;
113+
webhook: ApiDefinition.WebhookDefinition;
114+
slug: Slug | undefined;
115+
}
116+
| undefined,
117+
IsAsync
118+
>;
119+
107120
/**
108121
* @returns the root node of the docs (aware of authentication)
109122
*/

packages/commons/docs-server/src/processRequestSnippetComponents.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,76 @@ export function getMatchablePermutationsForEndpoint(
6262
});
6363
return possiblePaths;
6464
}
65+
66+
/**
67+
* Converts a camelCase string to kebab-case.
68+
* e.g., "onOrderCreated" -> "on-order-created"
69+
*/
70+
function camelToKebab(str: string): string {
71+
return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
72+
}
73+
74+
/**
75+
* Find a webhook by its ID, operationId, or path.
76+
* Webhooks can be identified by:
77+
* - Exact ID (e.g., "subpackage_orders.onOrderCreated")
78+
* - Operation ID (e.g., "on-order-created")
79+
* - ID suffix after the last dot (e.g., "onOrderCreated")
80+
* - camelCase converted to kebab-case for operationId matching (e.g., "onOrderCreated" -> "on-order-created")
81+
* - Path (e.g., "/webhooks/my-webhook")
82+
*/
83+
export function findWebhook({
84+
apiDefinition,
85+
webhookId
86+
}: {
87+
apiDefinition: ApiDefinition.ApiDefinition;
88+
webhookId: string;
89+
}): ApiDefinition.WebhookDefinition | undefined {
90+
const webhooks = Object.values(apiDefinition.webhooks);
91+
92+
// First, try to find by exact ID match
93+
const webhookById = webhooks.find((w) => w.id === webhookId);
94+
if (webhookById != null) {
95+
return webhookById;
96+
}
97+
98+
// Then, try to find by operationId match (exact)
99+
const webhookByOperationId = webhooks.find((w) => w.operationId === webhookId);
100+
if (webhookByOperationId != null) {
101+
return webhookByOperationId;
102+
}
103+
104+
// Then, try to find by operationId match with camelCase to kebab-case conversion
105+
// e.g., "onOrderCreated" -> "on-order-created"
106+
const kebabWebhookId = camelToKebab(webhookId);
107+
if (kebabWebhookId !== webhookId) {
108+
const webhookByKebabOperationId = webhooks.find((w) => w.operationId === kebabWebhookId);
109+
if (webhookByKebabOperationId != null) {
110+
return webhookByKebabOperationId;
111+
}
112+
}
113+
114+
// Then, try to find by ID suffix (part after the last dot)
115+
// e.g., "onOrderCreated" matches "subpackage_orders.onOrderCreated"
116+
const webhooksBySuffix = webhooks.filter((w) => {
117+
const lastDotIndex = w.id.lastIndexOf(".");
118+
if (lastDotIndex === -1) {
119+
return false;
120+
}
121+
const suffix = w.id.slice(lastDotIndex + 1);
122+
return suffix === webhookId;
123+
});
124+
// Only return if there's exactly one match to avoid ambiguity
125+
if (webhooksBySuffix.length === 1) {
126+
return webhooksBySuffix[0];
127+
}
128+
129+
// Then, try to find by path match
130+
const normalizedPath = webhookId.startsWith("/") ? webhookId : `/${webhookId}`;
131+
const webhookByPath = webhooks.find((w) => {
132+
const webhookPath = "/" + w.path.join("/");
133+
return webhookPath === normalizedPath;
134+
});
135+
136+
return webhookByPath;
137+
}

packages/fern-docs/bundle/src/mdx/bundler/serialize.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { rehypeSchema } from "../plugins/rehype-schema";
6262
import { rehypeSteps } from "../plugins/rehype-steps";
6363
import { rehypeTable } from "../plugins/rehype-table";
6464
import { rehypeTabs } from "../plugins/rehype-tabs";
65+
import { rehypeWebhookPayloadSnippet } from "../plugins/rehype-webhook-payload-snippet";
6566
import { remarkExtractTitle } from "../plugins/remark-extract-title";
6667
import { trackCustomComponents } from "./track-custom-components";
6768

@@ -254,6 +255,7 @@ async function serializeMdxImpl(
254255
rehypeButtons,
255256
[rehypeEndpointSchemaSnippets, { loader }],
256257
[rehypeEndpointExampleSnippets, { loader }],
258+
[rehypeWebhookPayloadSnippet, { loader }],
257259
[rehypeSchema, { loader }],
258260
[rehypeRunnableEndpoint, { loader }],
259261
[rehypeLang, { loader }],

packages/fern-docs/bundle/src/mdx/components/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ import {
5252
MergeAccessedThirdPartyEndpointsWidget,
5353
MergeSupportedFieldsByIntegrationWidget,
5454
Schema,
55-
SchemaSnippet
55+
SchemaSnippet,
56+
WebhookPayloadSnippet
5657
} from "./snippets";
5758
import { EndpointSchemaSnippet } from "./snippets/EndpointSchemaSnippet";
5859
import { Step, StepGroup } from "./steps";
@@ -106,6 +107,7 @@ const FERN_COMPONENTS = {
106107
Schema,
107108
SchemaSnippet,
108109
SearchBar: SearchV2Trigger,
110+
WebhookPayloadSnippet,
109111
Step,
110112
StepGroup,
111113
Tab,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { ApiDefinition } from "@fern-api/fdr-sdk";
2+
import { CodeSnippetExample } from "@fern-docs/components/api-reference/examples/CodeSnippetExample";
3+
import { cn } from "@fern-docs/components/cn";
4+
import { t } from "@fern-docs/i18n";
5+
6+
export function WebhookPayloadSnippet({
7+
webhookDefinition,
8+
slug,
9+
className,
10+
lang
11+
}: {
12+
/**
13+
* The webhook locator to use for the payload snippet.
14+
* This can be the webhook ID or path.
15+
*/
16+
webhook?: string;
17+
/**
18+
* @internal the rehype-webhook-payload-snippet plugin will set this
19+
*/
20+
webhookDefinition?: ApiDefinition.WebhookDefinition;
21+
/**
22+
* The slug of the webhook.
23+
*/
24+
slug: string | undefined;
25+
className?: string;
26+
lang?: string;
27+
}) {
28+
if (webhookDefinition == null) {
29+
return null;
30+
}
31+
32+
return (
33+
<WebhookPayloadSnippetInternal
34+
webhook={webhookDefinition}
35+
slug={slug}
36+
className={className}
37+
lang={lang ?? "en"}
38+
/>
39+
);
40+
}
41+
42+
function WebhookPayloadSnippetInternal({
43+
webhook,
44+
slug,
45+
className,
46+
lang
47+
}: {
48+
slug: string | undefined;
49+
webhook: ApiDefinition.WebhookDefinition;
50+
className?: string;
51+
lang: string;
52+
}) {
53+
const example = webhook.examples?.[0];
54+
55+
if (example == null) {
56+
return null;
57+
}
58+
59+
const payloadJson = example.payload;
60+
61+
if (payloadJson == null) {
62+
return null;
63+
}
64+
65+
const payloadJsonString = JSON.stringify(payloadJson, null, 2);
66+
67+
return (
68+
<div className={cn("mb-5 mt-3", className)}>
69+
<CodeSnippetExample
70+
title={t(lang).apiReference.payload}
71+
code={payloadJsonString}
72+
language="json"
73+
json={payloadJson}
74+
scrollAreaStyle={{ maxHeight: "500px" }}
75+
slug={slug}
76+
lang={lang}
77+
/>
78+
</div>
79+
);
80+
}

packages/fern-docs/bundle/src/mdx/components/snippets/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from "./MergeAccessedThirdPartyEndpointsWidget";
44
export * from "./MergeSupportedFieldsByIntegrationWidget";
55
export * from "./Schema";
66
export * from "./SchemaSnippet";
7+
export * from "./WebhookPayloadSnippet";
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { DocsLoader } from "@fern-api/docs-server/docs-loader";
2+
import {
3+
CONTINUE,
4+
type Hast,
5+
hastMdxJsxElementHastToProps,
6+
isMdxJsxElementHast,
7+
SKIP,
8+
type Unified,
9+
unknownToMdxJsxAttribute,
10+
visit
11+
} from "@fern-docs/mdx";
12+
13+
/**
14+
* This rehype plugin processes `WebhookPayloadSnippet` components in MDX content.
15+
* It looks up the webhook definition by the provided `webhook` prop (ID or path)
16+
* and injects the full webhook definition into the component props.
17+
*/
18+
export const rehypeWebhookPayloadSnippet: Unified.Plugin<[{ loader: DocsLoader }?], Hast.Root> = (opts) => {
19+
if (!opts) {
20+
return;
21+
}
22+
const loader = opts.loader;
23+
24+
return async (ast: Hast.Root) => {
25+
const promises: Promise<void>[] = [];
26+
27+
visit(ast, (node, index, parent) => {
28+
if (!isMdxJsxElementHast(node) || index == null || parent == null) {
29+
return CONTINUE;
30+
}
31+
32+
const isWebhookPayloadSnippet = node.name === "WebhookPayloadSnippet";
33+
34+
if (isWebhookPayloadSnippet) {
35+
const { props } = hastMdxJsxElementHastToProps(node);
36+
37+
// cannot parse non-string webhook prop
38+
if (typeof props.webhook !== "string") {
39+
return CONTINUE;
40+
}
41+
42+
const webhookId = props.webhook;
43+
44+
promises.push(
45+
(async () => {
46+
try {
47+
const result = await loader.getWebhookByLocator(webhookId);
48+
49+
if (result != null) {
50+
node.attributes.push(
51+
unknownToMdxJsxAttribute("webhookDefinition", result.webhook),
52+
unknownToMdxJsxAttribute("slug", result.slug)
53+
);
54+
} else {
55+
console.warn(`Could not find webhook for ${webhookId}`);
56+
}
57+
} catch (e) {
58+
console.error(`Error looking up webhook ${webhookId}`, e);
59+
}
60+
})()
61+
);
62+
63+
return SKIP;
64+
}
65+
66+
return CONTINUE;
67+
});
68+
69+
if (promises.length > 0) {
70+
// wait for all promises to resolve before proceeding
71+
await Promise.all(promises);
72+
}
73+
};
74+
};

0 commit comments

Comments
 (0)