Skip to content

Commit 863ab63

Browse files
feat: add initial version of createGetCentraWebhookEvents
1 parent 18d82e6 commit 863ab63

File tree

4 files changed

+182
-10
lines changed

4 files changed

+182
-10
lines changed

packages/react-centra-checkout/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
},
3838
"dependencies": {
3939
"@noaignite/utils": "workspace:*",
40-
"js-cookie": "^3.0.1"
40+
"js-cookie": "^3.0.1",
41+
"zod": "^3.23.8"
4142
},
4243
"devDependencies": {
4344
"@noaignite/centra-mocks": "workspace:*",
@@ -51,6 +52,7 @@
5152
"@types/react": "^18.3.12",
5253
"@vitest/coverage-v8": "^2.1.3",
5354
"jsdom": "^25.0.1",
55+
"next": "^14.2.15",
5456
"nock": "14.0.0-beta.15",
5557
"tsup": "^8.3.0",
5658
"typescript": "5.4.5",
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { NextApiRequest } from 'next'
2+
import crypto from 'node:crypto'
3+
import { z } from 'zod'
4+
5+
const sign = (secret: string, dataString: string) => {
6+
const hmac = crypto.createHmac('sha256', secret)
7+
8+
hmac.update(dataString)
9+
10+
return hmac.digest('hex')
11+
}
12+
13+
/**
14+
* Parse a named parameters string into an object.
15+
* @example
16+
* parseNamedParametersString('foo=bar,baz=qux') // \{ foo: 'bar', baz: 'qux' \}
17+
*/
18+
const parseNamedParametersString = (input: string): Record<string, string> => {
19+
return input.split(',').reduce((acc, pair) => {
20+
const [key, value] = pair.split('=')
21+
if (!key) {
22+
return acc
23+
}
24+
25+
return { ...acc, [key]: value }
26+
}, {})
27+
}
28+
29+
const bodyParserSchema = z
30+
.object({ payload: z.string() })
31+
.nullable()
32+
.catch(() => null)
33+
34+
type EventTypes =
35+
| 'affiliates'
36+
| 'brands'
37+
| 'campaignSites'
38+
| 'campaigns'
39+
| 'categories'
40+
| 'cmsArticles'
41+
| 'giftCertificates'
42+
| 'mapsLocations'
43+
| 'mapsRegion'
44+
| 'markets'
45+
| 'pricelists'
46+
| 'products'
47+
| 'statics'
48+
| 'warehouseGroups'
49+
| 'warehouses'
50+
51+
type CreateGetCentraWebhookEventsConfig<TRequest> = {
52+
adapter: {
53+
getHeader: (request: TRequest, headerKey: string) => string | null | undefined
54+
55+
getRawBody: (request: TRequest) => unknown
56+
}
57+
secret?: string
58+
}
59+
60+
/**
61+
* Create get Centra webhook events.
62+
* Parses and returns the events passed from Centra.
63+
*
64+
* @see https://centra.dev/docs/services/centra-webhooks
65+
* @example
66+
* ```typescript
67+
* const getCentraWebhookEvents = createGetCentraWebhookEvents({
68+
* adapter: nextAppRouterAdapter
69+
* })
70+
*
71+
* const POST = async (req: Request) => {
72+
* const [error, results] = await getCentraWebhookEvents(req)
73+
*
74+
* if (error) {
75+
* // Handle error codes
76+
* return Response.json({ message: 'Error' }, { status: 500 })
77+
* }
78+
*
79+
* if ('products' in results) {
80+
* return Response.json({ products: results.products }, { status: 200 })
81+
* }
82+
* }
83+
* ```
84+
*/
85+
export function createGetCentraWebhookEvents<TRequest>(
86+
config: CreateGetCentraWebhookEventsConfig<TRequest>,
87+
) {
88+
const { adapter, secret } = config
89+
const { getHeader, getRawBody } = adapter
90+
91+
return async (request: TRequest) => {
92+
const rawBody = await getRawBody(request)
93+
const body = bodyParserSchema.parse(rawBody)
94+
95+
if (!body) {
96+
return [
97+
{
98+
message: 'Invalid request body' as const,
99+
},
100+
] as const
101+
}
102+
103+
if (secret) {
104+
const signature = getHeader(request, 'x-centra-signature')
105+
106+
if (!signature) {
107+
return [
108+
{
109+
message: 'No signature' as const,
110+
},
111+
] as const
112+
}
113+
114+
const parameters = parseNamedParametersString(signature)
115+
116+
// @see https://centra.dev/docs/extend-with-plugins/integrations/centra-webhooks#signature-verification
117+
if (
118+
sign(secret, `${parameters.t}.payload=${encodeURIComponent(body.payload)}`) !==
119+
parameters.v1
120+
) {
121+
console.error('Invalid signature')
122+
123+
return [{ message: 'Invalid signature' as const }] as const
124+
}
125+
}
126+
127+
const payloadParsed = JSON.parse(body.payload) as Partial<Record<EventTypes, string[]>>
128+
129+
return [undefined, payloadParsed] as const
130+
}
131+
}
132+
133+
export const nextAppRouterAdapter: CreateGetCentraWebhookEventsConfig<Request>['adapter'] = {
134+
getHeader: (request, headerKey) => {
135+
return request.headers.get(headerKey)
136+
},
137+
getRawBody: async (request) => {
138+
const formData = await request.formData()
139+
140+
// Returning an object representation of `FormData`
141+
return Object.fromEntries([...formData.entries()])
142+
},
143+
}
144+
145+
export const nextPagesRouterAdapter: CreateGetCentraWebhookEventsConfig<NextApiRequest>['adapter'] =
146+
{
147+
getHeader: (request, headerKey) => {
148+
const header = request.headers[headerKey]
149+
150+
if (Array.isArray(header)) {
151+
throw new Error('Multiple headers with same key passed.')
152+
}
153+
154+
return header
155+
},
156+
getRawBody: (req) => {
157+
return req.body as unknown
158+
},
159+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './createGetCentraWebhookEvents'

pnpm-lock.yaml

Lines changed: 19 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)