UNPKG

fastify

Version:

Fast and low overhead web framework, for Node.js

1,316 lines (1,191 loc) 27.1 kB
'use strict' const { test } = require('tap') const Joi = require('joi') const yup = require('yup') const AJV = require('ajv') const S = require('fluent-json-schema') const Fastify = require('..') const ajvMergePatch = require('ajv-merge-patch') const ajvErrors = require('ajv-errors') test('Ajv plugins array parameter', t => { t.plan(3) const fastify = Fastify({ ajv: { customOptions: { allErrors: true }, plugins: [ [ajvErrors, { singleError: '@@@@' }] ] } }) fastify.post('/', { schema: { body: { type: 'object', properties: { foo: { type: 'number', minimum: 2, maximum: 10, multipleOf: 2, errorMessage: { type: 'should be number', minimum: 'should be >= 2', maximum: 'should be <= 10', multipleOf: 'should be multipleOf 2' } } } } }, handler (req, reply) { reply.send({ ok: 1 }) } }) fastify.inject({ method: 'POST', url: '/', payload: { foo: 99 } }, (err, res) => { t.error(err) t.equal(res.statusCode, 400) t.equal(res.json().message, 'body/foo should be <= 10@@@@should be multipleOf 2') }) }) test('Should handle root $merge keywords in header', t => { t.plan(5) const fastify = Fastify({ ajv: { plugins: [ ajvMergePatch ] } }) fastify.route({ method: 'GET', url: '/', schema: { headers: { $merge: { source: { type: 'object', properties: { q: { type: 'string' } } }, with: { required: ['q'] } } } }, handler (req, reply) { reply.send({ ok: 1 }) } }) fastify.ready(err => { t.error(err) fastify.inject({ method: 'GET', url: '/' }, (err, res) => { t.error(err) t.equal(res.statusCode, 400) }) fastify.inject({ method: 'GET', url: '/', headers: { q: 'foo' } }, (err, res) => { t.error(err) t.equal(res.statusCode, 200) }) }) }) test('Should handle root $patch keywords in header', t => { t.plan(5) const fastify = Fastify({ ajv: { plugins: [ ajvMergePatch ] } }) fastify.route({ method: 'GET', url: '/', schema: { headers: { $patch: { source: { type: 'object', properties: { q: { type: 'string' } } }, with: [ { op: 'add', path: '/properties/q', value: { type: 'number' } } ] } } }, handler (req, reply) { reply.send({ ok: 1 }) } }) fastify.ready(err => { t.error(err) fastify.inject({ method: 'GET', url: '/', headers: { q: 'foo' } }, (err, res) => { t.error(err) t.equal(res.statusCode, 400) }) fastify.inject({ method: 'GET', url: '/', headers: { q: 10 } }, (err, res) => { t.error(err) t.equal(res.statusCode, 200) }) }) }) test('Should handle $merge keywords in body', t => { t.plan(5) const fastify = Fastify({ ajv: { plugins: [ajvMergePatch] } }) fastify.post('/', { schema: { body: { $merge: { source: { type: 'object', properties: { q: { type: 'string' } } }, with: { required: ['q'] } } } }, handler (req, reply) { reply.send({ ok: 1 }) } }) fastify.ready(err => { t.error(err) fastify.inject({ method: 'POST', url: '/' }, (err, res) => { t.error(err) t.equal(res.statusCode, 400) }) fastify.inject({ method: 'POST', url: '/', payload: { q: 'foo' } }, (err, res) => { t.error(err) t.equal(res.statusCode, 200) }) }) }) test('Should handle $patch keywords in body', t => { t.plan(5) const fastify = Fastify({ ajv: { plugins: [ajvMergePatch] } }) fastify.post('/', { schema: { body: { $patch: { source: { type: 'object', properties: { q: { type: 'string' } } }, with: [ { op: 'add', path: '/properties/q', value: { type: 'number' } } ] } } }, handler (req, reply) { reply.send({ ok: 1 }) } }) fastify.ready(err => { t.error(err) fastify.inject({ method: 'POST', url: '/', payload: { q: 'foo' } }, (err, res) => { t.error(err) t.equal(res.statusCode, 400) }) fastify.inject({ method: 'POST', url: '/', payload: { q: 10 } }, (err, res) => { t.error(err) t.equal(res.statusCode, 200) }) }) }) test("serializer read validator's schemas", t => { t.plan(4) const ajvInstance = new AJV() const baseSchema = { $id: 'http://example.com/schemas/base', definitions: { hello: { type: 'string' } }, type: 'object', properties: { hello: { $ref: '#/definitions/hello' } } } const refSchema = { $id: 'http://example.com/schemas/ref', type: 'object', properties: { hello: { $ref: 'http://example.com/schemas/base#/definitions/hello' } } } ajvInstance.addSchema(baseSchema) ajvInstance.addSchema(refSchema) const fastify = Fastify({ schemaController: { bucket: function factory (storeInit) { t.notOk(storeInit, 'is always empty because fastify.addSchema is not called') return { getSchemas () { return { [baseSchema.$id]: ajvInstance.getSchema(baseSchema.$id).schema, [refSchema.$id]: ajvInstance.getSchema(refSchema.$id).schema } } } } } }) fastify.setValidatorCompiler(function ({ schema }) { return ajvInstance.compile(schema) }) fastify.get('/', { schema: { response: { '2xx': ajvInstance.getSchema('http://example.com/schemas/ref').schema } }, handler (req, res) { res.send({ hello: 'world', evict: 'this' }) } }) fastify.inject('/', (err, res) => { t.error(err) t.equal(res.statusCode, 200) t.same(res.json(), { hello: 'world' }) }) }) test('setSchemaController in a plugin', t => { t.plan(5) const baseSchema = { $id: 'urn:schema:base', definitions: { hello: { type: 'string' } }, type: 'object', properties: { hello: { $ref: '#/definitions/hello' } } } const refSchema = { $id: 'urn:schema:ref', type: 'object', properties: { hello: { $ref: 'urn:schema:base#/definitions/hello' } } } const ajvInstance = new AJV() ajvInstance.addSchema(baseSchema) ajvInstance.addSchema(refSchema) const fastify = Fastify({ exposeHeadRoutes: false }) fastify.register(schemaPlugin) fastify.get('/', { schema: { query: ajvInstance.getSchema('urn:schema:ref').schema, response: { '2xx': ajvInstance.getSchema('urn:schema:ref').schema } }, handler (req, res) { res.send({ hello: 'world', evict: 'this' }) } }) fastify.inject('/', (err, res) => { t.error(err) t.equal(res.statusCode, 200) t.same(res.json(), { hello: 'world' }) }) async function schemaPlugin (server) { server.setSchemaController({ bucket () { t.pass('the bucket is created') return { addSchema (source) { ajvInstance.addSchema(source) }, getSchema (id) { return ajvInstance.getSchema(id).schema }, getSchemas () { return { 'urn:schema:base': baseSchema, 'urn:schema:ref': refSchema } } } } }) server.setValidatorCompiler(function ({ schema }) { t.pass('the querystring schema is compiled') return ajvInstance.compile(schema) }) } schemaPlugin[Symbol.for('skip-override')] = true }) test('side effect on schema let the server crash', async t => { const firstSchema = { $id: 'example1', type: 'object', properties: { name: { type: 'string' } } } const reusedSchema = { $id: 'example2', type: 'object', properties: { name: { oneOf: [ { $ref: 'example1' } ] } } } const fastify = Fastify() fastify.addSchema(firstSchema) fastify.post('/a', { handler: async () => 'OK', schema: { body: reusedSchema, response: { 200: reusedSchema } } }) fastify.post('/b', { handler: async () => 'OK', schema: { body: reusedSchema, response: { 200: reusedSchema } } }) await fastify.ready() }) test('only response schema trigger AJV pollution', async t => { const ShowSchema = S.object().id('ShowSchema').prop('name', S.string()) const ListSchema = S.array().id('ListSchema').items(S.ref('ShowSchema#')) const fastify = Fastify() fastify.addSchema(ListSchema) fastify.addSchema(ShowSchema) const routeResponseSchemas = { schema: { response: { 200: S.ref('ListSchema#') } } } fastify.register( async (app) => { app.get('/resource/', routeResponseSchemas, () => ({})) }, { prefix: '/prefix1' } ) fastify.register( async (app) => { app.get('/resource/', routeResponseSchemas, () => ({})) }, { prefix: '/prefix2' } ) await fastify.ready() }) test('only response schema trigger AJV pollution #2', async t => { const ShowSchema = S.object().id('ShowSchema').prop('name', S.string()) const ListSchema = S.array().id('ListSchema').items(S.ref('ShowSchema#')) const fastify = Fastify() fastify.addSchema(ListSchema) fastify.addSchema(ShowSchema) const routeResponseSchemas = { schema: { params: S.ref('ListSchema#'), response: { 200: S.ref('ListSchema#') } } } fastify.register( async (app) => { app.get('/resource/', routeResponseSchemas, () => ({})) }, { prefix: '/prefix1' } ) fastify.register( async (app) => { app.get('/resource/', routeResponseSchemas, () => ({})) }, { prefix: '/prefix2' } ) await fastify.ready() }) test('setSchemaController in a plugin with head routes', t => { t.plan(6) const baseSchema = { $id: 'urn:schema:base', definitions: { hello: { type: 'string' } }, type: 'object', properties: { hello: { $ref: '#/definitions/hello' } } } const refSchema = { $id: 'urn:schema:ref', type: 'object', properties: { hello: { $ref: 'urn:schema:base#/definitions/hello' } } } const ajvInstance = new AJV() ajvInstance.addSchema(baseSchema) ajvInstance.addSchema(refSchema) const fastify = Fastify({ exposeHeadRoutes: true }) fastify.register(schemaPlugin) fastify.get('/', { schema: { query: ajvInstance.getSchema('urn:schema:ref').schema, response: { '2xx': ajvInstance.getSchema('urn:schema:ref').schema } }, handler (req, res) { res.send({ hello: 'world', evict: 'this' }) } }) fastify.inject('/', (err, res) => { t.error(err) t.equal(res.statusCode, 200) t.same(res.json(), { hello: 'world' }) }) async function schemaPlugin (server) { server.setSchemaController({ bucket () { t.pass('the bucket is created') return { addSchema (source) { ajvInstance.addSchema(source) }, getSchema (id) { return ajvInstance.getSchema(id).schema }, getSchemas () { return { 'urn:schema:base': baseSchema, 'urn:schema:ref': refSchema } } } } }) server.setValidatorCompiler(function ({ schema }) { if (schema.$id) { const stored = ajvInstance.getSchema(schema.$id) if (stored) { t.pass('the schema is reused') return stored } } t.pass('the schema is compiled') return ajvInstance.compile(schema) }) } schemaPlugin[Symbol.for('skip-override')] = true }) test('multiple refs with the same ids', t => { t.plan(3) const baseSchema = { $id: 'urn:schema:base', definitions: { hello: { type: 'string' } }, type: 'object', properties: { hello: { $ref: '#/definitions/hello' } } } const refSchema = { $id: 'urn:schema:ref', type: 'object', properties: { hello: { $ref: 'urn:schema:base#/definitions/hello' } } } const fastify = Fastify() fastify.addSchema(baseSchema) fastify.addSchema(refSchema) fastify.head('/', { schema: { query: refSchema, response: { '2xx': refSchema } }, handler (req, res) { res.send({ hello: 'world', evict: 'this' }) } }) fastify.get('/', { schema: { query: refSchema, response: { '2xx': refSchema } }, handler (req, res) { res.send({ hello: 'world', evict: 'this' }) } }) fastify.inject('/', (err, res) => { t.error(err) t.equal(res.statusCode, 200) t.same(res.json(), { hello: 'world' }) }) }) test('JOI validation overwrite request headers', t => { t.plan(3) const schemaValidator = ({ schema }) => data => { const validationResult = schema.validate(data) return validationResult } const fastify = Fastify() fastify.setValidatorCompiler(schemaValidator) fastify.get('/', { schema: { headers: Joi.object({ 'user-agent': Joi.string().required(), host: Joi.string().required() }) } }, (request, reply) => { reply.send(request.headers) }) fastify.inject('/', (err, res) => { t.error(err) t.equal(res.statusCode, 200) t.same(res.json(), { 'user-agent': 'lightMyRequest', host: 'localhost:80' }) }) }) test('Custom schema object should not trigger FST_ERR_SCH_DUPLICATE', async t => { const fastify = Fastify() const handler = () => { } fastify.get('/the/url', { schema: { query: yup.object({ foo: yup.string() }) }, validatorCompiler: ({ schema, method, url, httpPart }) => { return function (data) { // with option strict = false, yup `validateSync` function returns the coerced value if validation was successful, or throws if validation failed try { const result = schema.validateSync(data, {}) return { value: result } } catch (e) { return { error: e } } } }, handler }) await fastify.ready() t.pass('fastify is ready') }) test('The default schema compilers should not be called when overwritten by the user', async t => { const Fastify = t.mock('../', { '@fastify/ajv-compiler': () => { t.fail('The default validator compiler should not be called') }, '@fastify/fast-json-stringify-compiler': () => { t.fail('The default serializer compiler should not be called') } }) const fastify = Fastify({ schemaController: { compilersFactory: { buildValidator: function factory () { t.pass('The custom validator compiler should be called') return function validatorCompiler () { return () => { return true } } }, buildSerializer: function factory () { t.pass('The custom serializer compiler should be called') return function serializerCompiler () { return () => { return true } } } } } }) fastify.get('/', { schema: { query: { foo: { type: 'string' } }, response: { 200: { type: 'object' } } } }, () => {}) await fastify.ready() }) test('Supports async JOI validation', t => { t.plan(7) const schemaValidator = ({ schema }) => async data => { const validationResult = await schema.validateAsync(data) return validationResult } const fastify = Fastify({ exposeHeadRoutes: false }) fastify.setValidatorCompiler(schemaValidator) fastify.get('/', { schema: { headers: Joi.object({ 'user-agent': Joi.string().external(async (val) => { if (val !== 'lightMyRequest') { throw new Error('Invalid user-agent') } t.equal(val, 'lightMyRequest') return val }), host: Joi.string().required() }) } }, (request, reply) => { reply.send(request.headers) }) fastify.inject('/', (err, res) => { t.error(err) t.equal(res.statusCode, 200) t.same(res.json(), { 'user-agent': 'lightMyRequest', host: 'localhost:80' }) }) fastify.inject({ url: '/', headers: { 'user-agent': 'invalid' } }, (err, res) => { t.error(err) t.equal(res.statusCode, 400) t.same(res.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'Invalid user-agent (user-agent)' }) }) }) test('Supports async AJV validation', t => { t.plan(12) const fastify = Fastify({ exposeHeadRoutes: false, ajv: { customOptions: { allErrors: true, keywords: [ { keyword: 'idExists', async: true, type: 'number', validate: checkIdExists } ] }, plugins: [ [ajvErrors, { singleError: '@@@@' }] ] } }) async function checkIdExists (schema, data) { const res = await Promise.resolve(data) switch (res) { case 42: return true case 500: throw new Error('custom error') default: return false } } const schema = { $async: true, type: 'object', properties: { userId: { type: 'integer', idExists: { table: 'users' } }, postId: { type: 'integer', idExists: { table: 'posts' } } } } fastify.post('/', { schema: { body: schema }, handler (req, reply) { reply.send(req.body) } }) fastify.inject({ method: 'POST', url: '/', payload: { userId: 99 } }, (err, res) => { t.error(err) t.equal(res.statusCode, 400) t.same(res.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'validation failed' }) }) fastify.inject({ method: 'POST', url: '/', payload: { userId: 500 } }, (err, res) => { t.error(err) t.equal(res.statusCode, 400) t.same(res.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'custom error' }) }) fastify.inject({ method: 'POST', url: '/', payload: { userId: 42 } }, (err, res) => { t.error(err) t.equal(res.statusCode, 200) t.same(res.json(), { userId: 42 }) }) fastify.inject({ method: 'POST', url: '/', payload: { userId: 42, postId: 19 } }, (err, res) => { t.error(err) t.equal(res.statusCode, 400) t.same(res.json(), { statusCode: 400, code: 'FST_ERR_VALIDATION', error: 'Bad Request', message: 'validation failed' }) }) }) test('Check all the async AJV validation paths', t => { const fastify = Fastify({ exposeHeadRoutes: false, ajv: { customOptions: { allErrors: true, keywords: [ { keyword: 'idExists', async: true, type: 'number', validate: checkIdExists } ] } } }) async function checkIdExists (schema, data) { const res = await Promise.resolve(data) switch (res) { case 200: return true default: return false } } const schema = { $async: true, type: 'object', properties: { id: { type: 'integer', idExists: { table: 'posts' } } } } fastify.post('/:id', { schema: { params: schema, body: schema, query: schema, headers: schema }, handler (req, reply) { reply.send(req.body) } }) const testCases = [ { params: 400, body: 200, querystring: 200, headers: 200, response: 400 }, { params: 200, body: 400, querystring: 200, headers: 200, response: 400 }, { params: 200, body: 200, querystring: 400, headers: 200, response: 400 }, { params: 200, body: 200, querystring: 200, headers: 400, response: 400 }, { params: 200, body: 200, querystring: 200, headers: 200, response: 200 } ] t.plan(testCases.length * 2) testCases.forEach(validate) function validate ({ params, body, querystring, headers, response }) { fastify.inject({ method: 'POST', url: `/${params}`, headers: { id: headers }, query: { id: querystring }, payload: { id: body } }, (err, res) => { t.error(err) t.equal(res.statusCode, response) }) } }) test('Check mixed sync and async AJV validations', t => { const fastify = Fastify({ exposeHeadRoutes: false, ajv: { customOptions: { allErrors: true, keywords: [ { keyword: 'idExists', async: true, type: 'number', validate: checkIdExists } ] } } }) async function checkIdExists (schema, data) { const res = await Promise.resolve(data) switch (res) { case 200: return true default: return false } } const schemaSync = { type: 'object', properties: { id: { type: 'integer' } } } const schemaAsync = { $async: true, type: 'object', properties: { id: { type: 'integer', idExists: { table: 'posts' } } } } fastify.post('/queryAsync/:id', { schema: { params: schemaSync, body: schemaSync, query: schemaAsync, headers: schemaSync }, handler (req, reply) { reply.send(req.body) } }) fastify.post('/paramsAsync/:id', { schema: { params: schemaAsync, body: schemaSync }, handler (req, reply) { reply.send(req.body) } }) fastify.post('/bodyAsync/:id', { schema: { params: schemaAsync, body: schemaAsync, query: schemaSync }, handler (req, reply) { reply.send(req.body) } }) fastify.post('/headersSync/:id', { schema: { params: schemaSync, body: schemaSync, query: schemaAsync, headers: schemaSync }, handler (req, reply) { reply.send(req.body) } }) fastify.post('/noHeader/:id', { schema: { params: schemaSync, body: schemaSync, query: schemaAsync }, handler (req, reply) { reply.send(req.body) } }) fastify.post('/noBody/:id', { schema: { params: schemaSync, query: schemaAsync, headers: schemaSync }, handler (req, reply) { reply.send(req.body) } }) const testCases = [ { url: '/queryAsync', params: 200, body: 200, querystring: 200, headers: 'not a number sync', response: 400 }, { url: '/paramsAsync', params: 200, body: 'not a number sync', querystring: 200, headers: 200, response: 400 }, { url: '/bodyAsync', params: 200, body: 200, querystring: 'not a number sync', headers: 200, response: 400 }, { url: '/headersSync', params: 200, body: 200, querystring: 200, headers: 'not a number sync', response: 400 }, { url: '/noHeader', params: 200, body: 200, querystring: 200, headers: 'not a number sync, but not validated', response: 200 }, { url: '/noBody', params: 200, body: 'not a number sync, but not validated', querystring: 200, headers: 'not a number sync', response: 400 } ] t.plan(testCases.length * 2) testCases.forEach(validate) function validate ({ url, params, body, querystring, headers, response }) { fastify.inject({ method: 'POST', url: `${url}/${params || ''}`, headers: { id: headers }, query: { id: querystring }, payload: { id: body } }, (err, res) => { t.error(err) t.equal(res.statusCode, response) }) } }) test('Check if hooks and attachValidation work with AJV validations', t => { const fastify = Fastify({ exposeHeadRoutes: false, ajv: { customOptions: { allErrors: true, keywords: [ { keyword: 'idExists', async: true, type: 'number', validate: checkIdExists } ] } } }) async function checkIdExists (schema, data) { const res = await Promise.resolve(data) switch (res) { case 200: return true default: return false } } const schemaAsync = { $async: true, type: 'object', properties: { id: { type: 'integer', idExists: { table: 'posts' } } } } fastify.post('/:id', { preHandler: function hook (request, reply, done) { t.equal(request.validationError.message, 'validation failed') t.pass('preHandler called') reply.code(400).send(request.body) }, attachValidation: true, schema: { params: schemaAsync, body: schemaAsync, query: schemaAsync, headers: schemaAsync }, handler (req, reply) { reply.send(req.body) } }) const testCases = [ { params: 200, body: 200, querystring: 200, headers: 400, response: 400 }, { params: 200, body: 400, querystring: 200, headers: 200, response: 400 }, { params: 200, body: 200, querystring: 400, headers: 200, response: 400 }, { params: 200, body: 200, querystring: 200, headers: 400, response: 400 } ] t.plan(testCases.length * 4) testCases.forEach(validate) function validate ({ url, params, body, querystring, headers, response }) { fastify.inject({ method: 'POST', url: `/${params}`, headers: { id: headers }, query: { id: querystring }, payload: { id: body } }, (err, res) => { t.error(err) t.equal(res.statusCode, response) }) } })