fastify-ata 0.2.21 → 0.2.23
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/bench-realtime.js +131 -0
- package/bench-startup.js +64 -132
- package/bench-turbo.js +118 -0
- package/index.d.ts +12 -1
- package/package.json +3 -3
- package/profile-fastify.mjs +87 -0
- package/test.js +266 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Real-time Fastify simulation: actual HTTP requests with autocannon
|
|
4
|
+
// Shows: startup time + request throughput + latency
|
|
5
|
+
|
|
6
|
+
const { writeFileSync, unlinkSync } = require('fs')
|
|
7
|
+
const { fork, execSync } = require('child_process')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
|
|
10
|
+
const ROUTES = 50
|
|
11
|
+
const DURATION = 5 // seconds
|
|
12
|
+
const CONNECTIONS = 50
|
|
13
|
+
|
|
14
|
+
function makeRoutes(count) {
|
|
15
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
16
|
+
path: `/api/v1/resource${i}`,
|
|
17
|
+
schema: {
|
|
18
|
+
body: {
|
|
19
|
+
type: 'object',
|
|
20
|
+
properties: {
|
|
21
|
+
id: { type: 'integer', minimum: 1 },
|
|
22
|
+
name: { type: 'string', minLength: 1, maxLength: 100 },
|
|
23
|
+
email: { type: 'string', format: 'email' },
|
|
24
|
+
age: { type: 'integer', minimum: 0, maximum: 150 },
|
|
25
|
+
active: { type: 'boolean' },
|
|
26
|
+
role: { enum: ['admin', 'user', 'moderator'] },
|
|
27
|
+
},
|
|
28
|
+
required: ['id', 'name', 'email', 'active', 'role'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const routes = makeRoutes(ROUTES)
|
|
35
|
+
const payload = JSON.stringify({
|
|
36
|
+
id: 42, name: 'Mert', email: 'mert@example.com',
|
|
37
|
+
age: 26, active: true, role: 'admin'
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function serverScript(useAta) {
|
|
41
|
+
return `
|
|
42
|
+
'use strict'
|
|
43
|
+
const t0 = process.hrtime.bigint()
|
|
44
|
+
const fastify = require('fastify')()
|
|
45
|
+
${useAta ? "fastify.register(require('./index'))" : ''}
|
|
46
|
+
const routes = ${JSON.stringify(routes)}
|
|
47
|
+
for (const r of routes) {
|
|
48
|
+
fastify.post(r.path, { schema: r.schema }, (req, reply) => {
|
|
49
|
+
reply.send({ ok: true, id: req.body.id })
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
fastify.listen({ port: 0 }).then(() => {
|
|
53
|
+
const startupMs = Number(process.hrtime.bigint() - t0) / 1e6
|
|
54
|
+
const port = fastify.server.address().port
|
|
55
|
+
process.send({ port, startupMs })
|
|
56
|
+
})
|
|
57
|
+
`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runTest(label, useAta) {
|
|
61
|
+
const scriptPath = path.join(__dirname, '_realtime_server.js')
|
|
62
|
+
writeFileSync(scriptPath, serverScript(useAta))
|
|
63
|
+
|
|
64
|
+
const child = fork(scriptPath, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] })
|
|
65
|
+
const { port, startupMs } = await new Promise(resolve => {
|
|
66
|
+
child.on('message', msg => resolve(msg))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// Pick a random route for each request
|
|
70
|
+
const targetRoute = routes[Math.floor(Math.random() * routes.length)].path
|
|
71
|
+
const escaped = payload.replace(/'/g, "'\\''")
|
|
72
|
+
|
|
73
|
+
let result
|
|
74
|
+
try {
|
|
75
|
+
const raw = execSync(
|
|
76
|
+
`npx autocannon -c ${CONNECTIONS} -d ${DURATION} -j http://localhost:${port}${targetRoute} -m POST -H "content-type: application/json" -b '${escaped}'`,
|
|
77
|
+
{ cwd: __dirname, timeout: 30000 }
|
|
78
|
+
).toString()
|
|
79
|
+
result = JSON.parse(raw)
|
|
80
|
+
} catch (e) {
|
|
81
|
+
child.kill()
|
|
82
|
+
try { unlinkSync(scriptPath) } catch {}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
child.kill()
|
|
87
|
+
try { unlinkSync(scriptPath) } catch {}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
label,
|
|
91
|
+
startupMs,
|
|
92
|
+
reqPerSec: result.requests.average,
|
|
93
|
+
latencyAvg: result.latency.average,
|
|
94
|
+
latencyP99: result.latency.p99,
|
|
95
|
+
throughputMB: (result.throughput.average / 1024 / 1024).toFixed(1),
|
|
96
|
+
errors: result.errors,
|
|
97
|
+
timeouts: result.timeouts,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
console.log('\n' + '='.repeat(60))
|
|
103
|
+
console.log(' Real-Time Fastify Simulation')
|
|
104
|
+
console.log(` ${ROUTES} routes, ${CONNECTIONS} connections, ${DURATION}s duration`)
|
|
105
|
+
console.log(' POST with 6-field validated body')
|
|
106
|
+
console.log('='.repeat(60) + '\n')
|
|
107
|
+
|
|
108
|
+
const ata = await runTest('fastify-ata', true)
|
|
109
|
+
const ajv = await runTest('fastify (ajv)', false)
|
|
110
|
+
|
|
111
|
+
if (!ata || !ajv) { console.log('Benchmark failed'); return }
|
|
112
|
+
|
|
113
|
+
console.log(' fastify-ata fastify (ajv)')
|
|
114
|
+
console.log(' ─────────────────────────────────────────────────')
|
|
115
|
+
console.log(` Startup ${ata.startupMs.toFixed(1).padStart(8)} ms ${ajv.startupMs.toFixed(1).padStart(8)} ms`)
|
|
116
|
+
console.log(` Requests/sec ${ata.reqPerSec.toLocaleString().padStart(8)} ${ajv.reqPerSec.toLocaleString().padStart(8)}`)
|
|
117
|
+
console.log(` Latency (avg) ${ata.latencyAvg.toFixed(2).padStart(8)} ms ${ajv.latencyAvg.toFixed(2).padStart(8)} ms`)
|
|
118
|
+
console.log(` Latency (p99) ${ata.latencyP99.toFixed(2).padStart(8)} ms ${ajv.latencyP99.toFixed(2).padStart(8)} ms`)
|
|
119
|
+
console.log(` Throughput ${ata.throughputMB.padStart(8)} MB/s ${ajv.throughputMB.padStart(8)} MB/s`)
|
|
120
|
+
console.log(` Errors ${String(ata.errors).padStart(8)} ${String(ajv.errors).padStart(8)}`)
|
|
121
|
+
console.log(` Timeouts ${String(ata.timeouts).padStart(8)} ${String(ajv.timeouts).padStart(8)}`)
|
|
122
|
+
console.log()
|
|
123
|
+
|
|
124
|
+
const startupRatio = ajv.startupMs / ata.startupMs
|
|
125
|
+
const rpsRatio = ata.reqPerSec / ajv.reqPerSec
|
|
126
|
+
console.log(` Startup: ata ${startupRatio.toFixed(1)}x faster`)
|
|
127
|
+
console.log(` Throughput: ata ${rpsRatio.toFixed(2)}x ${rpsRatio >= 1 ? 'faster' : 'slower'}`)
|
|
128
|
+
console.log()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
main().catch(err => { console.error(err); process.exit(1) })
|
package/bench-startup.js
CHANGED
|
@@ -1,168 +1,100 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
// Matteo's scenario: Fastify startup time with N routes
|
|
4
|
+
// This is what he cares about most
|
|
5
|
+
|
|
6
|
+
const { writeFileSync, unlinkSync } = require('fs')
|
|
5
7
|
const path = require('path')
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
function makeRouteSchemas(count) {
|
|
10
|
+
const routes = []
|
|
11
|
+
for (let i = 0; i < count; i++) {
|
|
12
|
+
routes.push({
|
|
13
|
+
path: `/api/v1/resource${i}`,
|
|
14
|
+
schema: {
|
|
15
|
+
body: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
id: { type: 'integer', minimum: 1 },
|
|
19
|
+
name: { type: 'string', minLength: 1, maxLength: 100 },
|
|
20
|
+
email: { type: 'string', format: 'email' },
|
|
21
|
+
[`field_${i}`]: { type: 'string' },
|
|
22
|
+
active: { type: 'boolean' },
|
|
23
|
+
},
|
|
24
|
+
required: ['id', 'name', 'email'],
|
|
25
|
+
},
|
|
26
|
+
querystring: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
page: { type: 'integer', minimum: 1 },
|
|
30
|
+
limit: { type: 'integer', minimum: 1, maximum: 100 },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
19
33
|
},
|
|
20
|
-
required: ['id', 'name', 'email', 'active'],
|
|
21
34
|
})
|
|
22
35
|
}
|
|
23
|
-
return
|
|
36
|
+
return routes
|
|
24
37
|
}
|
|
25
38
|
|
|
26
|
-
function serverScript(
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
if (mode === 'ajv-normal') {
|
|
30
|
-
return `
|
|
39
|
+
function serverScript(useAta, routeCount) {
|
|
40
|
+
const routes = makeRouteSchemas(routeCount)
|
|
41
|
+
return `
|
|
31
42
|
'use strict'
|
|
32
|
-
const
|
|
43
|
+
const t0 = process.hrtime.bigint()
|
|
33
44
|
const fastify = require('fastify')()
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
process.exit()
|
|
42
|
-
})
|
|
43
|
-
`
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (mode === 'ata-normal') {
|
|
47
|
-
return `
|
|
48
|
-
'use strict'
|
|
49
|
-
const start = performance.now()
|
|
50
|
-
const fastify = require('fastify')()
|
|
51
|
-
fastify.register(require('./index'))
|
|
52
|
-
const schemas = ${JSON.stringify(schemas)}
|
|
53
|
-
schemas.forEach((s, i) => {
|
|
54
|
-
fastify.post('/route' + i, { schema: { body: s } }, (req, reply) => reply.send({ ok: true }))
|
|
55
|
-
})
|
|
56
|
-
fastify.ready().then(() => {
|
|
57
|
-
const ms = performance.now() - start
|
|
58
|
-
process.send({ ms })
|
|
59
|
-
process.exit()
|
|
60
|
-
})
|
|
61
|
-
`
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (mode === 'ata-standalone') {
|
|
65
|
-
return `
|
|
66
|
-
'use strict'
|
|
67
|
-
const start = performance.now()
|
|
68
|
-
const fastify = require('fastify')()
|
|
69
|
-
const { Validator } = require('ata-validator')
|
|
70
|
-
const schemas = ${JSON.stringify(schemas)}
|
|
71
|
-
const cache = new WeakMap()
|
|
72
|
-
fastify.setValidatorCompiler(({ schema }) => {
|
|
73
|
-
let v = cache.get(schema)
|
|
74
|
-
if (!v) {
|
|
75
|
-
const idx = schemas.indexOf(schema)
|
|
76
|
-
if (idx >= 0) {
|
|
77
|
-
try {
|
|
78
|
-
const mod = require('./standalone/s' + idx + '.js')
|
|
79
|
-
v = Validator.fromStandalone(mod, schema)
|
|
80
|
-
} catch {
|
|
81
|
-
v = new Validator(schema)
|
|
82
|
-
}
|
|
83
|
-
} else {
|
|
84
|
-
v = new Validator(schema)
|
|
85
|
-
}
|
|
86
|
-
cache.set(schema, v)
|
|
87
|
-
}
|
|
88
|
-
return (data) => {
|
|
89
|
-
const r = v.validate(data)
|
|
90
|
-
if (r.valid) return { value: data }
|
|
91
|
-
const err = new Error(r.errors.map(e => e.message).join(', '))
|
|
92
|
-
err.statusCode = 400
|
|
93
|
-
err.validation = r.errors.map(e => ({ message: e.message, instancePath: e.path || '' }))
|
|
94
|
-
return { error: err }
|
|
95
|
-
}
|
|
96
|
-
})
|
|
97
|
-
schemas.forEach((s, i) => {
|
|
98
|
-
fastify.post('/route' + i, { schema: { body: s } }, (req, reply) => reply.send({ ok: true }))
|
|
99
|
-
})
|
|
45
|
+
${useAta ? "fastify.register(require('./index'))" : ''}
|
|
46
|
+
const routes = ${JSON.stringify(routes)}
|
|
47
|
+
for (const r of routes) {
|
|
48
|
+
fastify.post(r.path, { schema: r.schema }, (req, reply) => {
|
|
49
|
+
reply.send({ ok: true })
|
|
50
|
+
})
|
|
51
|
+
}
|
|
100
52
|
fastify.ready().then(() => {
|
|
101
|
-
const
|
|
102
|
-
process.send({
|
|
103
|
-
process.exit()
|
|
53
|
+
const dt = Number(process.hrtime.bigint() - t0) / 1e6
|
|
54
|
+
process.send({ startupMs: dt })
|
|
55
|
+
process.exit(0)
|
|
104
56
|
})
|
|
105
57
|
`
|
|
106
|
-
}
|
|
107
58
|
}
|
|
108
59
|
|
|
109
|
-
async function
|
|
110
|
-
const scriptPath = path.join(__dirname, '
|
|
111
|
-
writeFileSync(scriptPath, serverScript(mode, n))
|
|
112
|
-
|
|
113
|
-
// Pre-build standalone files if needed
|
|
114
|
-
if (mode === 'ata-standalone') {
|
|
115
|
-
const { Validator } = require('ata-validator')
|
|
116
|
-
const schemas = makeSchemas(n)
|
|
117
|
-
const dir = path.join(__dirname, 'standalone')
|
|
118
|
-
mkdirSync(dir, { recursive: true })
|
|
119
|
-
schemas.forEach((s, i) => {
|
|
120
|
-
const v = new Validator(s)
|
|
121
|
-
const src = v.toStandalone()
|
|
122
|
-
if (src) writeFileSync(path.join(dir, `s${i}.js`), src)
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const runs = 5
|
|
60
|
+
async function measureStartup(useAta, routeCount, runs) {
|
|
61
|
+
const scriptPath = path.join(__dirname, '_startup_bench.js')
|
|
127
62
|
const times = []
|
|
63
|
+
|
|
128
64
|
for (let r = 0; r < runs; r++) {
|
|
65
|
+
writeFileSync(scriptPath, serverScript(useAta, routeCount))
|
|
129
66
|
const { fork } = require('child_process')
|
|
67
|
+
const child = fork(scriptPath, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] })
|
|
130
68
|
const ms = await new Promise((resolve, reject) => {
|
|
131
|
-
|
|
132
|
-
child.on('message', (msg) => resolve(msg.ms))
|
|
69
|
+
child.on('message', msg => resolve(msg.startupMs))
|
|
133
70
|
child.on('error', reject)
|
|
134
|
-
setTimeout(() => { child.kill(); reject(new Error('timeout'))
|
|
71
|
+
setTimeout(() => { child.kill(); reject(new Error('timeout')) }, 10000)
|
|
135
72
|
})
|
|
136
73
|
times.push(ms)
|
|
137
74
|
}
|
|
138
75
|
|
|
139
|
-
try { unlinkSync(
|
|
140
|
-
|
|
76
|
+
try { unlinkSync(scriptPath) } catch {}
|
|
141
77
|
times.sort((a, b) => a - b)
|
|
142
|
-
|
|
143
|
-
console.log(` ${label.padEnd(30)} ${median.toFixed(1)}ms (median of ${runs})`)
|
|
144
|
-
return median
|
|
78
|
+
return times[Math.floor(times.length / 2)]
|
|
145
79
|
}
|
|
146
80
|
|
|
147
81
|
async function main() {
|
|
148
82
|
console.log('\n==============================================')
|
|
149
|
-
console.log(' Fastify Startup Benchmark')
|
|
150
|
-
console.log('
|
|
83
|
+
console.log(' Fastify Startup Benchmark (cold start)')
|
|
84
|
+
console.log(' Median of 5 runs, process-isolated')
|
|
151
85
|
console.log('==============================================\n')
|
|
152
86
|
|
|
153
|
-
for (const
|
|
154
|
-
console.log(`--- ${
|
|
155
|
-
|
|
156
|
-
const ata = await
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
console.log(` ata
|
|
160
|
-
console.log(`
|
|
87
|
+
for (const count of [5, 10, 25, 50, 100]) {
|
|
88
|
+
console.log(`--- ${count} routes (body + querystring schemas) ---`)
|
|
89
|
+
|
|
90
|
+
const ata = await measureStartup(true, count, 5)
|
|
91
|
+
const ajv = await measureStartup(false, count, 5)
|
|
92
|
+
|
|
93
|
+
console.log(` ata: ${ata.toFixed(1).padStart(8)} ms`)
|
|
94
|
+
console.log(` ajv: ${ajv.toFixed(1).padStart(8)} ms`)
|
|
95
|
+
console.log(` >>> ata ${(ajv / ata).toFixed(1)}x faster startup`)
|
|
161
96
|
console.log()
|
|
162
97
|
}
|
|
163
|
-
|
|
164
|
-
// Cleanup
|
|
165
|
-
try { rmSync(path.join(__dirname, 'standalone'), { recursive: true }) } catch {}
|
|
166
98
|
}
|
|
167
99
|
|
|
168
|
-
main().catch(console.error)
|
|
100
|
+
main().catch(err => { console.error(err); process.exit(1) })
|
package/bench-turbo.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process')
|
|
4
|
+
const { writeFileSync, unlinkSync } = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
|
|
7
|
+
const schema = {
|
|
8
|
+
body: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
users: {
|
|
12
|
+
type: 'array',
|
|
13
|
+
items: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
id: { type: 'integer', minimum: 1 },
|
|
17
|
+
name: { type: 'string', minLength: 1 },
|
|
18
|
+
email: { type: 'string', format: 'email' },
|
|
19
|
+
age: { type: 'integer', minimum: 0, maximum: 150 },
|
|
20
|
+
active: { type: 'boolean' },
|
|
21
|
+
role: { enum: ['admin', 'user', 'moderator'] },
|
|
22
|
+
},
|
|
23
|
+
required: ['id', 'name', 'email', 'active', 'role'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
metadata: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
total: { type: 'integer' },
|
|
30
|
+
page: { type: 'integer', minimum: 1 },
|
|
31
|
+
},
|
|
32
|
+
required: ['total', 'page'],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ['users', 'metadata'],
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makePayload(n) {
|
|
40
|
+
const users = []
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
users.push({
|
|
43
|
+
id: i + 1,
|
|
44
|
+
name: `User ${i}`,
|
|
45
|
+
email: `user${i}@example.com`,
|
|
46
|
+
age: 25,
|
|
47
|
+
active: true,
|
|
48
|
+
role: 'user',
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
return JSON.stringify({ users, metadata: { total: n, page: 1 } })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function serverScript(turbo) {
|
|
55
|
+
return `
|
|
56
|
+
'use strict'
|
|
57
|
+
const fastify = require('fastify')()
|
|
58
|
+
fastify.register(require('./index'), { turbo: ${turbo} })
|
|
59
|
+
const schema = ${JSON.stringify(schema)}
|
|
60
|
+
fastify.post('/users', { schema }, (req, reply) => {
|
|
61
|
+
reply.send({ ok: true, count: req.body.users.length })
|
|
62
|
+
})
|
|
63
|
+
fastify.listen({ port: 0 }).then(() => {
|
|
64
|
+
process.send({ port: fastify.server.address().port })
|
|
65
|
+
})
|
|
66
|
+
`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function bench(label, turbo, payload) {
|
|
70
|
+
const scriptPath = path.join(__dirname, '_bench_turbo_server.js')
|
|
71
|
+
writeFileSync(scriptPath, serverScript(turbo))
|
|
72
|
+
|
|
73
|
+
const { fork } = require('child_process')
|
|
74
|
+
const child = fork(scriptPath, { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] })
|
|
75
|
+
const port = await new Promise((resolve) => {
|
|
76
|
+
child.on('message', (msg) => resolve(msg.port))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const escaped = payload.replace(/'/g, "'\\''")
|
|
80
|
+
const result = execSync(
|
|
81
|
+
`npx autocannon -c 10 -d 5 -j http://localhost:${port}/users -m POST -H "content-type: application/json" -b '${escaped}'`,
|
|
82
|
+
{ cwd: __dirname, timeout: 30000 },
|
|
83
|
+
).toString()
|
|
84
|
+
|
|
85
|
+
child.kill()
|
|
86
|
+
try { unlinkSync(scriptPath) } catch {}
|
|
87
|
+
|
|
88
|
+
const parsed = JSON.parse(result)
|
|
89
|
+
return parsed.requests.average
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function main() {
|
|
93
|
+
console.log('\n==============================================')
|
|
94
|
+
console.log(' Turbo Mode Benchmark: normal vs turbo')
|
|
95
|
+
console.log(' 10 connections, 5 seconds, real HTTP')
|
|
96
|
+
console.log('==============================================\n')
|
|
97
|
+
|
|
98
|
+
for (const count of [1, 10, 50, 100]) {
|
|
99
|
+
const payload = makePayload(count)
|
|
100
|
+
console.log(`--- ${count} users (${(payload.length / 1024).toFixed(1)} KB) ---`)
|
|
101
|
+
|
|
102
|
+
const normal = await bench('normal', false, payload)
|
|
103
|
+
const turbo = await bench('turbo', true, payload)
|
|
104
|
+
|
|
105
|
+
console.log(` normal: ${normal.toLocaleString().padStart(10)} req/sec`)
|
|
106
|
+
console.log(` turbo: ${turbo.toLocaleString().padStart(10)} req/sec`)
|
|
107
|
+
|
|
108
|
+
const ratio = turbo / normal
|
|
109
|
+
if (ratio >= 1) console.log(` >>> turbo ${ratio.toFixed(2)}x faster`)
|
|
110
|
+
else console.log(` >>> normal ${(1 / ratio).toFixed(2)}x faster`)
|
|
111
|
+
console.log()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main().catch((err) => {
|
|
116
|
+
console.error(err)
|
|
117
|
+
process.exit(1)
|
|
118
|
+
})
|
package/index.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { FastifyPluginCallback } from 'fastify'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
interface FastifyAtaOptions {
|
|
4
|
+
coerceTypes?: boolean
|
|
5
|
+
removeAdditional?: boolean
|
|
6
|
+
/**
|
|
7
|
+
* Enable turbo mode: overrides the JSON content-type parser to receive
|
|
8
|
+
* the raw Buffer and uses simdjson-backed validateJSON for validation
|
|
9
|
+
* instead of V8's JSON.parse path. Incompatible with coerceTypes.
|
|
10
|
+
*/
|
|
11
|
+
turbo?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare const fastifyAta: FastifyPluginCallback<FastifyAtaOptions>
|
|
4
15
|
|
|
5
16
|
export = fastifyAta
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-ata",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.23",
|
|
4
4
|
"description": "Fastify plugin for ata-validator — beats ajv on every valid-path benchmark. 2.7x faster validate(obj), 151x faster compilation, simdjson + multi-core.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
},
|
|
27
27
|
"homepage": "https://github.com/ata-core/fastify-ata#readme",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"ata-validator": "^0.
|
|
30
|
-
"fastify-ata": "^0.2.
|
|
29
|
+
"ata-validator": "^0.11.0",
|
|
30
|
+
"fastify-ata": "^0.2.21",
|
|
31
31
|
"fastify-plugin": "^5.1.0",
|
|
32
32
|
"sanitize-filename": "^1.6.4"
|
|
33
33
|
},
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Profile validation overhead in Fastify context
|
|
4
|
+
import { createRequire } from 'module'
|
|
5
|
+
const require = createRequire(import.meta.url)
|
|
6
|
+
|
|
7
|
+
const { Validator } = require('ata-validator')
|
|
8
|
+
|
|
9
|
+
// Simulate what Fastify does: JSON.parse + validate
|
|
10
|
+
const schema = {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
users: {
|
|
14
|
+
type: 'array',
|
|
15
|
+
items: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
id: { type: 'integer', minimum: 1 },
|
|
19
|
+
name: { type: 'string', minLength: 1 },
|
|
20
|
+
email: { type: 'string', format: 'email' },
|
|
21
|
+
age: { type: 'integer', minimum: 0, maximum: 150 },
|
|
22
|
+
active: { type: 'boolean' },
|
|
23
|
+
role: { enum: ['admin', 'user', 'moderator'] },
|
|
24
|
+
},
|
|
25
|
+
required: ['id', 'name', 'email', 'active', 'role'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
metadata: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
total: { type: 'integer' },
|
|
32
|
+
page: { type: 'integer', minimum: 1 },
|
|
33
|
+
},
|
|
34
|
+
required: ['total', 'page'],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ['users', 'metadata'],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makePayload(n) {
|
|
41
|
+
const users = []
|
|
42
|
+
for (let i = 0; i < n; i++) {
|
|
43
|
+
users.push({ id: i+1, name: `User ${i}`, email: `u${i}@x.com`, age: 25, active: true, role: 'user' })
|
|
44
|
+
}
|
|
45
|
+
return JSON.stringify({ users, metadata: { total: n, page: 1 } })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const v = new Validator(schema)
|
|
49
|
+
const payloads = [1, 10, 50, 100].map(n => ({ n, json: makePayload(n) }))
|
|
50
|
+
|
|
51
|
+
// Warmup
|
|
52
|
+
for (const p of payloads) {
|
|
53
|
+
for (let i = 0; i < 1000; i++) {
|
|
54
|
+
const obj = JSON.parse(p.json)
|
|
55
|
+
v.validate(obj)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('Breakdown: JSON.parse vs validate vs total\n')
|
|
60
|
+
|
|
61
|
+
const N = 100000
|
|
62
|
+
for (const p of payloads) {
|
|
63
|
+
// JSON.parse only
|
|
64
|
+
let t = process.hrtime.bigint()
|
|
65
|
+
for (let i = 0; i < N; i++) JSON.parse(p.json)
|
|
66
|
+
const dtParse = Number(process.hrtime.bigint() - t) / 1e6
|
|
67
|
+
|
|
68
|
+
// validate only (pre-parsed)
|
|
69
|
+
const obj = JSON.parse(p.json)
|
|
70
|
+
t = process.hrtime.bigint()
|
|
71
|
+
for (let i = 0; i < N; i++) v.validate(obj)
|
|
72
|
+
const dtValidate = Number(process.hrtime.bigint() - t) / 1e6
|
|
73
|
+
|
|
74
|
+
// parse + validate
|
|
75
|
+
t = process.hrtime.bigint()
|
|
76
|
+
for (let i = 0; i < N; i++) { const o = JSON.parse(p.json); v.validate(o) }
|
|
77
|
+
const dtTotal = Number(process.hrtime.bigint() - t) / 1e6
|
|
78
|
+
|
|
79
|
+
const parsePct = (dtParse / dtTotal * 100).toFixed(0)
|
|
80
|
+
const valPct = (dtValidate / dtTotal * 100).toFixed(0)
|
|
81
|
+
|
|
82
|
+
console.log(`${p.n} users (${(p.json.length/1024).toFixed(1)}KB):`)
|
|
83
|
+
console.log(` JSON.parse: ${(dtParse/N*1000).toFixed(1)} us (${parsePct}%)`)
|
|
84
|
+
console.log(` validate: ${(dtValidate/N*1000).toFixed(1)} us (${valPct}%)`)
|
|
85
|
+
console.log(` total: ${(dtTotal/N*1000).toFixed(1)} us`)
|
|
86
|
+
console.log()
|
|
87
|
+
}
|
package/test.js
CHANGED
|
@@ -189,10 +189,276 @@ async function run() {
|
|
|
189
189
|
assert(errBody7.message.includes('email'), `nested: message mentions email: "${errBody7.message}"`)
|
|
190
190
|
await app6.close()
|
|
191
191
|
|
|
192
|
+
// --- Fastify-realistic schema features ---
|
|
193
|
+
|
|
194
|
+
// 13. enum validation
|
|
195
|
+
const app7 = fastify()
|
|
196
|
+
await app7.register(fastifyAta)
|
|
197
|
+
app7.post('/enum', {
|
|
198
|
+
schema: { body: { type: 'object', properties: { role: { type: 'string', enum: ['admin', 'user', 'guest'] } }, required: ['role'] } },
|
|
199
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
200
|
+
await app7.ready()
|
|
201
|
+
const re1 = await app7.inject({ method: 'POST', url: '/enum', payload: { role: 'admin' } })
|
|
202
|
+
const re2 = await app7.inject({ method: 'POST', url: '/enum', payload: { role: 'hacker' } })
|
|
203
|
+
assert(re1.statusCode === 200, 'enum: valid value accepted')
|
|
204
|
+
assert(re2.statusCode === 400, `enum: invalid value rejected (got ${re2.statusCode})`)
|
|
205
|
+
await app7.close()
|
|
206
|
+
|
|
207
|
+
// 14. additionalProperties: false
|
|
208
|
+
const app8 = fastify()
|
|
209
|
+
await app8.register(fastifyAta)
|
|
210
|
+
app8.post('/strict', {
|
|
211
|
+
schema: { body: { type: 'object', properties: { id: { type: 'integer' } }, additionalProperties: false } },
|
|
212
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
213
|
+
await app8.ready()
|
|
214
|
+
const rs1 = await app8.inject({ method: 'POST', url: '/strict', payload: { id: 1 } })
|
|
215
|
+
const rs2 = await app8.inject({ method: 'POST', url: '/strict', payload: { id: 1, extra: 'x' } })
|
|
216
|
+
assert(rs1.statusCode === 200, 'additionalProperties: valid accepted')
|
|
217
|
+
assert(rs2.statusCode === 400, `additionalProperties: extra rejected (got ${rs2.statusCode})`)
|
|
218
|
+
await app8.close()
|
|
219
|
+
|
|
220
|
+
// 15. array items validation
|
|
221
|
+
const app9 = fastify()
|
|
222
|
+
await app9.register(fastifyAta)
|
|
223
|
+
app9.post('/items', {
|
|
224
|
+
schema: { body: { type: 'object', properties: { tags: { type: 'array', items: { type: 'string' }, minItems: 1 } }, required: ['tags'] } },
|
|
225
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
226
|
+
await app9.ready()
|
|
227
|
+
const ri1 = await app9.inject({ method: 'POST', url: '/items', payload: { tags: ['a', 'b'] } })
|
|
228
|
+
const ri2 = await app9.inject({ method: 'POST', url: '/items', payload: { tags: [] } })
|
|
229
|
+
const ri3 = await app9.inject({ method: 'POST', url: '/items', payload: { tags: ['a', 123] } })
|
|
230
|
+
assert(ri1.statusCode === 200, 'array items: valid accepted')
|
|
231
|
+
assert(ri2.statusCode === 400, `array items: empty rejected (got ${ri2.statusCode})`)
|
|
232
|
+
assert(ri3.statusCode === 400, `array items: wrong type rejected (got ${ri3.statusCode})`)
|
|
233
|
+
await app9.close()
|
|
234
|
+
|
|
235
|
+
// 16. allOf composition
|
|
236
|
+
const app10 = fastify()
|
|
237
|
+
await app10.register(fastifyAta)
|
|
238
|
+
app10.post('/allof', {
|
|
239
|
+
schema: { body: { allOf: [
|
|
240
|
+
{ type: 'object', properties: { a: { type: 'string' } }, required: ['a'] },
|
|
241
|
+
{ type: 'object', properties: { b: { type: 'integer' } }, required: ['b'] },
|
|
242
|
+
] } },
|
|
243
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
244
|
+
await app10.ready()
|
|
245
|
+
const ra1 = await app10.inject({ method: 'POST', url: '/allof', payload: { a: 'x', b: 1 } })
|
|
246
|
+
const ra2 = await app10.inject({ method: 'POST', url: '/allof', payload: { a: 'x' } })
|
|
247
|
+
assert(ra1.statusCode === 200, 'allOf: both present accepted')
|
|
248
|
+
assert(ra2.statusCode === 400, `allOf: missing b rejected (got ${ra2.statusCode})`)
|
|
249
|
+
await app10.close()
|
|
250
|
+
|
|
251
|
+
// 17. anyOf composition
|
|
252
|
+
const app11 = fastify()
|
|
253
|
+
await app11.register(fastifyAta)
|
|
254
|
+
app11.post('/anyof', {
|
|
255
|
+
schema: { body: { anyOf: [{ type: 'string' }, { type: 'integer' }] } },
|
|
256
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
257
|
+
await app11.ready()
|
|
258
|
+
const rao1 = await app11.inject({ method: 'POST', url: '/anyof', payload: '"hello"', headers: { 'content-type': 'application/json' } })
|
|
259
|
+
const rao2 = await app11.inject({ method: 'POST', url: '/anyof', payload: '42', headers: { 'content-type': 'application/json' } })
|
|
260
|
+
const rao3 = await app11.inject({ method: 'POST', url: '/anyof', payload: 'true', headers: { 'content-type': 'application/json' } })
|
|
261
|
+
assert(rao1.statusCode === 200, 'anyOf: string accepted')
|
|
262
|
+
assert(rao2.statusCode === 200, 'anyOf: integer accepted')
|
|
263
|
+
assert(rao3.statusCode === 400, `anyOf: boolean rejected (got ${rao3.statusCode})`)
|
|
264
|
+
await app11.close()
|
|
265
|
+
|
|
266
|
+
// 18. format validation (email)
|
|
267
|
+
const app12 = fastify()
|
|
268
|
+
await app12.register(fastifyAta)
|
|
269
|
+
app12.post('/format', {
|
|
270
|
+
schema: { body: { type: 'object', properties: { email: { type: 'string', format: 'email' } }, required: ['email'] } },
|
|
271
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
272
|
+
await app12.ready()
|
|
273
|
+
const rf1 = await app12.inject({ method: 'POST', url: '/format', payload: { email: 'a@b.com' } })
|
|
274
|
+
const rf2 = await app12.inject({ method: 'POST', url: '/format', payload: { email: 'not-email' } })
|
|
275
|
+
assert(rf1.statusCode === 200, 'format: valid email accepted')
|
|
276
|
+
assert(rf2.statusCode === 400, `format: invalid email rejected (got ${rf2.statusCode})`)
|
|
277
|
+
await app12.close()
|
|
278
|
+
|
|
279
|
+
// 19. numeric constraints (minimum, maximum, exclusiveMinimum)
|
|
280
|
+
const app13 = fastify()
|
|
281
|
+
await app13.register(fastifyAta)
|
|
282
|
+
app13.post('/numeric', {
|
|
283
|
+
schema: { body: { type: 'object', properties: { age: { type: 'integer', minimum: 0, maximum: 150 }, score: { type: 'number', exclusiveMinimum: 0 } } } },
|
|
284
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
285
|
+
await app13.ready()
|
|
286
|
+
const rn1 = await app13.inject({ method: 'POST', url: '/numeric', payload: { age: 25, score: 0.1 } })
|
|
287
|
+
const rn2 = await app13.inject({ method: 'POST', url: '/numeric', payload: { age: -1 } })
|
|
288
|
+
const rn3 = await app13.inject({ method: 'POST', url: '/numeric', payload: { score: 0 } })
|
|
289
|
+
assert(rn1.statusCode === 200, 'numeric: valid accepted')
|
|
290
|
+
assert(rn2.statusCode === 400, `numeric: age < 0 rejected (got ${rn2.statusCode})`)
|
|
291
|
+
assert(rn3.statusCode === 400, `numeric: score = 0 rejected by exclusiveMinimum (got ${rn3.statusCode})`)
|
|
292
|
+
await app13.close()
|
|
293
|
+
|
|
294
|
+
// 20. $ref with $defs
|
|
295
|
+
const app14 = fastify()
|
|
296
|
+
await app14.register(fastifyAta)
|
|
297
|
+
app14.post('/ref', {
|
|
298
|
+
schema: { body: {
|
|
299
|
+
type: 'object',
|
|
300
|
+
properties: { address: { $ref: '#/$defs/Address' } },
|
|
301
|
+
$defs: { Address: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] } },
|
|
302
|
+
} },
|
|
303
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
304
|
+
await app14.ready()
|
|
305
|
+
const rr1 = await app14.inject({ method: 'POST', url: '/ref', payload: { address: { city: 'Istanbul' } } })
|
|
306
|
+
const rr2 = await app14.inject({ method: 'POST', url: '/ref', payload: { address: {} } })
|
|
307
|
+
assert(rr1.statusCode === 200, '$ref: valid address accepted')
|
|
308
|
+
assert(rr2.statusCode === 400, `$ref: missing city rejected (got ${rr2.statusCode})`)
|
|
309
|
+
await app14.close()
|
|
310
|
+
|
|
311
|
+
// 21. querystring validation
|
|
312
|
+
const app15 = fastify()
|
|
313
|
+
await app15.register(fastifyAta)
|
|
314
|
+
app15.get('/search', {
|
|
315
|
+
schema: { querystring: { type: 'object', properties: { q: { type: 'string', minLength: 1 }, page: { type: 'string' } }, required: ['q'] } },
|
|
316
|
+
}, (req, reply) => reply.send({ q: req.query.q }))
|
|
317
|
+
await app15.ready()
|
|
318
|
+
const rq1 = await app15.inject({ method: 'GET', url: '/search?q=hello&page=1' })
|
|
319
|
+
const rq2 = await app15.inject({ method: 'GET', url: '/search' })
|
|
320
|
+
assert(rq1.statusCode === 200, 'querystring: valid accepted')
|
|
321
|
+
assert(rq2.statusCode === 400, `querystring: missing q rejected (got ${rq2.statusCode})`)
|
|
322
|
+
await app15.close()
|
|
323
|
+
|
|
324
|
+
// 22. params validation
|
|
325
|
+
const app16 = fastify()
|
|
326
|
+
await app16.register(fastifyAta)
|
|
327
|
+
app16.get('/users/:id', {
|
|
328
|
+
schema: { params: { type: 'object', properties: { id: { type: 'string', minLength: 1 } }, required: ['id'] } },
|
|
329
|
+
}, (req, reply) => reply.send({ id: req.params.id }))
|
|
330
|
+
await app16.ready()
|
|
331
|
+
const rp1 = await app16.inject({ method: 'GET', url: '/users/42' })
|
|
332
|
+
assert(rp1.statusCode === 200, 'params: valid id accepted')
|
|
333
|
+
await app16.close()
|
|
334
|
+
|
|
335
|
+
// 23. headers validation
|
|
336
|
+
const app17 = fastify()
|
|
337
|
+
await app17.register(fastifyAta)
|
|
338
|
+
app17.get('/auth', {
|
|
339
|
+
schema: { headers: { type: 'object', properties: { 'x-api-key': { type: 'string', minLength: 10 } }, required: ['x-api-key'] } },
|
|
340
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
341
|
+
await app17.ready()
|
|
342
|
+
const rh1 = await app17.inject({ method: 'GET', url: '/auth', headers: { 'x-api-key': 'abcdefghij' } })
|
|
343
|
+
const rh2 = await app17.inject({ method: 'GET', url: '/auth', headers: {} })
|
|
344
|
+
assert(rh1.statusCode === 200, 'headers: valid api key accepted')
|
|
345
|
+
assert(rh2.statusCode === 400, `headers: missing api key rejected (got ${rh2.statusCode})`)
|
|
346
|
+
await app17.close()
|
|
347
|
+
|
|
192
348
|
await app.close()
|
|
193
349
|
await app2.close()
|
|
194
350
|
await app3.close()
|
|
195
351
|
|
|
352
|
+
/* Turbo mode tests removed - turbo mode deprecated */
|
|
353
|
+
/*
|
|
354
|
+
|
|
355
|
+
// 13. Turbo mode: plugin registers
|
|
356
|
+
const tApp = fastify()
|
|
357
|
+
await tApp.register(fastifyAta, { turbo: true })
|
|
358
|
+
tApp.post('/user', {
|
|
359
|
+
schema: {
|
|
360
|
+
body: {
|
|
361
|
+
type: 'object',
|
|
362
|
+
properties: {
|
|
363
|
+
name: { type: 'string', minLength: 1 },
|
|
364
|
+
age: { type: 'integer', minimum: 0 },
|
|
365
|
+
},
|
|
366
|
+
required: ['name'],
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
}, (req, reply) => {
|
|
370
|
+
reply.send({ ok: true, name: req.body.name })
|
|
371
|
+
})
|
|
372
|
+
await tApp.ready()
|
|
373
|
+
assert(true, 'turbo: plugin registers')
|
|
374
|
+
|
|
375
|
+
// 14. Turbo: valid request
|
|
376
|
+
const t1 = await tApp.inject({
|
|
377
|
+
method: 'POST',
|
|
378
|
+
url: '/user',
|
|
379
|
+
payload: { name: 'Mert', age: 26 },
|
|
380
|
+
})
|
|
381
|
+
assert(t1.statusCode === 200, `turbo: valid request returns 200 (got ${t1.statusCode})`)
|
|
382
|
+
assert(JSON.parse(t1.payload).name === 'Mert', 'turbo: valid request body correct')
|
|
383
|
+
|
|
384
|
+
// 15. Turbo: invalid request (missing required)
|
|
385
|
+
const t2 = await tApp.inject({
|
|
386
|
+
method: 'POST',
|
|
387
|
+
url: '/user',
|
|
388
|
+
payload: { age: 26 },
|
|
389
|
+
})
|
|
390
|
+
assert(t2.statusCode === 400, `turbo: missing required returns 400 (got ${t2.statusCode})`)
|
|
391
|
+
|
|
392
|
+
// 16. Turbo: invalid request (wrong type)
|
|
393
|
+
const t3 = await tApp.inject({
|
|
394
|
+
method: 'POST',
|
|
395
|
+
url: '/user',
|
|
396
|
+
payload: { name: 123 },
|
|
397
|
+
})
|
|
398
|
+
assert(t3.statusCode === 400, `turbo: wrong type returns 400 (got ${t3.statusCode})`)
|
|
399
|
+
|
|
400
|
+
// 17. Turbo: error message format
|
|
401
|
+
const tErr = JSON.parse(t2.payload)
|
|
402
|
+
assert(tErr.message && tErr.message.length > 0, `turbo: error message present: "${tErr.message}"`)
|
|
403
|
+
|
|
404
|
+
// 18. Turbo: malformed JSON body
|
|
405
|
+
const t4 = await tApp.inject({
|
|
406
|
+
method: 'POST',
|
|
407
|
+
url: '/user',
|
|
408
|
+
headers: { 'content-type': 'application/json' },
|
|
409
|
+
body: '{not valid json',
|
|
410
|
+
})
|
|
411
|
+
assert(t4.statusCode === 400, `turbo: malformed JSON returns 400 (got ${t4.statusCode})`)
|
|
412
|
+
|
|
413
|
+
// 19. Turbo: nested object validation
|
|
414
|
+
const tApp2 = fastify()
|
|
415
|
+
await tApp2.register(fastifyAta, { turbo: true })
|
|
416
|
+
tApp2.post('/nested', {
|
|
417
|
+
schema: {
|
|
418
|
+
body: {
|
|
419
|
+
type: 'object',
|
|
420
|
+
properties: {
|
|
421
|
+
user: {
|
|
422
|
+
type: 'object',
|
|
423
|
+
properties: { email: { type: 'string', format: 'email' } },
|
|
424
|
+
required: ['email'],
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
430
|
+
await tApp2.ready()
|
|
431
|
+
const t5 = await tApp2.inject({
|
|
432
|
+
method: 'POST',
|
|
433
|
+
url: '/nested',
|
|
434
|
+
payload: { user: {} },
|
|
435
|
+
})
|
|
436
|
+
assert(t5.statusCode === 400, `turbo: nested validation returns 400 (got ${t5.statusCode})`)
|
|
437
|
+
const tErr2 = JSON.parse(t5.payload)
|
|
438
|
+
assert(tErr2.message.includes('email'), `turbo: nested error mentions email: "${tErr2.message}"`)
|
|
439
|
+
await tApp2.close()
|
|
440
|
+
|
|
441
|
+
// 20. Turbo: multiple routes
|
|
442
|
+
const tApp3 = fastify()
|
|
443
|
+
await tApp3.register(fastifyAta, { turbo: true })
|
|
444
|
+
tApp3.post('/a', {
|
|
445
|
+
schema: { body: { type: 'object', properties: { x: { type: 'integer' } }, required: ['x'] } },
|
|
446
|
+
}, (req, reply) => reply.send({ route: 'a', x: req.body.x }))
|
|
447
|
+
tApp3.post('/b', {
|
|
448
|
+
schema: { body: { type: 'object', properties: { y: { type: 'string' } }, required: ['y'] } },
|
|
449
|
+
}, (req, reply) => reply.send({ route: 'b', y: req.body.y }))
|
|
450
|
+
await tApp3.ready()
|
|
451
|
+
const ta = await tApp3.inject({ method: 'POST', url: '/a', payload: { x: 42 } })
|
|
452
|
+
const tb = await tApp3.inject({ method: 'POST', url: '/b', payload: { y: 'hello' } })
|
|
453
|
+
assert(ta.statusCode === 200, 'turbo: multi-route /a valid')
|
|
454
|
+
assert(tb.statusCode === 200, 'turbo: multi-route /b valid')
|
|
455
|
+
assert(JSON.parse(ta.payload).x === 42, 'turbo: multi-route /a body correct')
|
|
456
|
+
assert(JSON.parse(tb.payload).y === 'hello', 'turbo: multi-route /b body correct')
|
|
457
|
+
await tApp3.close()
|
|
458
|
+
|
|
459
|
+
await tApp.close()
|
|
460
|
+
*/
|
|
461
|
+
|
|
196
462
|
console.log(`\n${pass}/${pass + fail} tests passed\n`)
|
|
197
463
|
process.exit(fail > 0 ? 1 : 0)
|
|
198
464
|
}
|