@openaddresses/batch-schema 10.25.0 → 10.27.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/.github/workflows/test.yml +19 -19
- package/CHANGELOG.md +15 -0
- package/README.md +78 -0
- package/dist/lib/openapi.js +16 -6
- package/dist/lib/openapi.js.map +1 -1
- package/dist/lib/schema.d.ts +45 -2
- package/dist/lib/schema.js +177 -192
- package/dist/lib/schema.js.map +1 -1
- package/dist/lib/types.d.ts +48 -1
- package/dist/lib/types.js +48 -1
- package/dist/lib/types.js.map +1 -1
- package/dist/test/response.srv.test.d.ts +1 -0
- package/dist/test/response.srv.test.js +95 -0
- package/dist/test/response.srv.test.js.map +1 -0
- package/dist/test/schema.srv.test.js +216 -0
- package/dist/test/schema.srv.test.js.map +1 -1
- package/lib/openapi.ts +14 -6
- package/lib/schema.ts +224 -201
- package/lib/types.ts +86 -1
- package/package.json +1 -1
- package/test/response.srv.test.ts +114 -0
- package/test/schema.srv.test.ts +244 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { Type } from '@sinclair/typebox';
|
|
5
|
+
import Schema from '../index.js';
|
|
6
|
+
|
|
7
|
+
const app = express();
|
|
8
|
+
const schema = new Schema(express.Router(), { logging: false });
|
|
9
|
+
|
|
10
|
+
app.use('/api', schema.router);
|
|
11
|
+
|
|
12
|
+
let server: ReturnType<typeof app.listen>;
|
|
13
|
+
|
|
14
|
+
const stripped = { ok: true, secret: 'do-not-send' };
|
|
15
|
+
|
|
16
|
+
describe('Response Validation', () => {
|
|
17
|
+
before((_, done) => {
|
|
18
|
+
server = app.listen(2001, async () => {
|
|
19
|
+
await schema.get('/date-res', {
|
|
20
|
+
res: Type.Object({
|
|
21
|
+
when: Type.String()
|
|
22
|
+
})
|
|
23
|
+
}, async (_req, res) => {
|
|
24
|
+
res.json({ when: new Date(0) as unknown as string });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
await schema.get('/strip-res', {
|
|
28
|
+
res: Type.Object({
|
|
29
|
+
ok: Type.Boolean()
|
|
30
|
+
})
|
|
31
|
+
}, async (_req, res) => {
|
|
32
|
+
res.json(stripped);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await schema.get('/bad-res', {
|
|
36
|
+
res: Type.Object({
|
|
37
|
+
ok: Type.Boolean()
|
|
38
|
+
})
|
|
39
|
+
}, async (_req, res) => {
|
|
40
|
+
res.json({ wrong: 1 } as unknown as { ok: boolean });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await schema.get('/created-res', {
|
|
44
|
+
res: Type.Object({
|
|
45
|
+
ok: Type.Boolean()
|
|
46
|
+
})
|
|
47
|
+
}, async (_req, res) => {
|
|
48
|
+
res.status(201).json({ anything: 'goes', when: new Date(0) } as unknown as { ok: boolean });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await schema.get('/any-res', {
|
|
52
|
+
res: Type.Any()
|
|
53
|
+
}, async (_req, res) => {
|
|
54
|
+
res.json({ when: new Date(0), extra: 'kept' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
done();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
after(() => {
|
|
62
|
+
server.close();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('GET: /date-res serializes Dates before validation', async () => {
|
|
66
|
+
const res = await fetch('http://localhost:2001/api/date-res');
|
|
67
|
+
assert.strictEqual(res.status, 200);
|
|
68
|
+
assert.deepStrictEqual(await res.json(), { when: '1970-01-01T00:00:00.000Z' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('GET: /strip-res removes additional properties without mutating the handler object', async () => {
|
|
72
|
+
const res = await fetch('http://localhost:2001/api/strip-res');
|
|
73
|
+
assert.strictEqual(res.status, 200);
|
|
74
|
+
assert.deepStrictEqual(await res.json(), { ok: true });
|
|
75
|
+
assert.deepStrictEqual(stripped, { ok: true, secret: 'do-not-send' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('GET: /bad-res returns a Response validation error', async () => {
|
|
79
|
+
const res = await fetch('http://localhost:2001/api/bad-res');
|
|
80
|
+
assert.strictEqual(res.status, 400);
|
|
81
|
+
const body = await res.json();
|
|
82
|
+
assert.strictEqual(body.type, 'Response');
|
|
83
|
+
assert.ok(Array.isArray(body.errors));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('GET: /created-res skips response validation on non-200 status', async () => {
|
|
87
|
+
const res = await fetch('http://localhost:2001/api/created-res');
|
|
88
|
+
assert.strictEqual(res.status, 201);
|
|
89
|
+
assert.deepStrictEqual(await res.json(), {
|
|
90
|
+
anything: 'goes',
|
|
91
|
+
when: '1970-01-01T00:00:00.000Z'
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('GET: /any-res passes Type.Any responses through untouched', async () => {
|
|
96
|
+
const res = await fetch('http://localhost:2001/api/any-res');
|
|
97
|
+
assert.strictEqual(res.status, 200);
|
|
98
|
+
assert.deepStrictEqual(await res.json(), {
|
|
99
|
+
when: '1970-01-01T00:00:00.000Z',
|
|
100
|
+
extra: 'kept'
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('GET: rejects body validation at registration', async () => {
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
schema.get('/no-body-allowed', {
|
|
107
|
+
body: Type.Object({ nope: Type.Boolean() })
|
|
108
|
+
}, async (_req, res) => {
|
|
109
|
+
res.json({});
|
|
110
|
+
}),
|
|
111
|
+
/Get: \/no-body-allowed: Error: Body not allowed/
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/test/schema.srv.test.ts
CHANGED
|
@@ -24,6 +24,76 @@ describe('Schema Server', () => {
|
|
|
24
24
|
res.json({ ok: true });
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
await schema.post('/json-only', {
|
|
28
|
+
description: 'Backwards compatible JSON body',
|
|
29
|
+
body: Type.Object({
|
|
30
|
+
name: Type.String()
|
|
31
|
+
}),
|
|
32
|
+
res: Type.Object({ ok: Type.Boolean() })
|
|
33
|
+
}, async (req, res) => {
|
|
34
|
+
res.json({ ok: typeof req.body.name === 'string' });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await schema.post('/multi-body', {
|
|
38
|
+
description: 'Multi content-type body',
|
|
39
|
+
body: {
|
|
40
|
+
'application/json': Type.Object({ name: Type.String() }),
|
|
41
|
+
'text/xml': true
|
|
42
|
+
},
|
|
43
|
+
res: Type.Object({
|
|
44
|
+
contentType: Type.String(),
|
|
45
|
+
body: Type.Any()
|
|
46
|
+
})
|
|
47
|
+
}, async (req, res) => {
|
|
48
|
+
res.json({
|
|
49
|
+
contentType: (req.headers['content-type'] || '').split(';')[0],
|
|
50
|
+
body: req.body
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await schema.post('/wildcard-body', {
|
|
55
|
+
description: 'Wildcard content-type body',
|
|
56
|
+
body: {
|
|
57
|
+
'application/json': Type.Object({ name: Type.String() }),
|
|
58
|
+
'text/*': true
|
|
59
|
+
},
|
|
60
|
+
res: Type.Object({
|
|
61
|
+
contentType: Type.String(),
|
|
62
|
+
body: Type.Any()
|
|
63
|
+
})
|
|
64
|
+
}, async (req, res) => {
|
|
65
|
+
res.json({
|
|
66
|
+
contentType: (req.headers['content-type'] || '').split(';')[0],
|
|
67
|
+
body: req.body
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await schema.post('/optional-body', {
|
|
72
|
+
description: 'Optional body',
|
|
73
|
+
body: Type.Object({ name: Type.String() }),
|
|
74
|
+
bodyRequired: false,
|
|
75
|
+
res: Type.Object({ ok: Type.Boolean() })
|
|
76
|
+
}, async (_req, res) => {
|
|
77
|
+
res.json({ ok: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await schema.post('/with-examples', {
|
|
81
|
+
description: 'Body with examples in OpenAPI',
|
|
82
|
+
body: {
|
|
83
|
+
'application/json': {
|
|
84
|
+
schema: Type.Object({ name: Type.String() }),
|
|
85
|
+
example: { name: 'sample' },
|
|
86
|
+
examples: {
|
|
87
|
+
primary: { summary: 'Primary', value: { name: 'alice' } },
|
|
88
|
+
secondary: { summary: 'Secondary', value: { name: 'bob' } }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
res: Type.Object({ ok: Type.Boolean() })
|
|
93
|
+
}, async (_req, res) => {
|
|
94
|
+
res.json({ ok: true });
|
|
95
|
+
});
|
|
96
|
+
|
|
27
97
|
await schema.api();
|
|
28
98
|
done();
|
|
29
99
|
});
|
|
@@ -43,6 +113,31 @@ describe('Schema Server', () => {
|
|
|
43
113
|
query: false,
|
|
44
114
|
res: true
|
|
45
115
|
},
|
|
116
|
+
'POST /json-only': {
|
|
117
|
+
body: true,
|
|
118
|
+
query: false,
|
|
119
|
+
res: true
|
|
120
|
+
},
|
|
121
|
+
'POST /multi-body': {
|
|
122
|
+
body: true,
|
|
123
|
+
query: false,
|
|
124
|
+
res: true
|
|
125
|
+
},
|
|
126
|
+
'POST /wildcard-body': {
|
|
127
|
+
body: true,
|
|
128
|
+
query: false,
|
|
129
|
+
res: true
|
|
130
|
+
},
|
|
131
|
+
'POST /optional-body': {
|
|
132
|
+
body: true,
|
|
133
|
+
query: false,
|
|
134
|
+
res: true
|
|
135
|
+
},
|
|
136
|
+
'POST /with-examples': {
|
|
137
|
+
body: true,
|
|
138
|
+
query: false,
|
|
139
|
+
res: true
|
|
140
|
+
},
|
|
46
141
|
'GET /schema':{
|
|
47
142
|
body: false,
|
|
48
143
|
query: true,
|
|
@@ -123,4 +218,153 @@ describe('Schema Server', () => {
|
|
|
123
218
|
assert.strictEqual(body.paths['/legacy'].get.deprecated, true);
|
|
124
219
|
assert.strictEqual(body.paths['/schema'].get.deprecated, false);
|
|
125
220
|
});
|
|
221
|
+
|
|
222
|
+
it('POST: /json-only with valid JSON body (backwards compat)', async () => {
|
|
223
|
+
const res = await fetch('http://localhost:2000/api/json-only', {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
body: JSON.stringify({ name: 'foo' })
|
|
227
|
+
});
|
|
228
|
+
assert.strictEqual(res.status, 200);
|
|
229
|
+
assert.deepStrictEqual(await res.json(), { ok: true });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('POST: /multi-body with application/json', async () => {
|
|
233
|
+
const res = await fetch('http://localhost:2000/api/multi-body', {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: { 'Content-Type': 'application/json' },
|
|
236
|
+
body: JSON.stringify({ name: 'foo' })
|
|
237
|
+
});
|
|
238
|
+
assert.strictEqual(res.status, 200);
|
|
239
|
+
assert.deepStrictEqual(await res.json(), {
|
|
240
|
+
contentType: 'application/json',
|
|
241
|
+
body: { name: 'foo' }
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('POST: /multi-body with text/xml (true => no validation)', async () => {
|
|
246
|
+
const res = await fetch('http://localhost:2000/api/multi-body', {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'Content-Type': 'text/xml' },
|
|
249
|
+
body: '<root><a>1</a></root>'
|
|
250
|
+
});
|
|
251
|
+
assert.strictEqual(res.status, 200);
|
|
252
|
+
assert.deepStrictEqual(await res.json(), {
|
|
253
|
+
contentType: 'text/xml',
|
|
254
|
+
body: '<root><a>1</a></root>'
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('POST: /multi-body with unsupported content-type returns 400', async () => {
|
|
259
|
+
const res = await fetch('http://localhost:2000/api/multi-body', {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
262
|
+
body: 'hello'
|
|
263
|
+
});
|
|
264
|
+
assert.strictEqual(res.status, 400);
|
|
265
|
+
const body = await res.json();
|
|
266
|
+
assert.strictEqual(body.status, 400);
|
|
267
|
+
assert.match(body.message, /Content-Type text\/plain not supported/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('POST: /multi-body with invalid JSON body fails validation', async () => {
|
|
271
|
+
const res = await fetch('http://localhost:2000/api/multi-body', {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({})
|
|
275
|
+
});
|
|
276
|
+
assert.strictEqual(res.status, 400);
|
|
277
|
+
const body = await res.json();
|
|
278
|
+
assert.strictEqual(body.message, 'Validation Error POST /multi-body');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('GET: api/openapi documents multiple content types', async () => {
|
|
282
|
+
const res = await fetch('http://localhost:2000/api/openapi');
|
|
283
|
+
const body = await res.json();
|
|
284
|
+
|
|
285
|
+
assert.deepStrictEqual(
|
|
286
|
+
Object.keys(body.paths['/multi-body'].post.requestBody.content).sort(),
|
|
287
|
+
['application/json', 'text/xml']
|
|
288
|
+
);
|
|
289
|
+
assert.deepStrictEqual(
|
|
290
|
+
body.paths['/multi-body'].post.requestBody.content['text/xml'],
|
|
291
|
+
{ schema: {} }
|
|
292
|
+
);
|
|
293
|
+
assert.strictEqual(
|
|
294
|
+
body.paths['/json-only'].post.requestBody.content['application/json'].schema.type,
|
|
295
|
+
'object'
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('POST: /wildcard-body matches text/* with text/csv', async () => {
|
|
300
|
+
const res = await fetch('http://localhost:2000/api/wildcard-body', {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
headers: { 'Content-Type': 'text/csv' },
|
|
303
|
+
body: 'a,b,c\n1,2,3'
|
|
304
|
+
});
|
|
305
|
+
assert.strictEqual(res.status, 200);
|
|
306
|
+
assert.deepStrictEqual(await res.json(), {
|
|
307
|
+
contentType: 'text/csv',
|
|
308
|
+
body: 'a,b,c\n1,2,3'
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('POST: /wildcard-body still validates application/json strictly', async () => {
|
|
313
|
+
const res = await fetch('http://localhost:2000/api/wildcard-body', {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: { 'Content-Type': 'application/json' },
|
|
316
|
+
body: JSON.stringify({ name: 'foo' })
|
|
317
|
+
});
|
|
318
|
+
assert.strictEqual(res.status, 200);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('POST: /wildcard-body rejects unsupported family', async () => {
|
|
322
|
+
const res = await fetch('http://localhost:2000/api/wildcard-body', {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
headers: { 'Content-Type': 'image/png' },
|
|
325
|
+
body: 'binary'
|
|
326
|
+
});
|
|
327
|
+
assert.strictEqual(res.status, 400);
|
|
328
|
+
const body = await res.json();
|
|
329
|
+
assert.match(body.message, /Content-Type image\/png not supported/);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('POST: /optional-body accepts no body when bodyRequired=false', async () => {
|
|
333
|
+
const res = await fetch('http://localhost:2000/api/optional-body', {
|
|
334
|
+
method: 'POST'
|
|
335
|
+
});
|
|
336
|
+
assert.strictEqual(res.status, 200);
|
|
337
|
+
assert.deepStrictEqual(await res.json(), { ok: true });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('POST: /optional-body still validates when a body is sent', async () => {
|
|
341
|
+
const res = await fetch('http://localhost:2000/api/optional-body', {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'Content-Type': 'application/json' },
|
|
344
|
+
body: JSON.stringify({})
|
|
345
|
+
});
|
|
346
|
+
assert.strictEqual(res.status, 400);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('GET: api/openapi marks optional body required=false', async () => {
|
|
350
|
+
const res = await fetch('http://localhost:2000/api/openapi');
|
|
351
|
+
const body = await res.json();
|
|
352
|
+
assert.strictEqual(body.paths['/optional-body'].post.requestBody.required, false);
|
|
353
|
+
assert.strictEqual(body.paths['/json-only'].post.requestBody.required, true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('GET: api/openapi includes example and examples on media type', async () => {
|
|
357
|
+
const res = await fetch('http://localhost:2000/api/openapi');
|
|
358
|
+
const body = await res.json();
|
|
359
|
+
const media = body.paths['/with-examples'].post.requestBody.content['application/json'];
|
|
360
|
+
assert.deepStrictEqual(media.example, { name: 'sample' });
|
|
361
|
+
assert.deepStrictEqual(media.examples.primary, {
|
|
362
|
+
summary: 'Primary',
|
|
363
|
+
value: { name: 'alice' }
|
|
364
|
+
});
|
|
365
|
+
assert.deepStrictEqual(media.examples.secondary, {
|
|
366
|
+
summary: 'Secondary',
|
|
367
|
+
value: { name: 'bob' }
|
|
368
|
+
});
|
|
369
|
+
});
|
|
126
370
|
});
|