fastify
Version:
Fast and low overhead web framework, for Node.js
1,152 lines (1,024 loc) • 24 kB
JavaScript
'use strict'
const { test } = require('tap')
const Fastify = require('..')
const AJV = require('ajv')
const Schema = require('fluent-json-schema')
const customSchemaCompilers = {
body: new AJV({
coerceTypes: false
}),
params: new AJV({
coerceTypes: true
}),
querystring: new AJV({
coerceTypes: true
})
}
const customValidatorCompiler = req => {
if (!req.httpPart) {
throw new Error('Missing httpPart')
}
const compiler = customSchemaCompilers[req.httpPart]
if (!compiler) {
throw new Error(`Missing compiler for ${req.httpPart}`)
}
return compiler.compile(req.schema)
}
const schemaA = {
$id: 'urn:schema:foo',
type: 'object',
definitions: {
foo: { type: 'integer' }
},
properties: {
foo: { $ref: '#/definitions/foo' }
}
}
const schemaBRefToA = {
$id: 'urn:schema:response',
type: 'object',
required: ['foo'],
properties: {
foo: { $ref: 'urn:schema:foo#/definitions/foo' }
}
}
const schemaCRefToB = {
$id: 'urn:schema:request',
type: 'object',
required: ['foo'],
properties: {
foo: { $ref: 'urn:schema:response#/properties/foo' }
}
}
const schemaArtist = {
type: 'object',
properties: {
name: { type: 'string' },
work: { type: 'string' }
},
required: ['name', 'work']
}
test('Basic validation test', t => {
t.plan(6)
const fastify = Fastify()
fastify.post('/', {
schema: {
body: schemaArtist
}
}, function (req, reply) {
reply.code(200).send(req.body.name)
})
fastify.inject({
method: 'POST',
payload: {
name: 'michelangelo',
work: 'sculptor, painter, architect and poet'
},
url: '/'
}, (err, res) => {
t.error(err)
t.same(res.payload, 'michelangelo')
t.equal(res.statusCode, 200)
})
fastify.inject({
method: 'POST',
payload: { name: 'michelangelo' },
url: '/'
}, (err, res) => {
t.error(err)
t.same(res.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: "body must have required property 'work'" })
t.equal(res.statusCode, 400)
})
})
test('External AJV instance', t => {
t.plan(5)
const fastify = Fastify()
const ajv = new AJV()
ajv.addSchema(schemaA)
ajv.addSchema(schemaBRefToA)
// the user must provide the schemas to fastify also
fastify.addSchema(schemaA)
fastify.addSchema(schemaBRefToA)
fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {
t.pass('custom validator compiler called')
return ajv.compile(schema)
})
fastify.post('/', {
handler (req, reply) { reply.send({ foo: 1 }) },
schema: {
body: schemaCRefToB,
response: {
'2xx': ajv.getSchema('urn:schema:response').schema
}
}
})
fastify.inject({
method: 'POST',
url: '/',
payload: { foo: 42 }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
})
fastify.inject({
method: 'POST',
url: '/',
payload: { foo: 'not a number' }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
})
})
test('Encapsulation', t => {
t.plan(21)
const fastify = Fastify()
const ajv = new AJV()
ajv.addSchema(schemaA)
ajv.addSchema(schemaBRefToA)
// the user must provide the schemas to fastify also
fastify.addSchema(schemaA)
fastify.addSchema(schemaBRefToA)
fastify.register((instance, opts, done) => {
const validator = ({ schema, method, url, httpPart }) => {
t.pass('custom validator compiler called')
return ajv.compile(schema)
}
instance.setValidatorCompiler(validator)
instance.post('/one', {
handler (req, reply) { reply.send({ foo: 'one' }) },
schema: {
body: ajv.getSchema('urn:schema:response').schema
}
})
instance.register((instance, opts, done) => {
instance.post('/two', {
handler (req, reply) {
t.same(instance.validatorCompiler, validator)
reply.send({ foo: 'two' })
},
schema: {
body: ajv.getSchema('urn:schema:response').schema
}
})
const anotherValidator = ({ schema, method, url, httpPart }) => {
return () => { return true } // always valid
}
instance.post('/three', {
validatorCompiler: anotherValidator,
handler (req, reply) {
t.same(instance.validatorCompiler, validator, 'the route validator does not change the instance one')
reply.send({ foo: 'three' })
},
schema: {
body: ajv.getSchema('urn:schema:response').schema
}
})
done()
})
done()
})
fastify.register((instance, opts, done) => {
instance.post('/clean', function (req, reply) {
t.equal(instance.validatorCompiler, undefined)
reply.send({ foo: 'bar' })
})
done()
})
fastify.inject({
method: 'POST',
url: '/one',
payload: { foo: 1 }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.same(res.json(), { foo: 'one' })
})
fastify.inject({
method: 'POST',
url: '/one',
payload: { wrongFoo: 'bar' }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
})
fastify.inject({
method: 'POST',
url: '/two',
payload: { foo: 2 }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.same(res.json(), { foo: 'two' })
})
fastify.inject({
method: 'POST',
url: '/two',
payload: { wrongFoo: 'bar' }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
})
fastify.inject({
method: 'POST',
url: '/three',
payload: { wrongFoo: 'but works' }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.same(res.json(), { foo: 'three' })
})
fastify.inject({
method: 'POST',
url: '/clean',
payload: { wrongFoo: 'bar' }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.same(res.json(), { foo: 'bar' })
})
})
test('Triple $ref with a simple $id', t => {
t.plan(7)
const fastify = Fastify()
const ajv = new AJV()
ajv.addSchema(schemaA)
ajv.addSchema(schemaBRefToA)
ajv.addSchema(schemaCRefToB)
// the user must provide the schemas to fastify also
fastify.addSchema(schemaA)
fastify.addSchema(schemaBRefToA)
fastify.addSchema(schemaCRefToB)
fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {
t.pass('custom validator compiler called')
return ajv.compile(schema)
})
fastify.post('/', {
handler (req, reply) { reply.send({ foo: 105, bar: 'foo' }) },
schema: {
body: ajv.getSchema('urn:schema:request').schema,
response: {
'2xx': ajv.getSchema('urn:schema:response').schema
}
}
})
fastify.inject({
method: 'POST',
url: '/',
payload: { foo: 43 }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.same(res.json(), { foo: 105 })
})
fastify.inject({
method: 'POST',
url: '/',
payload: { fool: 'bar' }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
t.same(res.json().message, "body must have required property 'foo'")
})
})
test('Extending schema', t => {
t.plan(4)
const fastify = Fastify()
fastify.addSchema({
$id: 'address.id',
type: 'object',
definitions: {
address: {
type: 'object',
properties: {
city: { type: 'string' },
state: { type: 'string' }
},
required: ['city', 'state']
}
}
})
fastify.post('/', {
handler (req, reply) { reply.send('works') },
schema: {
body: {
type: 'object',
properties: {
billingAddress: { $ref: 'address.id#/definitions/address' },
shippingAddress: {
allOf: [
{ $ref: 'address.id#/definitions/address' },
{
type: 'object',
properties: { type: { enum: ['residential', 'business'] } },
required: ['type']
}
]
}
}
}
}
})
fastify.inject({
method: 'POST',
url: '/',
payload: {
shippingAddress: {
city: 'Forlì',
state: 'FC'
}
}
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
})
fastify.inject({
method: 'POST',
url: '/',
payload: {
shippingAddress: {
city: 'Forlì',
state: 'FC',
type: 'business'
}
}
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
})
})
test('Should work with nested ids', t => {
t.plan(6)
const fastify = Fastify()
fastify.addSchema({
$id: 'test',
type: 'object',
properties: {
id: { type: 'number' }
}
})
fastify.addSchema({
$id: 'greetings',
type: 'string'
})
fastify.post('/:id', {
handler (req, reply) { reply.send(typeof req.params.id) },
schema: {
params: { $ref: 'test#' },
body: {
type: 'object',
properties: {
hello: { $ref: 'greetings#' }
}
}
}
})
fastify.inject({
method: 'POST',
url: '/123',
payload: {
hello: 'world'
}
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.equal(res.payload, 'number')
})
fastify.inject({
method: 'POST',
url: '/abc',
payload: {
hello: 'world'
}
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
t.equal(res.json().message, 'params/id must be number')
})
})
test('Use the same schema across multiple routes', t => {
t.plan(8)
const fastify = Fastify()
fastify.addSchema({
$id: 'test',
type: 'object',
properties: {
id: { type: 'number' }
}
})
fastify.get('/first/:id', {
handler (req, reply) { reply.send(typeof req.params.id) },
schema: {
params: { $ref: 'test#' }
}
})
fastify.get('/second/:id', {
handler (req, reply) { reply.send(typeof req.params.id) },
schema: {
params: { $ref: 'test#' }
}
})
;[
'/first/123',
'/second/123'
].forEach(url => {
fastify.inject({
url,
method: 'GET'
}, (err, res) => {
t.error(err)
t.equal(res.payload, 'number')
})
})
;[
'/first/abc',
'/second/abc'
].forEach(url => {
fastify.inject({
url,
method: 'GET'
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
})
})
})
test('JSON Schema validation keywords', t => {
t.plan(6)
const fastify = Fastify()
fastify.addSchema({
$id: 'test',
type: 'object',
properties: {
ip: {
type: 'string',
format: 'ipv4'
}
}
})
fastify.get('/:ip', {
handler (req, reply) { reply.send(typeof req.params.ip) },
schema: {
params: { $ref: 'test#' }
}
})
fastify.inject({
method: 'GET',
url: '/127.0.0.1'
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.equal(res.payload, 'string')
})
fastify.inject({
method: 'GET',
url: '/localhost'
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
t.same(res.json(), {
statusCode: 400,
code: 'FST_ERR_VALIDATION',
error: 'Bad Request',
message: 'params/ip must match format "ipv4"'
})
})
})
test('Nested id calls', t => {
t.plan(6)
const fastify = Fastify()
fastify.addSchema({
$id: 'test',
type: 'object',
properties: {
ip: {
type: 'string',
format: 'ipv4'
}
}
})
fastify.addSchema({
$id: 'hello',
type: 'object',
properties: {
host: { $ref: 'test#' }
}
})
fastify.post('/', {
handler (req, reply) { reply.send(typeof req.body.host.ip) },
schema: {
body: { $ref: 'hello#' }
}
})
fastify.inject({
method: 'POST',
url: '/',
payload: { host: { ip: '127.0.0.1' } }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.equal(res.payload, 'string')
})
fastify.inject({
method: 'POST',
url: '/',
payload: { host: { ip: 'localhost' } }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
t.same(res.json(), {
error: 'Bad Request',
message: 'body/host/ip must match format "ipv4"',
statusCode: 400,
code: 'FST_ERR_VALIDATION'
})
})
})
test('Use the same schema id in different places', t => {
t.plan(2)
const fastify = Fastify()
fastify.addSchema({
$id: 'test',
type: 'object',
properties: {
id: { type: 'number' }
}
})
fastify.post('/', {
handler (req, reply) { reply.send({ id: req.body.id / 2 }) },
schema: {
body: { $ref: 'test#' },
response: {
200: { $ref: 'test#' }
}
}
})
fastify.inject({
method: 'POST',
url: '/',
payload: { id: 42 }
}, (err, res) => {
t.error(err)
t.same(res.json(), { id: 21 })
})
})
test('Use shared schema and $ref with $id ($ref to $id)', t => {
t.plan(5)
const fastify = Fastify()
fastify.addSchema({
$id: 'http://foo/test',
type: 'object',
properties: {
id: { type: 'number' }
}
})
const body = {
$id: 'http://foo/user',
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
definitions: {
address: {
$id: '#address',
type: 'object',
properties: {
city: { type: 'string' }
}
}
},
required: ['address'],
properties: {
test: { $ref: 'http://foo/test#' }, // to external
address: { $ref: '#address' } // to local
}
}
fastify.post('/', {
handler (req, reply) { reply.send(req.body.test) },
schema: {
body,
response: {
200: { $ref: 'http://foo/test#' }
}
}
})
const id = Date.now()
fastify.inject({
method: 'POST',
url: '/',
payload: {
address: { city: 'New Node' },
test: { id }
}
}, (err, res) => {
t.error(err)
t.same(res.json(), { id })
})
fastify.inject({
method: 'POST',
url: '/',
payload: { test: { id } }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
t.same(res.json(), {
error: 'Bad Request',
message: "body must have required property 'address'",
statusCode: 400,
code: 'FST_ERR_VALIDATION'
})
})
})
test('Use items with $ref', t => {
t.plan(4)
const fastify = Fastify()
fastify.addSchema({
$id: 'http://example.com/ref-to-external-validator.json',
type: 'object',
properties: {
hello: { type: 'string' }
}
})
const body = {
type: 'array',
items: { $ref: 'http://example.com/ref-to-external-validator.json#' }
}
fastify.post('/', {
schema: { body },
handler: (_, r) => { r.send('ok') }
})
fastify.inject({
method: 'POST',
url: '/',
payload: [{ hello: 'world' }]
}, (err, res) => {
t.error(err)
t.equal(res.payload, 'ok')
})
fastify.inject({
method: 'POST',
url: '/',
payload: { hello: 'world' }
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
})
})
test('Use $ref to /definitions', t => {
t.plan(6)
const fastify = Fastify()
fastify.addSchema({
$id: 'test',
type: 'object',
properties: {
id: { type: 'number' }
}
})
const body = {
type: 'object',
definitions: {
address: {
$id: '#otherId',
type: 'object',
properties: {
city: { type: 'string' }
}
}
},
properties: {
test: { $ref: 'test#' },
address: { $ref: '#/definitions/address' }
},
required: ['address', 'test']
}
fastify.post('/', {
schema: {
body,
response: {
200: body
}
},
handler: (req, reply) => {
req.body.removeThis = 'it should not be serialized'
reply.send(req.body)
}
})
const payload = {
address: { city: 'New Node' },
test: { id: Date.now() }
}
fastify.inject({
method: 'POST',
url: '/',
payload
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.same(res.json(), payload)
})
fastify.inject({
method: 'POST',
url: '/',
payload: {
address: { city: 'New Node' },
test: { id: 'wrong' }
}
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
t.same(res.json(), {
error: 'Bad Request',
message: 'body/test/id must be number',
statusCode: 400,
code: 'FST_ERR_VALIDATION'
})
})
})
test('Custom AJV settings - pt1', t => {
t.plan(4)
const fastify = Fastify()
fastify.post('/', {
schema: {
body: { num: { type: 'integer' } }
},
handler: (req, reply) => {
t.equal(req.body.num, 12)
reply.send(req.body)
}
})
fastify.inject({
method: 'POST',
url: '/',
payload: {
num: '12'
}
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 200)
t.same(res.json(), { num: 12 })
})
})
test('Custom AJV settings - pt2', t => {
t.plan(2)
const fastify = Fastify({
ajv: {
customOptions: {
coerceTypes: false
}
}
})
fastify.post('/', {
schema: {
body: { num: { type: 'integer' } }
},
handler: (req, reply) => {
t.fail('the handler is not called because the "12" is not coerced to number')
}
})
fastify.inject({
method: 'POST',
url: '/',
payload: {
num: '12'
}
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
})
})
test('Custom AJV settings on different parameters - pt1', t => {
t.plan(2)
const fastify = Fastify()
fastify.setValidatorCompiler(customValidatorCompiler)
fastify.post('/api/:id', {
schema: {
querystring: { id: { type: 'integer' } },
body: {
type: 'object',
properties: {
num: { type: 'number' }
},
required: ['num']
}
},
handler: (req, reply) => {
t.fail('the handler is not called because the "12" is not coerced to number')
}
})
fastify.inject({
method: 'POST',
url: '/api/42',
payload: {
num: '12'
}
}, (err, res) => {
t.error(err)
t.equal(res.statusCode, 400)
})
})
test('Custom AJV settings on different parameters - pt2', t => {
t.plan(4)
const fastify = Fastify()
fastify.setValidatorCompiler(customValidatorCompiler)
fastify.post('/api/:id', {
schema: {
params: {
type: 'object',
properties: {
id: { type: 'number' }
},
required: ['id']
},
body: {
type: 'object',
properties: {
num: { type: 'number' }
},
required: ['num']
}
},
handler: (req, reply) => {
t.same(typeof req.params.id, 'number')
t.same(typeof req.body.num, 'number')
t.same(req.params.id, 42)
t.same(req.body.num, 12)
}
})
fastify.inject({
method: 'POST',
url: '/api/42',
payload: {
num: 12
}
})
})
test("The same $id in route's schema must not overwrite others", t => {
t.plan(4)
const fastify = Fastify()
const UserSchema = Schema.object()
.id('http://mydomain.com/user')
.title('User schema')
.description('Contains all user fields')
.prop('id', Schema.integer())
.prop('username', Schema.string().minLength(4))
.prop('firstName', Schema.string().minLength(1))
.prop('lastName', Schema.string().minLength(1))
.prop('fullName', Schema.string().minLength(1))
.prop('email', Schema.string())
.prop('password', Schema.string().minLength(6))
.prop('bio', Schema.string())
const userCreateSchema = UserSchema.only([
'username',
'firstName',
'lastName',
'email',
'bio',
'password',
'password_confirm'
])
.required([
'username',
'firstName',
'lastName',
'email',
'bio',
'password'
])
const userPatchSchema = UserSchema.only([
'firstName',
'lastName',
'bio'
])
fastify
.patch('/user/:id', {
schema: { body: userPatchSchema },
handler: () => { return 'ok' }
})
.post('/user', {
schema: { body: userCreateSchema },
handler: () => { return 'ok' }
})
fastify.inject({
method: 'POST',
url: '/user',
body: {}
}, (err, res) => {
t.error(err)
t.same(res.json().message, "body must have required property 'username'")
})
fastify.inject({
url: '/user/1',
method: 'PATCH',
body: {}
}, (err, res) => {
t.error(err)
t.same(res.payload, 'ok')
})
})
test('Custom validator compiler should not mutate schema', async t => {
t.plan(2)
class Headers { }
const fastify = Fastify()
fastify.setValidatorCompiler(({ schema, method, url, httpPart }) => {
t.type(schema, Headers)
return () => { }
})
fastify.get('/', {
schema: {
headers: new Headers()
}
}, () => { })
await fastify.ready()
})
test('Custom validator builder override by custom validator compiler', async t => {
t.plan(3)
const ajvDefaults = {
removeAdditional: true,
coerceTypes: true,
allErrors: true
}
const ajv1 = new AJV(ajvDefaults).addKeyword({ keyword: 'extended_one', type: 'object', validator: () => true })
const ajv2 = new AJV(ajvDefaults).addKeyword({ keyword: 'extended_two', type: 'object', validator: () => true })
const fastify = Fastify({ schemaController: { compilersFactory: { buildValidator: () => (routeSchemaDef) => ajv1.compile(routeSchemaDef.schema) } } })
fastify.setValidatorCompiler((routeSchemaDef) => ajv2.compile(routeSchemaDef.schema))
fastify.post('/two/:id', {
schema: {
params: {
type: 'object',
extended_two: true,
properties: {
id: { type: 'number' }
},
required: ['id']
}
},
handler: (req, _reply) => {
t.same(typeof req.params.id, 'number')
t.same(req.params.id, 43)
return 'ok'
}
})
await fastify.ready()
const two = await fastify.inject({
method: 'POST',
url: '/two/43'
})
t.equal(two.statusCode, 200)
})
test('Custom validator builder override by custom validator compiler in child instance', async t => {
t.plan(6)
const ajvDefaults = {
removeAdditional: true,
coerceTypes: true,
allErrors: true
}
const ajv1 = new AJV(ajvDefaults).addKeyword({ keyword: 'extended_one', type: 'object', validator: () => true })
const ajv2 = new AJV(ajvDefaults).addKeyword({ keyword: 'extended_two', type: 'object', validator: () => true })
const fastify = Fastify({ schemaController: { compilersFactory: { buildValidator: () => (routeSchemaDef) => ajv1.compile(routeSchemaDef.schema) } } })
fastify.register((embedded, _opts, done) => {
embedded.setValidatorCompiler((routeSchemaDef) => ajv2.compile(routeSchemaDef.schema))
embedded.post('/two/:id', {
schema: {
params: {
type: 'object',
extended_two: true,
properties: {
id: { type: 'number' }
},
required: ['id']
}
},
handler: (req, _reply) => {
t.same(typeof req.params.id, 'number')
t.same(req.params.id, 43)
return 'ok'
}
})
done()
})
fastify.post('/one/:id', {
schema: {
params: {
type: 'object',
extended_one: true,
properties: {
id: { type: 'number' }
},
required: ['id']
}
},
handler: (req, _reply) => {
t.same(typeof req.params.id, 'number')
t.same(req.params.id, 42)
return 'ok'
}
})
await fastify.ready()
const one = await fastify.inject({
method: 'POST',
url: '/one/42'
})
t.equal(one.statusCode, 200)
const two = await fastify.inject({
method: 'POST',
url: '/two/43'
})
t.equal(two.statusCode, 200)
})