@shipfox/api-integration-webhook 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +18 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/connections.d.ts +4 -0
- package/dist/presentation/dto/connections.d.ts.map +1 -0
- package/dist/presentation/dto/connections.js +15 -0
- package/dist/presentation/dto/connections.js.map +1 -0
- package/dist/presentation/routes/connections.d.ts +14 -0
- package/dist/presentation/routes/connections.d.ts.map +1 -0
- package/dist/presentation/routes/connections.js +153 -0
- package/dist/presentation/routes/connections.js.map +1 -0
- package/dist/presentation/routes/inbound.d.ts +11 -0
- package/dist/presentation/routes/inbound.d.ts.map +1 -0
- package/dist/presentation/routes/inbound.js +117 -0
- package/dist/presentation/routes/inbound.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +62 -0
- package/src/config.ts +8 -0
- package/src/constants.test.ts +23 -0
- package/src/constants.ts +21 -0
- package/src/index.ts +52 -0
- package/src/presentation/dto/connections.ts +20 -0
- package/src/presentation/routes/connections.test.ts +374 -0
- package/src/presentation/routes/connections.ts +184 -0
- package/src/presentation/routes/inbound.test.ts +210 -0
- package/src/presentation/routes/inbound.ts +132 -0
- package/test/env.ts +7 -0
- package/test/globalSetup.ts +5 -0
- package/test/setup.ts +6 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import {AUTH_USER, requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
|
+
import {
|
|
3
|
+
ConnectionSlugConflictError,
|
|
4
|
+
type CreateIntegrationConnectionFn,
|
|
5
|
+
type DeleteIntegrationConnectionFn,
|
|
6
|
+
type GetIntegrationConnectionByIdFn,
|
|
7
|
+
type IntegrationConnection,
|
|
8
|
+
type UpdateIntegrationConnectionLifecycleStatusFn,
|
|
9
|
+
} from '@shipfox/api-integration-core-dto';
|
|
10
|
+
import {
|
|
11
|
+
createWebhookConnectionBodySchema,
|
|
12
|
+
listWebhookConnectionsQuerySchema,
|
|
13
|
+
listWebhookConnectionsResponseSchema,
|
|
14
|
+
updateWebhookConnectionBodySchema,
|
|
15
|
+
WEBHOOK_PROVIDER,
|
|
16
|
+
webhookConnectionDtoSchema,
|
|
17
|
+
} from '@shipfox/api-integration-webhook-dto';
|
|
18
|
+
import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
|
|
19
|
+
import type {FastifyRequest} from 'fastify';
|
|
20
|
+
import {z} from 'zod';
|
|
21
|
+
import {toWebhookConnectionDto} from '#presentation/dto/connections.js';
|
|
22
|
+
|
|
23
|
+
export interface CreateWebhookConnectionRoutesOptions {
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
createIntegrationConnection: CreateIntegrationConnectionFn;
|
|
26
|
+
listIntegrationConnections: (params: {workspaceId: string}) => Promise<IntegrationConnection[]>;
|
|
27
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
28
|
+
updateIntegrationConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;
|
|
29
|
+
deleteIntegrationConnection: DeleteIntegrationConnectionFn;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const connectionParamsSchema = z.object({
|
|
33
|
+
connectionId: z.string().uuid(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function isConnectionAlreadyExistsError(error: unknown): boolean {
|
|
37
|
+
return (
|
|
38
|
+
typeof error === 'object' &&
|
|
39
|
+
error !== null &&
|
|
40
|
+
((error as {name?: unknown}).name === 'IntegrationConnectionAlreadyExistsError' ||
|
|
41
|
+
error instanceof ConnectionSlugConflictError)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function requireWebhookConnection(params: {
|
|
46
|
+
request: FastifyRequest;
|
|
47
|
+
connectionId: string;
|
|
48
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
49
|
+
}): Promise<IntegrationConnection> {
|
|
50
|
+
const connection = await params.getIntegrationConnectionById(params.connectionId);
|
|
51
|
+
if (!connection || connection.provider !== WEBHOOK_PROVIDER) {
|
|
52
|
+
throw new ClientError('Webhook connection not found', 'not-found', {status: 404});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
requireWorkspaceAccess({request: params.request, workspaceId: connection.workspaceId});
|
|
56
|
+
return connection;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createWebhookConnectionRoutes(
|
|
60
|
+
options: CreateWebhookConnectionRoutesOptions,
|
|
61
|
+
): RouteGroup {
|
|
62
|
+
const createConnectionRoute = defineRoute({
|
|
63
|
+
method: 'POST',
|
|
64
|
+
path: '/connections',
|
|
65
|
+
auth: AUTH_USER,
|
|
66
|
+
description: 'Create a generic webhook connection.',
|
|
67
|
+
schema: {
|
|
68
|
+
body: createWebhookConnectionBodySchema,
|
|
69
|
+
response: {
|
|
70
|
+
201: webhookConnectionDtoSchema,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
errorHandler: (error) => {
|
|
74
|
+
if (isConnectionAlreadyExistsError(error)) {
|
|
75
|
+
throw new ClientError('Webhook slug already exists', 'slug-already-exists', {status: 409});
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
},
|
|
79
|
+
handler: async (request, reply) => {
|
|
80
|
+
const {workspace_id: workspaceId, name, slug} = request.body;
|
|
81
|
+
|
|
82
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
83
|
+
const connection = await options.createIntegrationConnection({
|
|
84
|
+
workspaceId,
|
|
85
|
+
provider: WEBHOOK_PROVIDER,
|
|
86
|
+
// The generic webhook provider uses the human slug as its provider-side account id.
|
|
87
|
+
externalAccountId: slug,
|
|
88
|
+
slug,
|
|
89
|
+
displayName: name,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
reply.status(201);
|
|
93
|
+
return toWebhookConnectionDto(connection, options.baseUrl);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const listConnectionsRoute = defineRoute({
|
|
98
|
+
method: 'GET',
|
|
99
|
+
path: '/connections',
|
|
100
|
+
auth: AUTH_USER,
|
|
101
|
+
description: 'List generic webhook connections for a workspace.',
|
|
102
|
+
schema: {
|
|
103
|
+
querystring: listWebhookConnectionsQuerySchema,
|
|
104
|
+
response: {
|
|
105
|
+
200: listWebhookConnectionsResponseSchema,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
handler: async (request) => {
|
|
109
|
+
const {workspace_id: workspaceId} = request.query;
|
|
110
|
+
|
|
111
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
112
|
+
const connections = (await options.listIntegrationConnections({workspaceId})).filter(
|
|
113
|
+
(connection) => connection.provider === WEBHOOK_PROVIDER,
|
|
114
|
+
);
|
|
115
|
+
return {
|
|
116
|
+
connections: connections.map((connection) =>
|
|
117
|
+
toWebhookConnectionDto(connection, options.baseUrl),
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const updateConnectionRoute = defineRoute({
|
|
124
|
+
method: 'PATCH',
|
|
125
|
+
path: '/connections/:connectionId',
|
|
126
|
+
auth: AUTH_USER,
|
|
127
|
+
description: 'Update a generic webhook connection.',
|
|
128
|
+
schema: {
|
|
129
|
+
params: connectionParamsSchema,
|
|
130
|
+
body: updateWebhookConnectionBodySchema,
|
|
131
|
+
response: {
|
|
132
|
+
200: webhookConnectionDtoSchema,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
handler: async (request) => {
|
|
136
|
+
const connection = await requireWebhookConnection({
|
|
137
|
+
request,
|
|
138
|
+
connectionId: request.params.connectionId,
|
|
139
|
+
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
140
|
+
});
|
|
141
|
+
const updated = await options.updateIntegrationConnectionLifecycleStatus({
|
|
142
|
+
id: connection.id,
|
|
143
|
+
lifecycleStatus: request.body.lifecycle_status,
|
|
144
|
+
});
|
|
145
|
+
if (!updated) {
|
|
146
|
+
throw new ClientError('Webhook connection not found', 'not-found', {status: 404});
|
|
147
|
+
}
|
|
148
|
+
return toWebhookConnectionDto(updated, options.baseUrl);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const deleteConnectionRoute = defineRoute({
|
|
153
|
+
method: 'DELETE',
|
|
154
|
+
path: '/connections/:connectionId',
|
|
155
|
+
auth: AUTH_USER,
|
|
156
|
+
description: 'Delete a generic webhook connection.',
|
|
157
|
+
schema: {
|
|
158
|
+
params: connectionParamsSchema,
|
|
159
|
+
response: {
|
|
160
|
+
204: z.void(),
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
handler: async (request, reply) => {
|
|
164
|
+
const connection = await requireWebhookConnection({
|
|
165
|
+
request,
|
|
166
|
+
connectionId: request.params.connectionId,
|
|
167
|
+
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await options.deleteIntegrationConnection({id: connection.id});
|
|
171
|
+
reply.code(204);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
prefix: '/integrations/webhook',
|
|
177
|
+
routes: [
|
|
178
|
+
createConnectionRoute,
|
|
179
|
+
listConnectionsRoute,
|
|
180
|
+
updateConnectionRoute,
|
|
181
|
+
deleteConnectionRoute,
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import {closeApp, createApp} from '@shipfox/node-fastify';
|
|
3
|
+
import type {FastifyInstance} from 'fastify';
|
|
4
|
+
import {createWebhookInboundRoutes} from './inbound.js';
|
|
5
|
+
|
|
6
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
7
|
+
|
|
8
|
+
function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
|
|
9
|
+
const now = new Date();
|
|
10
|
+
return {
|
|
11
|
+
id: crypto.randomUUID(),
|
|
12
|
+
workspaceId: crypto.randomUUID(),
|
|
13
|
+
provider: 'webhook',
|
|
14
|
+
externalAccountId: 'stripe-prod',
|
|
15
|
+
slug: 'stripe_prod',
|
|
16
|
+
displayName: 'Stripe Production',
|
|
17
|
+
lifecycleStatus: 'active',
|
|
18
|
+
createdAt: now,
|
|
19
|
+
updatedAt: now,
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function createTestApp(
|
|
25
|
+
options: {connection?: IntegrationConnection | undefined} = {},
|
|
26
|
+
): Promise<{
|
|
27
|
+
app: FastifyInstance;
|
|
28
|
+
publishIntegrationEventReceived: ReturnType<typeof vi.fn>;
|
|
29
|
+
getIntegrationConnectionById: ReturnType<typeof vi.fn>;
|
|
30
|
+
}> {
|
|
31
|
+
const connection = options.connection ?? fakeConnection();
|
|
32
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
33
|
+
const getIntegrationConnectionById = vi.fn((id: string) =>
|
|
34
|
+
Promise.resolve(id === connection.id ? connection : undefined),
|
|
35
|
+
);
|
|
36
|
+
const routes = createWebhookInboundRoutes({
|
|
37
|
+
coreDb: () => ({transaction: (callback) => callback({})}),
|
|
38
|
+
getIntegrationConnectionById,
|
|
39
|
+
publishIntegrationEventReceived,
|
|
40
|
+
});
|
|
41
|
+
const app = await createApp({routes: [routes], swagger: false});
|
|
42
|
+
await app.ready();
|
|
43
|
+
return {app, publishIntegrationEventReceived, getIntegrationConnectionById};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('webhook inbound route', () => {
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
await closeApp();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await closeApp();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('publishes a JSON delivery envelope', async () => {
|
|
56
|
+
const connection = fakeConnection();
|
|
57
|
+
const {app, publishIntegrationEventReceived} = await createTestApp({connection});
|
|
58
|
+
|
|
59
|
+
const res = await app.inject({
|
|
60
|
+
method: 'POST',
|
|
61
|
+
url: `/webhook/${connection.id}?mode=test`,
|
|
62
|
+
headers: {
|
|
63
|
+
'content-type': 'application/json',
|
|
64
|
+
'x-delivery-id': 'evt-1',
|
|
65
|
+
authorization: 'Bearer secret',
|
|
66
|
+
},
|
|
67
|
+
payload: {ok: true},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(res.statusCode).toBe(202);
|
|
71
|
+
expect(res.json().delivery_id).toBe('evt-1');
|
|
72
|
+
expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(publishIntegrationEventReceived.mock.calls[0]?.[0].event).toMatchObject({
|
|
74
|
+
provider: 'webhook',
|
|
75
|
+
source: 'stripe_prod',
|
|
76
|
+
event: 'received',
|
|
77
|
+
workspaceId: connection.workspaceId,
|
|
78
|
+
connectionId: connection.id,
|
|
79
|
+
connectionName: 'Stripe Production',
|
|
80
|
+
deliveryId: 'evt-1',
|
|
81
|
+
payload: {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
query: {mode: 'test'},
|
|
84
|
+
body: {ok: true},
|
|
85
|
+
headers: {
|
|
86
|
+
'content-type': 'application/json',
|
|
87
|
+
'x-delivery-id': 'evt-1',
|
|
88
|
+
authorization: '[redacted]',
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('publishes a form delivery envelope', async () => {
|
|
95
|
+
const connection = fakeConnection();
|
|
96
|
+
const {app, publishIntegrationEventReceived} = await createTestApp({connection});
|
|
97
|
+
|
|
98
|
+
const res = await app.inject({
|
|
99
|
+
method: 'POST',
|
|
100
|
+
url: `/webhook/${connection.id}`,
|
|
101
|
+
headers: {'content-type': 'application/x-www-form-urlencoded'},
|
|
102
|
+
payload: 'status=paid&amount=1200',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(res.statusCode).toBe(202);
|
|
106
|
+
expect(publishIntegrationEventReceived.mock.calls[0]?.[0].event.payload.body).toEqual({
|
|
107
|
+
status: 'paid',
|
|
108
|
+
amount: '1200',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('publishes a text delivery envelope', async () => {
|
|
113
|
+
const connection = fakeConnection();
|
|
114
|
+
const {app, publishIntegrationEventReceived} = await createTestApp({connection});
|
|
115
|
+
|
|
116
|
+
const res = await app.inject({
|
|
117
|
+
method: 'POST',
|
|
118
|
+
url: `/webhook/${connection.id}`,
|
|
119
|
+
headers: {'content-type': 'text/plain'},
|
|
120
|
+
payload: 'hello',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(res.statusCode).toBe(202);
|
|
124
|
+
expect(publishIntegrationEventReceived.mock.calls[0]?.[0].event.payload.body).toBe('hello');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('rejects unsupported content types without publishing', async () => {
|
|
128
|
+
const connection = fakeConnection();
|
|
129
|
+
const {app, publishIntegrationEventReceived} = await createTestApp({connection});
|
|
130
|
+
|
|
131
|
+
const res = await app.inject({
|
|
132
|
+
method: 'POST',
|
|
133
|
+
url: `/webhook/${connection.id}`,
|
|
134
|
+
headers: {'content-type': 'application/xml'},
|
|
135
|
+
payload: '<ok />',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(res.statusCode).toBe(415);
|
|
139
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects malformed JSON without publishing', async () => {
|
|
143
|
+
const connection = fakeConnection();
|
|
144
|
+
const {app, publishIntegrationEventReceived} = await createTestApp({connection});
|
|
145
|
+
|
|
146
|
+
const res = await app.inject({
|
|
147
|
+
method: 'POST',
|
|
148
|
+
url: `/webhook/${connection.id}`,
|
|
149
|
+
headers: {'content-type': 'application/json'},
|
|
150
|
+
payload: '{"ok":',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(res.statusCode).toBe(400);
|
|
154
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it.each([
|
|
158
|
+
{connection: undefined, description: 'unknown connection'},
|
|
159
|
+
{connection: fakeConnection({lifecycleStatus: 'disabled'}), description: 'disabled connection'},
|
|
160
|
+
{connection: fakeConnection({provider: 'github'}), description: 'wrong provider'},
|
|
161
|
+
])('returns 404 for $description without publishing', async ({connection}) => {
|
|
162
|
+
const activeConnection = connection ?? fakeConnection();
|
|
163
|
+
const {app, publishIntegrationEventReceived} = await createTestApp({connection});
|
|
164
|
+
|
|
165
|
+
const res = await app.inject({
|
|
166
|
+
method: 'POST',
|
|
167
|
+
url: `/webhook/${activeConnection.id}`,
|
|
168
|
+
headers: {'content-type': 'application/json'},
|
|
169
|
+
payload: {ok: true},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(res.statusCode).toBe(404);
|
|
173
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('uses a random delivery ID when no header is present', async () => {
|
|
177
|
+
const connection = fakeConnection();
|
|
178
|
+
const {app, publishIntegrationEventReceived} = await createTestApp({connection});
|
|
179
|
+
|
|
180
|
+
const res = await app.inject({
|
|
181
|
+
method: 'POST',
|
|
182
|
+
url: `/webhook/${connection.id}`,
|
|
183
|
+
headers: {'content-type': 'application/json'},
|
|
184
|
+
payload: {ok: true},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(res.statusCode).toBe(202);
|
|
188
|
+
const deliveryId = publishIntegrationEventReceived.mock.calls[0]?.[0].event.deliveryId;
|
|
189
|
+
expect(deliveryId).toMatch(UUID_RE);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('rejects an overlong delivery ID header without publishing', async () => {
|
|
193
|
+
const connection = fakeConnection();
|
|
194
|
+
const {app, publishIntegrationEventReceived} = await createTestApp({connection});
|
|
195
|
+
|
|
196
|
+
const res = await app.inject({
|
|
197
|
+
method: 'POST',
|
|
198
|
+
url: `/webhook/${connection.id}`,
|
|
199
|
+
headers: {
|
|
200
|
+
'content-type': 'application/json',
|
|
201
|
+
'x-delivery-id': 'x'.repeat(201),
|
|
202
|
+
},
|
|
203
|
+
payload: {ok: true},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(res.statusCode).toBe(400);
|
|
207
|
+
expect(res.json().code).toBe('delivery-id-too-long');
|
|
208
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type {Buffer} from 'node:buffer';
|
|
2
|
+
import {randomUUID} from 'node:crypto';
|
|
3
|
+
import formBodyPlugin from '@fastify/formbody';
|
|
4
|
+
import type {
|
|
5
|
+
GetIntegrationConnectionByIdFn,
|
|
6
|
+
IntegrationTx,
|
|
7
|
+
PublishIntegrationEventReceivedFn,
|
|
8
|
+
} from '@shipfox/api-integration-core-dto';
|
|
9
|
+
import {WEBHOOK_PROVIDER, WEBHOOK_RECEIVED_EVENT} from '@shipfox/api-integration-webhook-dto';
|
|
10
|
+
import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
|
|
11
|
+
import type {FastifyPluginAsync} from 'fastify';
|
|
12
|
+
import fp from 'fastify-plugin';
|
|
13
|
+
import {z} from 'zod';
|
|
14
|
+
import {redactHeaders, WEBHOOK_INBOUND_BODY_LIMIT} from '#constants.js';
|
|
15
|
+
|
|
16
|
+
const MAX_DELIVERY_ID_HEADER_LENGTH = 200;
|
|
17
|
+
const acceptedContentTypes = new Set([
|
|
18
|
+
'application/json',
|
|
19
|
+
'application/x-www-form-urlencoded',
|
|
20
|
+
'text/plain',
|
|
21
|
+
]);
|
|
22
|
+
const FORM_URLENCODED_CONTENT_TYPE_RE = /^application\/x-www-form-urlencoded(?:;.*)?$/i;
|
|
23
|
+
|
|
24
|
+
const formBodyRoutePlugin: FastifyPluginAsync = fp(async (app) => {
|
|
25
|
+
await app.register(formBodyPlugin, {bodyLimit: WEBHOOK_INBOUND_BODY_LIMIT});
|
|
26
|
+
|
|
27
|
+
app.addContentTypeParser(
|
|
28
|
+
FORM_URLENCODED_CONTENT_TYPE_RE,
|
|
29
|
+
{parseAs: 'buffer', bodyLimit: WEBHOOK_INBOUND_BODY_LIMIT},
|
|
30
|
+
(_request, body: Buffer, done) => {
|
|
31
|
+
done(null, Object.fromEntries(new URLSearchParams(body.toString('utf8'))));
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export interface CreateWebhookInboundRoutesOptions {
|
|
37
|
+
coreDb: () => {
|
|
38
|
+
transaction<T>(callback: (tx: IntegrationTx) => Promise<T>): Promise<T>;
|
|
39
|
+
};
|
|
40
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
41
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const inboundParamsSchema = z.object({
|
|
45
|
+
connectionId: z.string().uuid(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const inboundQuerySchema = z.record(z.string(), z.unknown());
|
|
49
|
+
|
|
50
|
+
const inboundAcceptedResponseSchema = z.object({
|
|
51
|
+
delivery_id: z.string(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function contentType(requestContentType: string | undefined): string | undefined {
|
|
55
|
+
return requestContentType?.split(';', 1)[0]?.trim().toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function deliveryIdFor(header: string | string[] | undefined): string {
|
|
59
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
60
|
+
if (!value) return randomUUID();
|
|
61
|
+
if (value.length > MAX_DELIVERY_ID_HEADER_LENGTH) {
|
|
62
|
+
throw new ClientError('Delivery ID header is too long', 'delivery-id-too-long', {status: 400});
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createWebhookInboundRoutes(options: CreateWebhookInboundRoutesOptions): RouteGroup {
|
|
68
|
+
const inboundRoute = defineRoute({
|
|
69
|
+
method: 'POST',
|
|
70
|
+
path: '/:connectionId',
|
|
71
|
+
auth: [],
|
|
72
|
+
description: 'Generic webhook receiver.',
|
|
73
|
+
options: {bodyLimit: WEBHOOK_INBOUND_BODY_LIMIT},
|
|
74
|
+
schema: {
|
|
75
|
+
params: inboundParamsSchema,
|
|
76
|
+
querystring: inboundQuerySchema,
|
|
77
|
+
response: {
|
|
78
|
+
202: inboundAcceptedResponseSchema,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
handler: async (request, reply) => {
|
|
82
|
+
const mediaType = contentType(request.headers['content-type']);
|
|
83
|
+
if (!mediaType || !acceptedContentTypes.has(mediaType)) {
|
|
84
|
+
throw new ClientError('Unsupported content type', 'unsupported-media-type', {status: 415});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const {connectionId} = request.params;
|
|
88
|
+
const connection = await options.getIntegrationConnectionById(connectionId);
|
|
89
|
+
if (
|
|
90
|
+
!connection ||
|
|
91
|
+
connection.provider !== WEBHOOK_PROVIDER ||
|
|
92
|
+
connection.lifecycleStatus !== 'active'
|
|
93
|
+
) {
|
|
94
|
+
throw new ClientError('Webhook connection not found', 'not-found', {status: 404});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const deliveryId = deliveryIdFor(request.headers['x-delivery-id']);
|
|
98
|
+
const receivedAt = new Date().toISOString();
|
|
99
|
+
await options.coreDb().transaction(async (tx) => {
|
|
100
|
+
await options.publishIntegrationEventReceived({
|
|
101
|
+
tx,
|
|
102
|
+
event: {
|
|
103
|
+
provider: WEBHOOK_PROVIDER,
|
|
104
|
+
source: connection.slug,
|
|
105
|
+
event: WEBHOOK_RECEIVED_EVENT,
|
|
106
|
+
workspaceId: connection.workspaceId,
|
|
107
|
+
connectionId: connection.id,
|
|
108
|
+
connectionName: connection.displayName,
|
|
109
|
+
deliveryId,
|
|
110
|
+
receivedAt,
|
|
111
|
+
payload: {
|
|
112
|
+
method: request.method,
|
|
113
|
+
headers: redactHeaders(request.headers),
|
|
114
|
+
query: request.query,
|
|
115
|
+
body: request.body,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
reply.code(202);
|
|
122
|
+
return {delivery_id: deliveryId};
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
prefix: '/webhook',
|
|
128
|
+
auth: [],
|
|
129
|
+
plugins: [formBodyRoutePlugin],
|
|
130
|
+
routes: [inboundRoute],
|
|
131
|
+
};
|
|
132
|
+
}
|
package/test/env.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
process.env.POSTGRES_HOST = 'localhost';
|
|
2
|
+
process.env.POSTGRES_PORT = '5432';
|
|
3
|
+
process.env.POSTGRES_USERNAME = 'shipfox';
|
|
4
|
+
process.env.POSTGRES_PASSWORD = 'password';
|
|
5
|
+
process.env.POSTGRES_DATABASE = 'api_test';
|
|
6
|
+
process.env.POSTGRES_MAX_CONNECTIONS = '5';
|
|
7
|
+
process.env.TZ = 'UTC';
|
package/test/setup.ts
ADDED