@ontrails/http 0.2.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/CHANGELOG.md +570 -0
- package/README.md +169 -0
- package/package.json +59 -0
- package/src/blob-output.ts +31 -0
- package/src/build.ts +1552 -0
- package/src/bun.ts +270 -0
- package/src/fetch.ts +1047 -0
- package/src/index.ts +28 -0
- package/src/method.ts +68 -0
- package/src/openapi.ts +383 -0
- package/src/query-coercion.ts +150 -0
- package/src/testing.ts +378 -0
package/src/testing.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PermissionError,
|
|
5
|
+
Result,
|
|
6
|
+
getWebhookHeader,
|
|
7
|
+
trail,
|
|
8
|
+
topo,
|
|
9
|
+
webhook,
|
|
10
|
+
} from '@ontrails/core';
|
|
11
|
+
import type { Topo } from '@ontrails/core';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
DeriveHttpRoutesOptions,
|
|
16
|
+
HttpHeaderSource,
|
|
17
|
+
ResolveHttpPermit,
|
|
18
|
+
} from './build.js';
|
|
19
|
+
import type { CreateRouteHandlerOptions } from './fetch.js';
|
|
20
|
+
|
|
21
|
+
export interface HttpAdapterConformanceApp {
|
|
22
|
+
readonly fetch: (request: Request) => Response | Promise<Response>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HttpAdapterConformanceOptions
|
|
26
|
+
extends DeriveHttpRoutesOptions, CreateRouteHandlerOptions {
|
|
27
|
+
readonly resolvePermit?: ResolveHttpPermit | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HttpAdapterConformanceAdapter {
|
|
31
|
+
readonly createApp: (
|
|
32
|
+
graph: Topo,
|
|
33
|
+
options?: HttpAdapterConformanceOptions
|
|
34
|
+
) => HttpAdapterConformanceApp | Promise<HttpAdapterConformanceApp>;
|
|
35
|
+
readonly name: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HttpAdapterConformanceCase {
|
|
39
|
+
readonly check: (adapter: HttpAdapterConformanceAdapter) => Promise<void>;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const echoTrail = trail('echo', {
|
|
44
|
+
implementation: (input) => Result.ok({ reply: input.message }),
|
|
45
|
+
input: z.object({ message: z.string() }),
|
|
46
|
+
intent: 'read',
|
|
47
|
+
output: z.object({ reply: z.string() }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const tagsTrail = trail('tags', {
|
|
51
|
+
implementation: (input) => Result.ok({ tags: input.tags }),
|
|
52
|
+
input: z.object({ tags: z.array(z.string()) }),
|
|
53
|
+
intent: 'read',
|
|
54
|
+
output: z.object({ tags: z.array(z.string()) }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const typedQuerySchema = z.object({
|
|
58
|
+
count: z.number(),
|
|
59
|
+
enabled: z.boolean(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const typedQueryTrail = trail('typed.query', {
|
|
63
|
+
implementation: (input) => Result.ok(input),
|
|
64
|
+
input: typedQuerySchema,
|
|
65
|
+
intent: 'read',
|
|
66
|
+
output: typedQuerySchema,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const echoBodyTrail = trail('echo.body', {
|
|
70
|
+
implementation: (input) => Result.ok({ length: input.message.length }),
|
|
71
|
+
input: z.object({ message: z.string() }),
|
|
72
|
+
intent: 'write',
|
|
73
|
+
output: z.object({ length: z.number() }),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const genericRedactionError = (): Error =>
|
|
77
|
+
new Error('database password=secret');
|
|
78
|
+
|
|
79
|
+
const genericErrorTrail = trail('generic.error', {
|
|
80
|
+
implementation: () => Result.err(genericRedactionError()),
|
|
81
|
+
input: z.object({}),
|
|
82
|
+
intent: 'read',
|
|
83
|
+
output: z.object({ ok: z.boolean() }),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const protectedTrail = trail('permit.scope', {
|
|
87
|
+
implementation: (_input, ctx) =>
|
|
88
|
+
Result.ok({
|
|
89
|
+
permitId: ctx.permit?.id,
|
|
90
|
+
requestId: ctx.requestId,
|
|
91
|
+
}),
|
|
92
|
+
input: z.object({}),
|
|
93
|
+
intent: 'read',
|
|
94
|
+
output: z.object({
|
|
95
|
+
permitId: z.string().optional(),
|
|
96
|
+
requestId: z.string().optional(),
|
|
97
|
+
}),
|
|
98
|
+
permit: { scopes: ['thing:read'] },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const abortingTrail = trail('abort.check', {
|
|
102
|
+
implementation: (_input, ctx) =>
|
|
103
|
+
Result.ok({ aborted: ctx.abortSignal.aborted }),
|
|
104
|
+
input: z.object({}),
|
|
105
|
+
intent: 'read',
|
|
106
|
+
output: z.object({ aborted: z.boolean() }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const webhookSecret = 'secret';
|
|
110
|
+
const paymentWebhook = webhook('webhook.payment.received', {
|
|
111
|
+
parse: z.object({ paymentId: z.string() }),
|
|
112
|
+
path: '/webhooks/payment',
|
|
113
|
+
verify: (request) =>
|
|
114
|
+
getWebhookHeader(request, 'x-webhook-secret') === webhookSecret
|
|
115
|
+
? Result.ok()
|
|
116
|
+
: Result.err(new PermissionError('Invalid webhook secret')),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const paymentWebhookTrail = trail('payment.receive', {
|
|
120
|
+
implementation: (input) => Result.ok({ paymentId: input.paymentId }),
|
|
121
|
+
input: z.object({ paymentId: z.string() }),
|
|
122
|
+
on: [paymentWebhook],
|
|
123
|
+
output: z.object({ paymentId: z.string() }),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const buildRequest = (path: string, init: RequestInit = {}): Request =>
|
|
127
|
+
new Request(new URL(path, 'http://localhost').toString(), init);
|
|
128
|
+
|
|
129
|
+
const readHeader = (
|
|
130
|
+
headers: HttpHeaderSource | undefined,
|
|
131
|
+
name: string
|
|
132
|
+
): string | undefined => {
|
|
133
|
+
if (headers === undefined) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
if (headers instanceof Headers) {
|
|
137
|
+
return headers.get(name) ?? undefined;
|
|
138
|
+
}
|
|
139
|
+
const needle = name.toLowerCase();
|
|
140
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
141
|
+
if (key.toLowerCase() !== needle || value === undefined) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
return typeof value === 'string' ? value : value[0];
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const expectJson = async (
|
|
150
|
+
response: Response
|
|
151
|
+
): Promise<Record<string, unknown>> =>
|
|
152
|
+
(await response.json()) as Record<string, unknown>;
|
|
153
|
+
|
|
154
|
+
const materialize = async (
|
|
155
|
+
adapter: HttpAdapterConformanceAdapter,
|
|
156
|
+
graph: Topo,
|
|
157
|
+
options?: HttpAdapterConformanceOptions
|
|
158
|
+
): Promise<HttpAdapterConformanceApp> =>
|
|
159
|
+
await adapter.createApp(graph, options);
|
|
160
|
+
|
|
161
|
+
const expectOkResponse = async (
|
|
162
|
+
response: Response,
|
|
163
|
+
data: Record<string, unknown>
|
|
164
|
+
): Promise<void> => {
|
|
165
|
+
expect(response.status).toBe(200);
|
|
166
|
+
expect(await expectJson(response)).toEqual({ data });
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const request = async (
|
|
170
|
+
adapter: HttpAdapterConformanceAdapter,
|
|
171
|
+
graph: Topo,
|
|
172
|
+
path: string,
|
|
173
|
+
init?: RequestInit,
|
|
174
|
+
options?: HttpAdapterConformanceOptions
|
|
175
|
+
): Promise<Response> => {
|
|
176
|
+
const app = await materialize(adapter, graph, options);
|
|
177
|
+
return await app.fetch(buildRequest(path, init));
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const readRouteCase = async (
|
|
181
|
+
adapter: HttpAdapterConformanceAdapter
|
|
182
|
+
): Promise<void> => {
|
|
183
|
+
const response = await request(
|
|
184
|
+
adapter,
|
|
185
|
+
topo('http-conformance-read', { echoTrail }),
|
|
186
|
+
'/echo?message=hello'
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
await expectOkResponse(response, { reply: 'hello' });
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const writeRouteCase = async (
|
|
193
|
+
adapter: HttpAdapterConformanceAdapter
|
|
194
|
+
): Promise<void> => {
|
|
195
|
+
const response = await request(
|
|
196
|
+
adapter,
|
|
197
|
+
topo('http-conformance-write', { echoBodyTrail }),
|
|
198
|
+
'/echo/body',
|
|
199
|
+
{
|
|
200
|
+
body: JSON.stringify({ message: 'hello' }),
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
method: 'POST',
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await expectOkResponse(response, { length: 5 });
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const repeatedQueryCase = async (
|
|
210
|
+
adapter: HttpAdapterConformanceAdapter
|
|
211
|
+
): Promise<void> => {
|
|
212
|
+
const graph = topo('http-conformance-query', { tagsTrail });
|
|
213
|
+
const repeated = await request(adapter, graph, '/tags?tags=red&tags=blue');
|
|
214
|
+
const singleton = await request(adapter, graph, '/tags?tags=solo');
|
|
215
|
+
|
|
216
|
+
await expectOkResponse(repeated, { tags: ['red', 'blue'] });
|
|
217
|
+
expect(singleton.status).toBe(400);
|
|
218
|
+
expect(await expectJson(singleton)).toMatchObject({
|
|
219
|
+
error: { category: 'validation' },
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const typedQueryCase = async (
|
|
224
|
+
adapter: HttpAdapterConformanceAdapter
|
|
225
|
+
): Promise<void> => {
|
|
226
|
+
const graph = topo('http-conformance-typed-query', { typedQueryTrail });
|
|
227
|
+
const valid = await request(
|
|
228
|
+
adapter,
|
|
229
|
+
graph,
|
|
230
|
+
'/typed/query?count=0&enabled=false'
|
|
231
|
+
);
|
|
232
|
+
const malformed = await request(
|
|
233
|
+
adapter,
|
|
234
|
+
graph,
|
|
235
|
+
'/typed/query?count=2x&enabled=true'
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await expectOkResponse(valid, { count: 0, enabled: false });
|
|
239
|
+
expect(malformed.status).toBe(400);
|
|
240
|
+
expect(await expectJson(malformed)).toMatchObject({
|
|
241
|
+
error: { category: 'validation' },
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const publicErrorCase = async (
|
|
246
|
+
adapter: HttpAdapterConformanceAdapter
|
|
247
|
+
): Promise<void> => {
|
|
248
|
+
const response = await request(
|
|
249
|
+
adapter,
|
|
250
|
+
topo('http-conformance-errors', { genericErrorTrail }),
|
|
251
|
+
'/generic/error',
|
|
252
|
+
{ headers: { 'X-Request-ID': 'req-123 forged/line' } }
|
|
253
|
+
);
|
|
254
|
+
const body = await expectJson(response);
|
|
255
|
+
|
|
256
|
+
expect(response.status).toBe(500);
|
|
257
|
+
expect(body).toEqual({
|
|
258
|
+
error: {
|
|
259
|
+
category: 'internal',
|
|
260
|
+
code: 'InternalError',
|
|
261
|
+
message: 'Internal server error',
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
expect(JSON.stringify(body)).not.toContain('secret');
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const requestContextCase = async (
|
|
268
|
+
adapter: HttpAdapterConformanceAdapter
|
|
269
|
+
): Promise<void> => {
|
|
270
|
+
let observedTenant: string | null | undefined;
|
|
271
|
+
const resolvePermit: ResolveHttpPermit = ({ headers }) => {
|
|
272
|
+
observedTenant = readHeader(headers, 'x-tenant-id');
|
|
273
|
+
return Result.ok({ id: 'user-1', scopes: ['thing:read'] });
|
|
274
|
+
};
|
|
275
|
+
const graph = topo('http-conformance-context', {
|
|
276
|
+
abortingTrail,
|
|
277
|
+
protectedTrail,
|
|
278
|
+
});
|
|
279
|
+
const app = await materialize(adapter, graph, { resolvePermit });
|
|
280
|
+
const controller = new AbortController();
|
|
281
|
+
controller.abort();
|
|
282
|
+
|
|
283
|
+
const permitResponse = await app.fetch(
|
|
284
|
+
buildRequest('/permit/scope', {
|
|
285
|
+
headers: {
|
|
286
|
+
Authorization: 'Bearer strong',
|
|
287
|
+
'X-Request-ID': 'req-1',
|
|
288
|
+
'X-Tenant-ID': 'tenant-1',
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
);
|
|
292
|
+
const abortResponse = await app.fetch(
|
|
293
|
+
buildRequest('/abort/check', { signal: controller.signal })
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
await expectOkResponse(permitResponse, {
|
|
297
|
+
permitId: 'user-1',
|
|
298
|
+
requestId: 'req-1',
|
|
299
|
+
});
|
|
300
|
+
expect(observedTenant).toBe('tenant-1');
|
|
301
|
+
await expectOkResponse(abortResponse, { aborted: true });
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const webhookCase = async (
|
|
305
|
+
adapter: HttpAdapterConformanceAdapter
|
|
306
|
+
): Promise<void> => {
|
|
307
|
+
const graph = topo('http-conformance-webhooks', { paymentWebhookTrail });
|
|
308
|
+
const verified = await request(adapter, graph, '/webhooks/payment', {
|
|
309
|
+
body: JSON.stringify({ paymentId: 'pay_1' }),
|
|
310
|
+
headers: {
|
|
311
|
+
'Content-Type': 'application/json',
|
|
312
|
+
'X-Webhook-Secret': webhookSecret,
|
|
313
|
+
},
|
|
314
|
+
method: 'POST',
|
|
315
|
+
});
|
|
316
|
+
const denied = await request(adapter, graph, '/webhooks/payment', {
|
|
317
|
+
body: JSON.stringify({ paymentId: 'pay_1' }),
|
|
318
|
+
headers: {
|
|
319
|
+
'Content-Type': 'application/json',
|
|
320
|
+
'X-Webhook-Secret': 'wrong',
|
|
321
|
+
},
|
|
322
|
+
method: 'POST',
|
|
323
|
+
});
|
|
324
|
+
const invalidPayload = await request(adapter, graph, '/webhooks/payment', {
|
|
325
|
+
body: JSON.stringify({ paymentId: 123 }),
|
|
326
|
+
headers: {
|
|
327
|
+
'Content-Type': 'application/json',
|
|
328
|
+
'X-Webhook-Secret': webhookSecret,
|
|
329
|
+
},
|
|
330
|
+
method: 'POST',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await expectOkResponse(verified, { paymentId: 'pay_1' });
|
|
334
|
+
expect(denied.status).toBe(403);
|
|
335
|
+
expect(await expectJson(denied)).toMatchObject({
|
|
336
|
+
error: { category: 'permission' },
|
|
337
|
+
});
|
|
338
|
+
expect(invalidPayload.status).toBe(400);
|
|
339
|
+
expect(await expectJson(invalidPayload)).toMatchObject({
|
|
340
|
+
error: { category: 'validation' },
|
|
341
|
+
});
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
export const createHttpAdapterConformanceCases =
|
|
345
|
+
(): readonly HttpAdapterConformanceCase[] => [
|
|
346
|
+
{ check: readRouteCase, name: 'serves read trails from query parameters' },
|
|
347
|
+
{ check: writeRouteCase, name: 'serves write trails from JSON bodies' },
|
|
348
|
+
{
|
|
349
|
+
check: repeatedQueryCase,
|
|
350
|
+
name: 'preserves repeated query keys before validation',
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
check: typedQueryCase,
|
|
354
|
+
name: 'converts schema-declared query primitives before validation',
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
check: publicErrorCase,
|
|
358
|
+
name: 'renders generic errors as redacted public 500 responses',
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
check: requestContextCase,
|
|
362
|
+
name: 'threads request context and abort signals',
|
|
363
|
+
},
|
|
364
|
+
{ check: webhookCase, name: 'handles webhook verification and parsing' },
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
export const runConformance = (
|
|
368
|
+
adapter: HttpAdapterConformanceAdapter,
|
|
369
|
+
cases: readonly HttpAdapterConformanceCase[] = createHttpAdapterConformanceCases()
|
|
370
|
+
): void => {
|
|
371
|
+
describe(`${adapter.name} HTTP adapter conformance`, () => {
|
|
372
|
+
for (const conformanceCase of cases) {
|
|
373
|
+
test(conformanceCase.name, async () => {
|
|
374
|
+
await conformanceCase.check(adapter);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
};
|