theme | layout | highlighter | lineNumbers |
---|---|---|---|
slidev-theme-nearform |
default |
shiki |
false |
-
An efficient server implies lower infrastructure costs, better responsiveness under load, and happy users
-
How can you efficiently handle the server resources, while serving the highest number of requests possible, without sacrificing security validations and handy development?
- Fastify is a Node.js web framework focused on performance and developer experience
- The Fastify team has spent considerable time building a highly supportive and encouraging community
- Fastify gained adoption by solving real needs of Node.js developers
- Highly performant: as far as we know, Fastify is one of the fastest web frameworks in town, depending on the code complexity we can serve up to 30k requests per second.
- Extensible: fully extensible via hooks, plugins and decorators.
- Schema based: It isn't mandatory, but we recommend to use JSON Schema to validate your routes and serialize your outputs. Fastify compiles the schema in a highly performant function.
- Logging: logs are extremely important but are costly; we chose the best logger to almost remove this cost, Pino!
- Developer friendly: the framework is built to be very expressive and to help developers in their daily use, without sacrificing performance and security
- TypeScript ready: we work hard to maintain a TypeScript type declaration file so we can support the growing TypeScript community
https://www.fastify.io/organisations/
-
There are 45 core plugins and 155 community plugins
-
Can't find the plugin you are looking for? No problem, it's very easy to write one!
https://www.fastify.io/ecosystem/
- Leveraging our experience with Node.js performance, Fastify has been built from the ground up to be as fast as possible
- All the code used for our benchmarks is available on GitHub
- Node LTS
- npm >= 7
- docker
- docker-compose
git clone https://github.com/nearform/the-fastify-workshop
npm ci
npm run db:up
npm run db:migrate
# make sure you're all set
npm test --workspaces
- This workshop is made of multiple, incremental modules
- Each module builds on top of the previous one
- At each step you are asked to add features and solve problems
- You will find the solution to each step in the
src/step-{n}-{name}
folder - The π icon indicates bonus features
- The π‘ icon indicates hints
-
cd src/step-{n}-{name}
-
Check out README.md
cd src/step-01-hello-world
npm run start
Write a Fastify program in a server.js
file which:
- Exposes a
GET /
route - Listens on port 3000
- Responds with the JSON object
{
"hello": "world"
}
π use ES modules!
// server.js
import Fastify from 'fastify'
const start = async function () {
const fastify = Fastify()
fastify.get('/', () => {
return { hello: 'world' }
})
try {
await fastify.listen({ port: 3000 })
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
}
start()
curl http://localhost:3000
{"hello":"world"}
-
As with JavaScript, where everything is an object, with Fastify everything is a plugin
-
Fastify allows you to extend its functionalities with plugins. A plugin can be a set of routes, a server decorator or whatever. The API to use one or more plugins is
register
-
Split
server.js
into two files:server.js
contains only the server startup logicindex.js
contains the code to instantiate Fastify and register plugins
-
Create a
GET /users
route inroutes/users.js
and export it as a Fastify plugin
// index.js
import Fastify from 'fastify'
function buildServer() {
const fastify = Fastify()
fastify.register(import('./routes/users.js'))
return fastify
}
export default buildServer
// server.js
import buildServer from './index.js'
const fastify = buildServer()
const start = async function () {
try {
await fastify.listen({ port: 3000 })
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
}
start()
// routes/users.js
export default async function users(fastify) {
fastify.get('/users', {}, async () => [
{ username: 'alice' },
{ username: 'bob' },
])
}
curl http://localhost:3000/
{
"message": "Route GET:/ not found",
"error": "Not Found",
"statusCode": 404
}
curl http://localhost:3000/users
[{ "username": "alice" }, { "username": "bob" }]
- Fastify ships by default with
pino
- Pino is a logger that aims to lower as much as possible its impact on the application performance
- The 2 base principles it follows are:
- Log processing should be conducted in a separate process
- Use minimum resources for logging
- Fastify has a
logger
option you can use to enable logging and configure it
- Pino provides a child logger to each route which includes the request id, enabling the developer to group log outputs under the request that generated them
- By using transports we can also send logs for further processing, for example the
pino-pretty
transport will output the logs in a more human readable form. Note that this option should only be used during development. - Options like this improve understandability for developers, making it easier to develop.
- Enable built-in request logging in the application
- Use the
pino-pretty
transport for pretty printing of logs - Use the request logging that Pino provides when logging from the users route.
- Programmatically write logs in the application.
// index.js
import Fastify from 'fastify'
function buildServer() {
const fastify = Fastify({
logger: {
transport: {
target: 'pino-pretty',
},
},
})
fastify.register(import('./routes/users.js'))
fastify.log.info('Fastify is starting up!')
return fastify
}
export default buildServer
// routes/users.js
export default async function users(fastify) {
fastify.get('/users', async req => {
req.log.info('Users route called')
return [{ username: 'alice' }, { username: 'bob' }]
})
}
npm run start
[1612530447393] INFO (62680 on HostComputer):
Fastify is starting up!
[1612530447411] INFO (62680 on HostComputer):
Server listening at http://127.0.0.1:3000
curl http://localhost:3000/users
[{"username":"alice"},{"username":"bob"}]
[1612531288501] INFO (63322 on Softwares-MBP): incoming request
req: {
"method": "GET",
"url": "/users",
"hostname": "localhost:3000",
"remoteAddress": "127.0.0.1",
"remotePort": 54847
}
reqId: 1
[1612531288503] INFO (63322 on Softwares-MBP): Users route called
reqId: 1
[1612531288515] INFO (63322 on Softwares-MBP): request completed
res: {
"statusCode": 200
}
responseTime: 13.076016008853912
reqId: 1
- Route validation internally relies upon Ajv, which is a high-performance JSON Schema validator
Created
https://www.fastify.io/docs/latest/Reference/Validation-and-Serialization/#validation
-
Create and register a
POST /login
route inroutes/login.js
-
Validate the body of the request to ensure it is a JSON object containing two required string properties:
username
andpassword
withfluent-json-schema
// routes/login.js
import S from 'fluent-json-schema'
const schema = {
body: S.object()
.prop('username', S.string().required())
.prop('password', S.string().required()),
}
export default async function login(fastify) {
fastify.post('/login', { schema }, async req => {
const { username, password } = req.body
return { username, password }
})
}
curl -X POST -H "Content-Type: application/json" \
-d '{ "username": "alice", "password": "alice" }'
http://localhost:3000/login
{
"username": "alice",
"password": "alice"
}
curl -X POST -H "Content-Type: application/json" \
-d '{ "name": "alice", "passcode": "alice" }'
http://localhost:3000/login
{
"statusCode": 400,
"error": "Bad Request",
"message": "body should have required property 'username'"
}
- Route matching can also be constrained to match properties of the request. By default fastify supports
version
(viaAccept-Version
header) andhost
(viaHost
header)
π Custom constraints can be added via
find-my-way
https://www.fastify.io/docs/latest/Reference/Routes/#constraints
- Add a new
GET /version
route that only accepts requests matching version1.0.0
π‘ The
Accept-Version
header should accept 1.x, 1.0.x and 1.0.0
π Add
Vary
header to the response to avoid cache poisoning
// routes/version.js
export default async function version(fastify) {
fastify.route({
method: 'GET',
url: '/version',
constraints: { version: '1.0.0' },
handler: async (req) => {
return { version: '1.0.0' }
},
})
}
curl -X GET -H "Content-Type: application/json" \
-H "Accept-Version: 1.0.0" \
http://localhost:3000/version
{
"version": "1.0.0"
}
curl -X GET -H "Content-Type: application/json" \
-H "Accept-Version: 2.0.0" \
http://localhost:3000/version
{
"statusCode": 404,
"error": "Not Found",
"message": "Route GET:/version not found"
}
For the rest of the workshop the
GET /version
route will be removed
- Fastify is very flexible when it comes to testing and is compatible with most testing frameworks
- Built-in support for fake http injection thanks to light-my-request
- Fastify can also be tested after starting the server with
fastify.listen()
or after initializing routes and plugins withfastify.ready()
- Write a unit test for the
index.js
module - Use
node --test
- Use
fastify.inject
- Check that GETting the
/users
route:- Responds with status code 200
- Returns the expected array of users
π‘ you don't need to start the server
// test/index.test.js
import buildServer from '../index.js'
import {test} from "node:test"
import assert from "node:assert/strict"
test('GET /users', async t => {
await t.test('returns users', async t => {
const fastify = buildServer()
const res = await fastify.inject('/users')
assert.equal(res.statusCode, 200)
assert.deepEqual(res.json(), [
{ username: 'alice' },
{ username: 'bob' },
])
})
})
β― npm run test
$ node --test
[10:30:06.058] INFO (1601): Fastify is starting up!
[10:30:06.098] INFO (1601): incoming request
...
β test/index.test.js (123.827ms)
βΉ tests 3
βΉ suites 0
βΉ pass 3
βΉ fail 0
βΉ cancelled 0
βΉ skipped 0
βΉ todo 0
βΉ duration_ms 346.373708
β¨ Done in 2.70s.
- Fastify uses a schema-based approach, and even if it is not mandatory we recommend using JSON Schema to validate your routes and serialize your outputs. Internally, Fastify compiles the schema into a highly performant function
- We encourage you to use an output schema, as it can drastically increase throughput and help prevent accidental disclosure of sensitive information
https://www.fastify.io/docs/latest/Reference/Validation-and-Serialization/
- Validate the response in the users route
- Ensure that the response is serialized properly and contains the required property
username
in each array item
// routes/users.js
import S from 'fluent-json-schema'
const schema = {
response: {
200: S.array().items(
S.object().prop('username', S.string().required())
),
},
}
export default async function users(fastify) {
fastify.get('/users', { schema }, async req => {
req.log.info('Users route called')
return [{ username: 'alice' }, { username: 'bob' }]
})
}
In routes/users.js change the hardcoded response so it doesn't match the schema:
[{ "wrong": "alice" }, { "wrong": "bob" }]
You will need to restart the server in step-4-serialization for these changes to take effect.
curl http://localhost:3000/users
{
"statusCode": 500,
"error": "Internal Server Error",
"message": "\"username\" is required!"
}
@fastify/jwt
contains JWT utils for Fastify, internally uses jsonwebtoken
-
Change
index.js
so that it:- Registers the
@fastify/jwt
plugin using a hardcoded string as thesecret
property of the plugin's configuration options
- Registers the
// index.js
import Fastify from 'fastify'
function buildServer() {
const fastify = Fastify({
logger: {
transport: {
target: 'pino-pretty',
},
},
})
fastify.register(import('@fastify/jwt'), {
secret: 'supersecret',
})
fastify.register(import('./routes/login.js'))
fastify.register(import('./routes/users.js'))
fastify.log.info('Fastify is starting up!')
return fastify
}
export default buildServer
-
Change
routes/login.js
to add an auth check:-
Perform a dummy check on the auth: if
username === password
then the user is authenticated -
If the auth check fails, respond with a
401 Unauthorized
HTTP error
π‘ you can use the
http-errors
package -
-
Still on
routes/login.js
:-
If the auth check succeeds, respond with a JSON object containing a
token
property, whose value is the result of signing the object{ username }
using thefastify.jwt.sign
decorator added by the@fastify/jwt
plugin -
Change the response schema to ensure the
200
response is correctly formatted
-
// routes/login.js
const schema = {
body: S.object()
.prop('username', S.string().required())
.prop('password', S.string().required()),
response: {
200: S.object().prop('token', S.string().required()),
},
}
export default async function login(fastify) {
fastify.post('/login', { schema }, async req => {
const { username, password } = req.body
// sample auth check
if (username !== password) {
throw errors.Unauthorized()
}
return { token: fastify.jwt.sign({ username }) }
})
}
curl -X POST -H "Content-Type: application/json" \
-d '{ "username": "alice", "password": "alice" }'
http://localhost:3000/login
{
"token": "eyJhbGciOi ..."
}
curl -X POST -H "Content-Type: application/json" \
-d '{ "username": "alice", "password": "wrong" }'
http://localhost:3000/login
{
"statusCode": 401,
"error": "Unauthorized",
"message": "Unauthorized"
}
- It is preferable to use environment variables to configure your app. Example: the JWT secret from the previous step
- This makes it easier to deploy the same code into different environments
- Typically config values are not committed to a repository and they are managed with environment variables. An example would be the logging level: in production it's usually better to have only important info, while in a dev env it may be useful to show more
π‘ As we only refactor in this step we don't have a try it out slide. You can try things from earlier steps and expect them to work
- Create a
config.js
file which:- Uses
env-schema
to load aJWT_SECRET
environment variable, with fallback to a.env
file - Validates its value with
fluent-json-schema
- Uses
- Change
server.js
so that it imports theconfig.js
module and provides it to thebuildServer
function - Change
index.js
so that it:- Accepts the configuration provided by
server.js
in the exportedbuildServer
function
- Accepts the configuration provided by
// config.js
import { join } from 'desm'
import envSchema from 'env-schema'
import S from 'fluent-json-schema'
const schema = S.object()
.prop('JWT_SECRET', S.string().required())
.prop('LOG_LEVEL', S.string().default('info'))
.prop('PRETTY_PRINT', S.string().default(true))
export default envSchema({
schema,
dotenv: { path: join(import.meta.url, '.env') },
})
// server.js
import buildServer from './index.js'
import config from './config.js'
const fastify = buildServer(config)
const start = async function () {
try {
await fastify.listen({ port: 3000 })
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
}
start()
// index.js
import Fastify from 'fastify'
function buildServer(config) {
const opts = {
...config,
logger: {
level: config.LOG_LEVEL,
}
}
const fastify = Fastify(opts)
...
return fastify
}
export default buildServer
- In the previous step we generated a JWT token that can be used to access protected routes. In this step we're going to create a protected route and allow access only to authenticated users via a Fastify decorator
π‘ This step and the next one work together and we'll get to try it all out after the next step
-
Create a
plugins/authentication.js
plugin which:-
Registers
@fastify/jwt
with a secret provided via plugin optionsπ‘ move the plugin registration from
index.js
to the new plugin module -
Exposes an
authenticate
decorator on the Fastify instance which verifies the authentication token and responds with an error if invalid
-
-
Register the new plugin in
index.js
// plugins/authenticate.js
async function authenticate(fastify, opts) {
fastify.register(import('@fastify/jwt'), {
secret: opts.JWT_SECRET,
})
fastify.decorate('authenticate', async (req, reply) => {
try {
await req.jwtVerify()
} catch (err) {
reply.send(err)
}
})
}
authenticate[Symbol.for('skip-override')] = true
export default authenticate
// index.js
import Fastify from 'fastify'
function buildServer(config) {
const opts = {
...
}
const fastify = Fastify(opts)
fastify.register(import('./plugins/authenticate.js'), opts)
fastify.register(import('./routes/login.js'))
fastify.register(import('./routes/users.js'))
fastify.log.info('Fastify is starting up!')
return fastify
}
export default buildServer
- In this step we're going to build on the previous step by using a fastify hook with our decorator for the protected route
https://www.fastify.io/docs/latest/Reference/Hooks/
- Create a
GET /user
route inroutes/user/index.js
- Require authentication using the
onRequest
Fastify hook - Use the
fastify.authenticate
decorator - Return the information about the currently authenticated user in the response
π‘ you can get the current user from
request.user
// routes/user/index.js
import S from 'fluent-json-schema'
const schema = {
response: {
200: S.object().prop('username', S.string().required()),
},
}
export default async function user(fastify) {
fastify.get(
'/user',
{
onRequest: [fastify.authenticate],
schema,
},
async req => req.user
)
}
π‘ you need a valid JWT by logging in via the
POST /login
route
curl http://localhost:3000/user \
-H "Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5c..."
{ "username": "alice" }
{
"statusCode": 401,
"error": "Unauthorized",
"message": "Authorization token ..."
}
@fastify/autoload
is a convenience plugin for Fastify that loads all plugins found in a directory and automatically configures routes matching the folder structure- Note that as we only refactor in this step we don't have a try it out slide. You can try things from earlier steps and expect them to work
- In this step we have also introduced integration tests. You can see these running if you run
npm run test
- Remove all the manual route registrations.
- Register the autoload plugin two times:
- one for the
plugins
folder - one for the
routes
folder
- one for the
- Remove the
user
path inuser/index.js
as autoload will derive this from the folder structure
π does the route need to be registered explicitly?
π what is the url the route will respond to?
// index.js
import { join } from 'desm'
import Fastify from 'fastify'
import autoload from '@fastify/autoload'
function buildServer(config) {
...
fastify.register(autoload, {
dir: join(import.meta.url, 'plugins'),
options: opts,
})
fastify.register(autoload, {
dir: join(import.meta.url, 'routes'),
options: opts,
})
fastify.log.info('Fastify is starting up!')
return fastify
}
// routes/user/index.js
...
export default async function user(fastify) {
fastify.get(
'/',
...
)
}
- Use
@fastify/postgres
, which allows to share the same PostgreSQL connection pool in every part of your server - Use
@nearform/sql
to create database queries using template strings without introducing SQL injection vulnerabilities
Make sure you setup the db first with:
npm run db:up
npm run db:migrate
π‘ check the
migrations
folder to see the database schema.
- Change
config.js
to support aPG_CONNECTION_STRING
variable - Enrich
.env
with:PG_CONNECTION_STRING=postgres://postgres:[email protected]:5433/postgres
- Register
@fastify/postgres
inindex.js
, providing the variable's value as theconnectionString
plugin option
// index.js
function buildServer(config) {
//...
fastify.register(import('@fastify/postgres'), {
connectionString: opts.PG_CONNECTION_STRING,
})
// ...
return fastify
}
export default buildServer
Change routes/login.js
:
- After carrying out the existing dummy auth check, look up the user in the
users
database table via theusername
property provided in the request body
π‘ write the query using
@nearform/sql
- If the user does not exist in the database, return a
401 Unauthorized
error
// routes/login.js
import SQL from '@nearform/sql'
export default async function login(fastify) {
fastify.post('/login', { schema }, async req => {
const { username, password } = req.body
// sample auth check
if (username !== password) throw errors.Unauthorized()
const {
rows: [user],
} = await fastify.pg.query(
SQL`SELECT id, username FROM users WHERE username = ${username}`
)
if (!user) throw errors.Unauthorized()
return { token: fastify.jwt.sign({ username }) }
})
}
- Move the existing
routes/users.js
route toroutes/users/index.js
and make it an auto-prefixed route responding toGET /users
- Change the response schema so that it requires an array of objects with properties
username
of typestring
andid
of typeinteger
- Load all users from the database instead of returning an hardcoded array of users
// routes/users/index.js
const schema = {
response: {
200: S.array().items(
S.object()
.prop('id', S.integer().required())
.prop('username', S.string().required())
),
},
}
export default async function users(fastify) {
fastify.get(
'/',
{ onRequest: [fastify.authenticate], schema },
async () => {
const { rows: users } = await fastify.pg.query(
'SELECT id, username FROM users'
)
return users
}
)
}
- Let's create an Fastify application using TypeScript.
- We will transpose the application that you did in the Step 10 to TypeScript
- Use
declaration merging
to add the customauthenticate
decorator property toFastifyInstance
- Use
@sinclair/typebox
to transform JSON Schema into types
// routes/login.ts
import { Type, Static } from '@sinclair/typebox'
import { FastifyInstance, FastifyRequest } from 'fastify'
import errors from 'http-errors'
const BodySchema = Type.Object({
username: Type.String(),
password: Type.String(),
})
// Generate type from JSON Schema
type BodySchema = Static<typeof BodySchema>
const ResponseSchema = Type.Object({
token: Type.String(),
})
type ResponseSchema = Static<typeof ResponseSchema>
const schema = {
body: BodySchema,
response: { 200: ResponseSchema },
}
// routes/login.ts
export default async function login(fastify: FastifyInstance) {
fastify.post(
'/login',
{ schema },
async (
req: FastifyRequest<{ Body: BodySchema }>
): Promise<ResponseSchema> => {
const { username, password } = req.body
if (username !== password) {
throw new errors.Unauthorized()
}
return { token: fastify.jwt.sign({ username }) }
}
)
}
// plugins/authenticate.ts
async function authenticate(
fastify: FastifyInstance,
opts: FastifyPluginOptions
): Promise<void> {
fastify.register(fastifyJwt, { secret: opts.JWT_SECRET })
fastify.decorate(
'authenticate',
async (req: FastifyRequest, reply: FastifyReply) => {
try {
await req.jwtVerify()
} catch (err) {
reply.send(err)
}
}
)
}
export default fp(authenticate)
// @types/index.d.ts
import type { FastifyRequest, FastifyReply } from 'fastify'
declare module 'fastify' {
export interface FastifyInstance {
authenticate: (
request: FastifyRequest,
reply: FastifyReply
) => Promise<void>
}
}
It adds the authenticate
property to FastifyInstance
:
π‘ inspire from the code in the completed steps