",
10 | "scripts": {
11 | "build": "slidev build",
12 | "start": "slidev --open",
13 | "export": "slidev export",
14 | "db:up": "docker compose up -d",
15 | "db:migrate": "postgrator",
16 | "db:down": "docker compose down",
17 | "lint": "eslint --ext .ts,.js ./src",
18 | "test": "c8 --check-coverage --100 node --test"
19 | },
20 | "dependencies": {
21 | "@fastify/autoload": "^6.3.0",
22 | "@fastify/jwt": "^9.1.0",
23 | "@fastify/postgres": "^6.0.2",
24 | "@nearform/sql": "^1.10.5",
25 | "@sinclair/typebox": "^0.34.33",
26 | "@slidev/cli": "^51.7.1",
27 | "@vueuse/shared": "^13.0.0",
28 | "desm": "^1.3.0",
29 | "env-schema": "^6.0.1",
30 | "fastify": "^5.3.2",
31 | "fluent-json-schema": "^6.0.0",
32 | "http-errors": "^2.0.0",
33 | "pg": "^8.15.6",
34 | "pino-pretty": "^13.0.0",
35 | "slidev-theme-nearform": "^2.1.0"
36 | },
37 | "devDependencies": {
38 | "@tsconfig/node18": "^18.2.4",
39 | "@types/http-errors": "^2.0.2",
40 | "@types/node": "^22.14.1",
41 | "@types/sinon": "^17.0.4",
42 | "@typescript-eslint/eslint-plugin": "^5.62.0",
43 | "@typescript-eslint/parser": "^5.62.0",
44 | "c8": "^10.1.3",
45 | "cross-env": "^7.0.3",
46 | "eslint": "^8.57.0",
47 | "eslint-config-prettier": "^10.1.5",
48 | "eslint-plugin-import": "^2.31.0",
49 | "eslint-plugin-prettier": "^5.4.0",
50 | "eslint-plugin-sql": "^3.2.1",
51 | "postgrator-cli": "^9.0.1",
52 | "prettier": "^3.5.1",
53 | "sinon": "^20.0.0",
54 | "ts-node-dev": "^2.0.0",
55 | "typescript": "^5.8.3"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/public/images/nearform.svg:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
13 |
17 |
21 |
25 |
29 |
33 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/slides.md:
--------------------------------------------------------------------------------
1 | ---
2 | theme: slidev-theme-nearform
3 | layout: default
4 | highlighter: shiki
5 | lineNumbers: false
6 | ---
7 |
8 |
9 |
10 | # The Fastify Workshop
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ---
21 |
22 | # Introduction: Why Fastify
23 |
24 |
25 |
26 | - An efficient server implies lower infrastructure costs, better responsiveness under load, and happy users
27 |
28 | - How can you efficiently handle the server resources, while serving the highest number of requests possible, without sacrificing security validations and handy development?
29 |
30 |
31 |
32 | ---
33 |
34 | # Introduction: Why Fastify /2
35 |
36 |
37 |
38 | - Fastify is a Node.js web framework focused on performance and developer experience
39 | - The Fastify team has spent considerable time building a highly supportive and encouraging community
40 | - Fastify gained adoption by solving real needs of Node.js developers
41 |
42 |
43 |
44 | ---
45 |
46 | # Core features
47 |
48 |
49 |
50 | - **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.
51 | - **Extensible**: fully extensible via hooks, plugins and decorators.
52 | - **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.
53 |
54 |
55 |
56 | ---
57 |
58 | # Core features /2
59 |
60 |
61 |
62 | - **Logging**: logs are extremely important but are costly; we chose the best logger to almost remove this cost, Pino!
63 | - **Developer friendly**: the framework is built to be very expressive and to help developers in their daily use, without sacrificing performance and security
64 | - **TypeScript ready**: we work hard to maintain a TypeScript type declaration file so we can support the growing TypeScript community
65 |
66 |
67 |
68 | ---
69 |
70 | # Who is using Fastify
71 |
72 | 
73 |
74 | https://www.fastify.io/organisations/
75 |
76 | ---
77 |
78 | # Ecosystem
79 |
80 | - There are 45 core plugins and 155 community plugins
81 |
82 | - Can't find the plugin you are looking for? No problem, it's very easy to write one!
83 |
84 | https://www.fastify.io/ecosystem/
85 |
86 | ---
87 |
88 | # Benchmarks
89 |
90 |
91 |
92 |
93 |
94 | Leveraging our experience with Node.js performance, Fastify has been built from the ground up to be as fast as possible
95 |
96 |
97 |
98 | All the code used for our benchmarks is available on GitHub
99 |
100 |
101 |
102 |
107 |
108 |
109 | ---
110 |
111 | # Getting setup
112 |
113 |
114 |
115 | #### Requirements
116 |
117 | - Node LTS
118 | - npm >= 7
119 | - docker
120 | - docker-compose
121 |
122 | #### Setup
123 |
124 | ```bash
125 | git clone https://github.com/nearform/the-fastify-workshop
126 | npm ci
127 | npm run db:up
128 | npm run db:migrate
129 |
130 | # make sure you're all set
131 | npm test --workspaces
132 | ```
133 |
134 |
135 |
136 | ---
137 |
138 | # Workshop structure
139 |
140 |
141 |
142 | - This workshop is made of multiple, incremental modules
143 | - Each module builds on top of the previous one
144 | - At each step you are asked to add features and solve problems
145 | - You will find the solution to each step in the `src/step-{n}-{name}` folder
146 | - The 🏆 icon indicates bonus features
147 | - The 💡 icon indicates hints
148 |
149 |
150 |
151 | ---
152 |
153 | # Running the modules
154 |
155 | - `cd src/step-{n}-{name}`
156 |
157 | - Check out README.md
158 |
159 | #### Example
160 |
161 | ```bash
162 | cd src/step-01-hello-world
163 |
164 | npm run start
165 | ```
166 |
167 | ---
168 |
169 | # Step 1: Exercise 💻
170 |
171 |
172 |
173 | Write a Fastify program in a `server.js` file which:
174 |
175 | - Exposes a `GET /` route
176 | - Listens on port 3000
177 | - Responds with the JSON object
178 |
179 | ```json
180 | {
181 | "hello": "world"
182 | }
183 | ```
184 |
185 | > 🏆 use ES modules!
186 |
187 |
188 |
189 | ---
190 |
191 | # Step 1: Solution
192 |
193 | ```js
194 | // server.js
195 | import Fastify from 'fastify'
196 |
197 | const start = async function () {
198 | const fastify = Fastify()
199 |
200 | fastify.get('/', () => {
201 | return { hello: 'world' }
202 | })
203 |
204 | try {
205 | await fastify.listen({ port: 3000 })
206 | } catch (err) {
207 | fastify.log.error(err)
208 | process.exit(1)
209 | }
210 | }
211 |
212 | start()
213 | ```
214 |
215 | ---
216 |
217 | # Step 1: Trying it out
218 |
219 | ### In the terminal:
220 |
221 | ```bash
222 | curl http://localhost:3000
223 |
224 | {"hello":"world"}
225 | ```
226 |
227 | ### In the browser:
228 |
229 |
230 |
231 | ---
232 |
233 | # Step 2: Plugins
234 |
235 |
236 |
237 | - As with JavaScript, where everything is an object, with Fastify everything is a plugin
238 |
239 | - 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`
240 |
241 | https://www.fastify.io/docs/latest/Reference/Plugins/
242 |
243 |
244 |
245 | ---
246 |
247 | # Step 2: Exercise 💻
248 |
249 |
250 |
251 | - Split `server.js` into two files:
252 |
253 | - `server.js` contains only the server startup logic
254 | - `index.js` contains the code to instantiate Fastify and register plugins
255 |
256 | - Create a `GET /users` route in `routes/users.js` and export it as a Fastify plugin
257 |
258 |
259 |
260 | ---
261 |
262 | # Step 2: Solution
263 |
264 | ```js
265 | // index.js
266 | import Fastify from 'fastify'
267 |
268 | function buildServer() {
269 | const fastify = Fastify()
270 |
271 | fastify.register(import('./routes/users.js'))
272 |
273 | return fastify
274 | }
275 |
276 | export default buildServer
277 | ```
278 |
279 | ---
280 |
281 | # Step 2: Solution /2
282 |
283 | ```js
284 | // server.js
285 | import buildServer from './index.js'
286 |
287 | const fastify = buildServer()
288 |
289 | const start = async function () {
290 | try {
291 | await fastify.listen({ port: 3000 })
292 | } catch (err) {
293 | fastify.log.error(err)
294 | process.exit(1)
295 | }
296 | }
297 |
298 | start()
299 | ```
300 |
301 | ---
302 |
303 | # Step 2: Solution /3
304 |
305 | ```js
306 | // routes/users.js
307 | export default async function users(fastify) {
308 | fastify.get('/users', {}, async () => [
309 | { username: 'alice' },
310 | { username: 'bob' },
311 | ])
312 | }
313 | ```
314 |
315 | ---
316 |
317 | # Step 2: Trying it out
318 |
319 | #### Note that the / route is now not found
320 |
321 | ```bash
322 | curl http://localhost:3000/
323 | ```
324 |
325 | ```json
326 | {
327 | "message": "Route GET:/ not found",
328 | "error": "Not Found",
329 | "statusCode": 404
330 | }
331 | ```
332 |
333 | #### We'll find our response at /users
334 |
335 | ```bash
336 | curl http://localhost:3000/users
337 | ```
338 |
339 | ```json
340 | [{ "username": "alice" }, { "username": "bob" }]
341 | ```
342 |
343 | ---
344 |
345 | # Step 3: Logging
346 |
347 |
348 |
349 | - Fastify ships by default with [`pino`](https://github.com/pinojs/pino)
350 | - Pino is a logger that aims to lower as much as possible its impact on the application performance
351 | - The 2 base principles it follows are:
352 | 1. Log processing should be conducted in a separate process
353 | 2. Use minimum resources for logging
354 | - Fastify has a `logger` option you can use to enable logging and configure it
355 |
356 | https://www.fastify.io/docs/latest/Reference/Logging/
357 |
358 |
359 |
360 | ---
361 |
362 | # Step 3: Logging Readability / 2
363 |
364 |
365 |
366 | - 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
367 | - 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.
368 | - Options like this improve understandability for developers, making it easier to develop.
369 |
370 |
371 |
372 | ---
373 |
374 | # Step 3: Exercise 💻
375 |
376 |
377 |
378 | - Enable built-in request logging in the application
379 | - Use the `pino-pretty` transport for pretty printing of logs
380 | - Use the request logging that Pino provides when logging from the users route.
381 | - Programmatically write logs in the application.
382 |
383 |
384 |
385 | ---
386 |
387 | # Step 3: Solution /1
388 |
389 | ```js
390 | // index.js
391 | import Fastify from 'fastify'
392 |
393 | function buildServer() {
394 | const fastify = Fastify({
395 | logger: {
396 | transport: {
397 | target: 'pino-pretty',
398 | },
399 | },
400 | })
401 |
402 | fastify.register(import('./routes/users.js'))
403 |
404 | fastify.log.info('Fastify is starting up!')
405 |
406 | return fastify
407 | }
408 |
409 | export default buildServer
410 | ```
411 |
412 | ---
413 |
414 | # Step 3: Solution /2
415 |
416 | ```js
417 | // routes/users.js
418 | export default async function users(fastify) {
419 | fastify.get('/users', async req => {
420 | req.log.info('Users route called')
421 |
422 | return [{ username: 'alice' }, { username: 'bob' }]
423 | })
424 | }
425 | ```
426 |
427 | ---
428 |
429 | # Step 3: Trying it out
430 |
431 | ```bash
432 | npm run start
433 |
434 | [1612530447393] INFO (62680 on HostComputer):
435 | Fastify is starting up!
436 | [1612530447411] INFO (62680 on HostComputer):
437 | Server listening at http://127.0.0.1:3000
438 | ```
439 |
440 | ---
441 |
442 | # Step 3: Trying it out /2
443 |
444 | ```bash
445 | curl http://localhost:3000/users
446 |
447 | [{"username":"alice"},{"username":"bob"}]
448 | ```
449 |
450 | ```bash
451 | [1612531288501] INFO (63322 on Softwares-MBP): incoming request
452 | req: {
453 | "method": "GET",
454 | "url": "/users",
455 | "hostname": "localhost:3000",
456 | "remoteAddress": "127.0.0.1",
457 | "remotePort": 54847
458 | }
459 | reqId: 1
460 | [1612531288503] INFO (63322 on Softwares-MBP): Users route called
461 | reqId: 1
462 | [1612531288515] INFO (63322 on Softwares-MBP): request completed
463 | res: {
464 | "statusCode": 200
465 | }
466 | responseTime: 13.076016008853912
467 | reqId: 1
468 | ```
469 |
470 | ---
471 |
472 | # Step 4 Validation
473 |
474 | - Route validation internally relies upon [Ajv](https://www.npmjs.com/package/ajv), which is a high-performance JSON Schema validator
475 |
476 | Created
477 |
478 | https://www.fastify.io/docs/latest/Reference/Validation-and-Serialization/#validation
479 |
480 | ---
481 |
482 | # Step 4: Exercise 💻
483 |
484 |
485 |
486 | - Create and register a `POST /login` route in `routes/login.js`
487 |
488 | - Validate the body of the request to ensure it is a JSON object containing two required string properties: `username` and `password` with [`fluent-json-schema`](https://github.com/fastify/fluent-json-schema)
489 |
490 |
491 |
492 | ---
493 |
494 | # Step 4: Solution
495 |
496 | ```js
497 | // routes/login.js
498 | import S from 'fluent-json-schema'
499 |
500 | const schema = {
501 | body: S.object()
502 | .prop('username', S.string().required())
503 | .prop('password', S.string().required()),
504 | }
505 |
506 | export default async function login(fastify) {
507 | fastify.post('/login', { schema }, async req => {
508 | const { username, password } = req.body
509 | return { username, password }
510 | })
511 | }
512 | ```
513 |
514 | ---
515 |
516 | # Step 4: Trying it out
517 |
518 | #### With right credentials
519 |
520 | ```bash
521 | curl -X POST -H "Content-Type: application/json" \
522 | -d '{ "username": "alice", "password": "alice" }'
523 | http://localhost:3000/login
524 | ```
525 |
526 | ```json
527 | {
528 | "username": "alice",
529 | "password": "alice"
530 | }
531 | ```
532 |
533 | ---
534 |
535 | # Step 4: Trying it out /2
536 |
537 | #### With wrong credentials
538 |
539 | ```bash
540 | curl -X POST -H "Content-Type: application/json" \
541 | -d '{ "name": "alice", "passcode": "alice" }'
542 | http://localhost:3000/login
543 | ```
544 |
545 | ```json
546 | {
547 | "statusCode": 400,
548 | "error": "Bad Request",
549 | "message": "body should have required property 'username'"
550 | }
551 | ```
552 |
553 | ---
554 |
555 | # Step 5 Constraints
556 |
557 | - Route matching can also be constrained to match properties of the request. By default fastify supports `version` (via `Accept-Version` header) and `host` (via `Host` header)
558 |
559 | > 🏆 Custom constraints can be added via [`find-my-way`](https://github.com/delvedor/find-my-way)
560 |
561 | https://www.fastify.io/docs/latest/Reference/Routes/#constraints
562 |
563 | ---
564 |
565 | # Step 5: Exercise 💻
566 |
567 |
568 |
569 | - Add a new `GET /version` route that only accepts requests matching version `1.0.0`
570 |
571 | > 💡 The `Accept-Version` header should accept 1.x, 1.0.x and 1.0.0
572 |
573 | > 🏆 Add `Vary` header to the response to avoid cache poisoning
574 |
575 |
576 |
577 | ---
578 |
579 | # Step 5: Solution
580 |
581 | ```js
582 | // routes/version.js
583 | export default async function version(fastify) {
584 | fastify.route({
585 | method: 'GET',
586 | url: '/version',
587 | constraints: { version: '1.0.0' },
588 | handler: async (req) => {
589 | return { version: '1.0.0' }
590 | },
591 | })
592 | }
593 | ```
594 |
595 | ---
596 |
597 | # Step 5: Trying it out
598 |
599 | #### With right version
600 |
601 | ```bash
602 | curl -X GET -H "Content-Type: application/json" \
603 | -H "Accept-Version: 1.0.0" \
604 | http://localhost:3000/version
605 | ```
606 |
607 | ```json
608 | {
609 | "version": "1.0.0"
610 | }
611 | ```
612 |
613 | ---
614 |
615 | # Step 5: Trying it out /2
616 |
617 | #### With wrong version
618 |
619 | ```bash
620 | curl -X GET -H "Content-Type: application/json" \
621 | -H "Accept-Version: 2.0.0" \
622 | http://localhost:3000/version
623 | ```
624 |
625 | ```json
626 | {
627 | "statusCode": 404,
628 | "error": "Not Found",
629 | "message": "Route GET:/version not found"
630 | }
631 | ```
632 |
633 | > For the rest of the workshop the `GET /version` route will be removed
634 |
635 | ---
636 |
637 | # Step 6: Testing
638 |
639 |
640 |
641 | - Fastify is very flexible when it comes to testing and is compatible with most testing frameworks
642 | - Built-in support for fake http injection thanks to [light-my-request](https://github.com/fastify/light-my-request)
643 | - Fastify can also be tested after starting the server with `fastify.listen()` or after initializing routes and plugins with `fastify.ready()`
644 |
645 | https://www.fastify.io/docs/latest/Guides/Testing/
646 |
647 |
648 |
649 | ---
650 |
651 | # Step 6: Exercise 💻
652 |
653 |
654 |
655 | - Write a unit test for the `index.js` module
656 | - Use `node --test`
657 | - Use `fastify.inject`
658 | - Check that GETting the `/users` route:
659 | - Responds with status code 200
660 | - Returns the expected array of users
661 |
662 | > 💡 you don't need to start the server
663 |
664 |
665 |
666 | ---
667 |
668 | # Step 6: Solution
669 |
670 | ```js
671 | // test/index.test.js
672 | import buildServer from '../index.js'
673 |
674 | import {test} from "node:test"
675 | import assert from "node:assert/strict"
676 |
677 | test('GET /users', async t => {
678 | await t.test('returns users', async t => {
679 | const fastify = buildServer()
680 |
681 | const res = await fastify.inject('/users')
682 |
683 | assert.equal(res.statusCode, 200)
684 |
685 | assert.deepEqual(res.json(), [
686 | { username: 'alice' },
687 | { username: 'bob' },
688 | ])
689 | })
690 | })
691 | ```
692 |
693 | ---
694 |
695 | # Step 6: Trying it out
696 |
697 | #### Run the tests
698 |
699 | ```bash
700 | ❯ npm run test
701 | $ node --test
702 | [10:30:06.058] INFO (1601): Fastify is starting up!
703 | [10:30:06.098] INFO (1601): incoming request
704 | ...
705 | ✔ test/index.test.js (123.827ms)
706 |
707 | ℹ tests 3
708 | ℹ suites 0
709 | ℹ pass 3
710 | ℹ fail 0
711 | ℹ cancelled 0
712 | ℹ skipped 0
713 | ℹ todo 0
714 | ℹ duration_ms 346.373708
715 |
716 | ✨ Done in 2.70s.
717 | ```
718 |
719 | ---
720 |
721 | # Step 7: Serialization
722 |
723 |
724 |
725 | - 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
726 | - We encourage you to use an output schema, as it can drastically increase throughput and help prevent accidental disclosure of sensitive information
727 |
728 | https://www.fastify.io/docs/latest/Reference/Validation-and-Serialization/
729 |
730 |
731 |
732 | ---
733 |
734 | # Step 7: Exercise 💻
735 |
736 | - Validate the response in the users route
737 | - Ensure that the response is serialized properly and contains the required property `username` in each array item
738 |
739 | ---
740 |
741 | # Step 7: Solution
742 |
743 | ```js
744 | // routes/users.js
745 | import S from 'fluent-json-schema'
746 |
747 | const schema = {
748 | response: {
749 | 200: S.array().items(
750 | S.object().prop('username', S.string().required())
751 | ),
752 | },
753 | }
754 |
755 | export default async function users(fastify) {
756 | fastify.get('/users', { schema }, async req => {
757 | req.log.info('Users route called')
758 |
759 | return [{ username: 'alice' }, { username: 'bob' }]
760 | })
761 | }
762 | ```
763 |
764 | ---
765 |
766 | # Step 7: Trying it out
767 |
768 | #### Make the response invalid
769 |
770 | In routes/users.js change the hardcoded response so it doesn't match the schema:
771 |
772 | ```json
773 | [{ "wrong": "alice" }, { "wrong": "bob" }]
774 | ```
775 |
776 | You will need to restart the server in step-4-serialization for these changes to take effect.
777 |
778 | ```bash
779 | curl http://localhost:3000/users
780 | ```
781 |
782 | ```json
783 | {
784 | "statusCode": 500,
785 | "error": "Internal Server Error",
786 | "message": "\"username\" is required!"
787 | }
788 | ```
789 |
790 | ---
791 |
792 | # Step 8: Authentication
793 |
794 | - [`@fastify/jwt`](https://github.com/fastify/fastify-jwt) contains JWT utils for Fastify, internally uses [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken)
795 |
796 | ---
797 |
798 | # Step 8: Exercise 💻
799 |
800 | - Change `index.js` so that it:
801 |
802 | - Registers the `@fastify/jwt` plugin using a hardcoded string as the `secret` property of the plugin's configuration options
803 |
804 | ---
805 |
806 | # Step 8: Solution
807 |
808 | ```js
809 | // index.js
810 | import Fastify from 'fastify'
811 |
812 | function buildServer() {
813 | const fastify = Fastify({
814 | logger: {
815 | transport: {
816 | target: 'pino-pretty',
817 | },
818 | },
819 | })
820 |
821 | fastify.register(import('@fastify/jwt'), {
822 | secret: 'supersecret',
823 | })
824 | fastify.register(import('./routes/login.js'))
825 | fastify.register(import('./routes/users.js'))
826 |
827 | fastify.log.info('Fastify is starting up!')
828 |
829 | return fastify
830 | }
831 |
832 | export default buildServer
833 | ```
834 |
835 | ---
836 |
837 | # Step 8: Exercise /2 💻
838 |
839 | - Change `routes/login.js` to add an auth check:
840 |
841 | - Perform a dummy check on the auth: if `username === password` then the user is authenticated
842 |
843 | - If the auth check fails, respond with a `401 Unauthorized` HTTP error
844 |
845 | > 💡 you can use the [`http-errors`](https://github.com/jshttp/http-errors) package
846 |
847 | ---
848 |
849 | # Step 8: Exercise /2 💻
850 |
851 | - Still on `routes/login.js`:
852 |
853 | - 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 the `fastify.jwt.sign` decorator added by the `@fastify/jwt` plugin
854 |
855 | - Change the response schema to ensure the `200` response is correctly formatted
856 |
857 | ---
858 |
859 | # Step 8: Solution
860 |
861 | ```js
862 | // routes/login.js
863 | const schema = {
864 | body: S.object()
865 | .prop('username', S.string().required())
866 | .prop('password', S.string().required()),
867 | response: {
868 | 200: S.object().prop('token', S.string().required()),
869 | },
870 | }
871 |
872 | export default async function login(fastify) {
873 | fastify.post('/login', { schema }, async req => {
874 | const { username, password } = req.body
875 |
876 | // sample auth check
877 | if (username !== password) {
878 | throw errors.Unauthorized()
879 | }
880 |
881 | return { token: fastify.jwt.sign({ username }) }
882 | })
883 | }
884 | ```
885 |
886 | ---
887 |
888 | # Step 8: Trying it out
889 |
890 | #### With right credentials
891 |
892 | ```bash
893 | curl -X POST -H "Content-Type: application/json" \
894 | -d '{ "username": "alice", "password": "alice" }'
895 | http://localhost:3000/login
896 | ```
897 |
898 | ```json
899 | {
900 | "token": "eyJhbGciOi ..."
901 | }
902 | ```
903 |
904 | ---
905 |
906 | # Step 8: Trying it out /2
907 |
908 | #### With wrong credentials
909 |
910 | ```bash
911 | curl -X POST -H "Content-Type: application/json" \
912 | -d '{ "username": "alice", "password": "wrong" }'
913 | http://localhost:3000/login
914 | ```
915 |
916 | ```json
917 | {
918 | "statusCode": 401,
919 | "error": "Unauthorized",
920 | "message": "Unauthorized"
921 | }
922 | ```
923 |
924 | ---
925 |
926 | # Step 9: Config
927 |
928 |
929 |
930 | - It is preferable to use environment variables to configure your app. Example: the JWT secret from the previous step
931 | - This makes it easier to deploy the same code into different environments
932 | - 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
933 |
934 |
935 |
936 | > 💡 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
937 |
938 | ---
939 |
940 | # Step 9: Exercise 💻
941 |
942 |
943 |
944 | - Create a `config.js` file which:
945 | - Uses `env-schema` to load a `JWT_SECRET` environment variable, with fallback to a `.env` file
946 | - Validates its value with `fluent-json-schema`
947 | - Change `server.js` so that it imports the `config.js` module and provides it to the `buildServer` function
948 | - Change `index.js` so that it:
949 | - Accepts the configuration provided by `server.js` in the exported `buildServer` function
950 |
951 |
952 |
953 | ---
954 |
955 | # Step 9: Solution
956 |
957 | ```js
958 | // config.js
959 | import { join } from 'desm'
960 | import envSchema from 'env-schema'
961 | import S from 'fluent-json-schema'
962 |
963 | const schema = S.object()
964 | .prop('JWT_SECRET', S.string().required())
965 | .prop('LOG_LEVEL', S.string().default('info'))
966 | .prop('PRETTY_PRINT', S.string().default(true))
967 |
968 | export default envSchema({
969 | schema,
970 | dotenv: { path: join(import.meta.url, '.env') },
971 | })
972 | ```
973 |
974 | ---
975 |
976 | # Step 9: Solution /2
977 |
978 | ```js
979 | // server.js
980 | import buildServer from './index.js'
981 | import config from './config.js'
982 |
983 | const fastify = buildServer(config)
984 |
985 | const start = async function () {
986 | try {
987 | await fastify.listen({ port: 3000 })
988 | } catch (err) {
989 | fastify.log.error(err)
990 | process.exit(1)
991 | }
992 | }
993 |
994 | start()
995 | ```
996 |
997 | ---
998 |
999 | # Step 9: Solution /3
1000 |
1001 | ```js
1002 | // index.js
1003 | import Fastify from 'fastify'
1004 |
1005 | function buildServer(config) {
1006 | const opts = {
1007 | ...config,
1008 | logger: {
1009 | level: config.LOG_LEVEL,
1010 | }
1011 | }
1012 |
1013 | const fastify = Fastify(opts)
1014 |
1015 | ...
1016 |
1017 | return fastify
1018 | }
1019 |
1020 | export default buildServer
1021 | ```
1022 |
1023 | ---
1024 |
1025 | # Step 10: Decorators
1026 |
1027 |
1028 |
1029 | - 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
1030 |
1031 | > 💡 This step and the next one work together and we'll get to try it all out after the next step
1032 |
1033 | https://www.fastify.io/docs/latest/Reference/Decorators/
1034 |
1035 |
1036 |
1037 | ---
1038 |
1039 | # Fastify extensibility
1040 |
1041 |
1042 |
1043 | ---
1044 |
1045 | # Step 10: Exercise 💻
1046 |
1047 |
1048 |
1049 | - Create a `plugins/authentication.js` plugin which:
1050 |
1051 | - Registers `@fastify/jwt` with a secret provided via plugin options
1052 | > 💡 move the plugin registration from `index.js` to the new plugin module
1053 |
1054 | - Exposes an `authenticate` decorator on the Fastify instance which verifies the authentication token and responds with an error if invalid
1055 |
1056 | - Register the new plugin in `index.js`
1057 |
1058 |
1059 |
1060 | ---
1061 |
1062 | # Step 10: Solution
1063 |
1064 | ```js
1065 | // plugins/authenticate.js
1066 | async function authenticate(fastify, opts) {
1067 | fastify.register(import('@fastify/jwt'), {
1068 | secret: opts.JWT_SECRET,
1069 | })
1070 |
1071 | fastify.decorate('authenticate', async (req, reply) => {
1072 | try {
1073 | await req.jwtVerify()
1074 | } catch (err) {
1075 | reply.send(err)
1076 | }
1077 | })
1078 | }
1079 |
1080 | authenticate[Symbol.for('skip-override')] = true
1081 |
1082 | export default authenticate
1083 | ```
1084 |
1085 | #### 🏆 why is `skip-override` necessary? what is the alternative?
1086 |
1087 | ---
1088 |
1089 | # Step 10: Solution/2
1090 |
1091 | ```js
1092 | // index.js
1093 | import Fastify from 'fastify'
1094 |
1095 | function buildServer(config) {
1096 | const opts = {
1097 | ...
1098 | }
1099 |
1100 | const fastify = Fastify(opts)
1101 |
1102 | fastify.register(import('./plugins/authenticate.js'), opts)
1103 |
1104 | fastify.register(import('./routes/login.js'))
1105 | fastify.register(import('./routes/users.js'))
1106 |
1107 | fastify.log.info('Fastify is starting up!')
1108 |
1109 | return fastify
1110 | }
1111 |
1112 | export default buildServer
1113 | ```
1114 |
1115 | ---
1116 |
1117 | # Step 11: Hooks
1118 |
1119 | - In this step we're going to build on the previous step by using a fastify hook with our decorator for the protected route
1120 |
1121 | https://www.fastify.io/docs/latest/Reference/Hooks/
1122 |
1123 | ---
1124 |
1125 | # Fastify lifecycle hooks
1126 |
1127 |
1128 |
1129 | ---
1130 |
1131 | # Step 11: Exercise 💻
1132 |
1133 |
1134 |
1135 | - Create a `GET /user` route in `routes/user/index.js`
1136 | - Require authentication using the `onRequest` Fastify hook
1137 | - Use the `fastify.authenticate` decorator
1138 | - Return the information about the currently authenticated user in the response
1139 |
1140 | > 💡 you can get the current user from `request.user`
1141 |
1142 |
1143 |
1144 | ---
1145 |
1146 | # Step 11: Solution
1147 |
1148 | ```js
1149 | // routes/user/index.js
1150 | import S from 'fluent-json-schema'
1151 |
1152 | const schema = {
1153 | response: {
1154 | 200: S.object().prop('username', S.string().required()),
1155 | },
1156 | }
1157 |
1158 | export default async function user(fastify) {
1159 | fastify.get(
1160 | '/user',
1161 | {
1162 | onRequest: [fastify.authenticate],
1163 | schema,
1164 | },
1165 | async req => req.user
1166 | )
1167 | }
1168 | ```
1169 |
1170 | ---
1171 |
1172 | # Steps 10 & 11: Trying it out
1173 |
1174 | > 💡 you need a valid JWT by logging in via the `POST /login` route
1175 |
1176 | #### Hit the user route with a token in the headers
1177 |
1178 | ```bash
1179 | curl http://localhost:3000/user \
1180 | -H "Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5c..."
1181 | ```
1182 |
1183 | #### With valid token
1184 |
1185 | ```json
1186 | { "username": "alice" }
1187 | ```
1188 |
1189 | #### With a wrong token (the error message will vary)
1190 |
1191 | ```json
1192 | {
1193 | "statusCode": 401,
1194 | "error": "Unauthorized",
1195 | "message": "Authorization token ..."
1196 | }
1197 | ```
1198 |
1199 | ---
1200 |
1201 | # Step 12: Fastify autoload
1202 |
1203 |
1204 |
1205 | - [`@fastify/autoload`](https://github.com/fastify/fastify-autoload) is a convenience plugin for Fastify that loads all plugins found in a directory and automatically configures routes matching the folder structure
1206 | - 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
1207 | - In this step we have also introduced integration tests. You can see these running if you run `npm run test`
1208 |
1209 |
1210 |
1211 | ---
1212 |
1213 | # Step 12: Exercise 💻
1214 |
1215 |
1216 |
1217 | - Remove all the manual route registrations.
1218 | - Register the autoload plugin two times:
1219 | - one for the `plugins` folder
1220 | - one for the `routes` folder
1221 | - Remove the `user` path in `user/index.js` as autoload will derive this from the folder structure
1222 |
1223 | > 🏆 does the route need to be registered explicitly?
1224 |
1225 |
1226 |
1227 | > 🏆 what is the url the route will respond to?
1228 |
1229 |
1230 |
1231 | ---
1232 |
1233 | # Step 12: Solution
1234 |
1235 | ```js
1236 | // index.js
1237 | import { join } from 'desm'
1238 | import Fastify from 'fastify'
1239 | import autoload from '@fastify/autoload'
1240 |
1241 | function buildServer(config) {
1242 | ...
1243 |
1244 | fastify.register(autoload, {
1245 | dir: join(import.meta.url, 'plugins'),
1246 | options: opts,
1247 | })
1248 |
1249 | fastify.register(autoload, {
1250 | dir: join(import.meta.url, 'routes'),
1251 | options: opts,
1252 | })
1253 |
1254 | fastify.log.info('Fastify is starting up!')
1255 |
1256 | return fastify
1257 | }
1258 | ```
1259 |
1260 | ---
1261 |
1262 | # Step 12: Solution /2
1263 |
1264 | ```js
1265 | // routes/user/index.js
1266 | ...
1267 |
1268 | export default async function user(fastify) {
1269 | fastify.get(
1270 | '/',
1271 |
1272 | ...
1273 | )
1274 | }
1275 | ```
1276 |
1277 | ---
1278 |
1279 | # Step 13: Database 🏆
1280 |
1281 |
1282 |
1283 | - Use [`@fastify/postgres`](https://github.com/fastify/fastify-postgres), which allows to share the same PostgreSQL connection pool in every part of your server
1284 | - Use [`@nearform/sql`](https://github.com/nearform/sql) to create database queries using template strings without introducing SQL injection vulnerabilities
1285 |
1286 | Make sure you setup the db first with:
1287 |
1288 | ```sh
1289 | npm run db:up
1290 | npm run db:migrate
1291 | ```
1292 |
1293 |
1294 |
1295 | > 💡 check the `migrations` folder to see the database schema.
1296 |
1297 |
1298 |
1299 | ---
1300 |
1301 | # Step 13: Exercise 💻
1302 |
1303 |
1304 |
1305 | - Change `config.js` to support a `PG_CONNECTION_STRING` variable
1306 | - Enrich `.env` with:
1307 | ```txt
1308 | PG_CONNECTION_STRING=postgres://postgres:postgres@0.0.0.0:5433/postgres
1309 | ```
1310 | - Register `@fastify/postgres` in `index.js`, providing the variable's value as the `connectionString` plugin option
1311 |
1312 |
1313 |
1314 | ---
1315 |
1316 | # Step 13: Solution
1317 |
1318 | ```js
1319 | // index.js
1320 | function buildServer(config) {
1321 | //...
1322 | fastify.register(import('@fastify/postgres'), {
1323 | connectionString: opts.PG_CONNECTION_STRING,
1324 | })
1325 | // ...
1326 |
1327 | return fastify
1328 | }
1329 |
1330 | export default buildServer
1331 | ```
1332 |
1333 | ---
1334 |
1335 | # Step 13: Exercise 💻
1336 |
1337 | Change `routes/login.js`:
1338 |
1339 |
1340 |
1341 | - After carrying out the existing dummy auth check, look up the user in the `users` database table via the `username` property provided in the request body
1342 |
1343 | > 💡 write the query using `@nearform/sql`
1344 |
1345 |
1346 |
1347 | - If the user does not exist in the database, return a `401 Unauthorized` error
1348 |
1349 |
1350 |
1351 | ---
1352 |
1353 | # Step 13: Solution
1354 |
1355 | ```js
1356 | // routes/login.js
1357 | import SQL from '@nearform/sql'
1358 |
1359 | export default async function login(fastify) {
1360 | fastify.post('/login', { schema }, async req => {
1361 | const { username, password } = req.body
1362 |
1363 | // sample auth check
1364 | if (username !== password) throw errors.Unauthorized()
1365 |
1366 | const {
1367 | rows: [user],
1368 | } = await fastify.pg.query(
1369 | SQL`SELECT id, username FROM users WHERE username = ${username}`
1370 | )
1371 |
1372 | if (!user) throw errors.Unauthorized()
1373 |
1374 | return { token: fastify.jwt.sign({ username }) }
1375 | })
1376 | }
1377 | ```
1378 |
1379 | ---
1380 |
1381 | # Step 13: Exercise 💻
1382 |
1383 |
1384 |
1385 | - Move the existing `routes/users.js` route to `routes/users/index.js` and make it an auto-prefixed route responding to `GET /users`
1386 | - Change the response schema so that it requires an array of objects with properties `username` of type `string` and `id` of type `integer`
1387 | - Load all users from the database instead of returning an hardcoded array of users
1388 |
1389 |
1390 |
1391 | ---
1392 |
1393 | # Step 13: Solution
1394 |
1395 | ```js
1396 | // routes/users/index.js
1397 | const schema = {
1398 | response: {
1399 | 200: S.array().items(
1400 | S.object()
1401 | .prop('id', S.integer().required())
1402 | .prop('username', S.string().required())
1403 | ),
1404 | },
1405 | }
1406 | export default async function users(fastify) {
1407 | fastify.get(
1408 | '/',
1409 | { onRequest: [fastify.authenticate], schema },
1410 | async () => {
1411 | const { rows: users } = await fastify.pg.query(
1412 | 'SELECT id, username FROM users'
1413 | )
1414 | return users
1415 | }
1416 | )
1417 | }
1418 | ```
1419 |
1420 | ---
1421 |
1422 | # Step 14: Exercise 💻
1423 |
1424 |
1425 |
1426 | - Let's create an Fastify application using **TypeScript**.
1427 | - We will transpose the application that you did in the [Step 10](./59) to TypeScript
1428 | - Use `declaration merging` to add the custom `authenticate` decorator property to `FastifyInstance`
1429 | - Use [`@sinclair/typebox`](https://www.npmjs.com/package/@sinclair/typebox) to transform JSON Schema into types
1430 |
1431 |
1432 |
1433 | ---
1434 |
1435 | # Step 14: Solution/1
1436 |
1437 | ```ts
1438 | // routes/login.ts
1439 | import { Type, Static } from '@sinclair/typebox'
1440 | import { FastifyInstance, FastifyRequest } from 'fastify'
1441 | import errors from 'http-errors'
1442 |
1443 | const BodySchema = Type.Object({
1444 | username: Type.String(),
1445 | password: Type.String(),
1446 | })
1447 |
1448 | // Generate type from JSON Schema
1449 | type BodySchema = Static
1450 |
1451 | const ResponseSchema = Type.Object({
1452 | token: Type.String(),
1453 | })
1454 | type ResponseSchema = Static
1455 |
1456 | const schema = {
1457 | body: BodySchema,
1458 | response: { 200: ResponseSchema },
1459 | }
1460 | ```
1461 |
1462 | ---
1463 |
1464 | # Step 14: Solution/2
1465 |
1466 | ```ts
1467 | // routes/login.ts
1468 | export default async function login(fastify: FastifyInstance) {
1469 | fastify.post(
1470 | '/login',
1471 | { schema },
1472 | async (
1473 | req: FastifyRequest<{ Body: BodySchema }>
1474 | ): Promise => {
1475 | const { username, password } = req.body
1476 |
1477 | if (username !== password) {
1478 | throw new errors.Unauthorized()
1479 | }
1480 |
1481 | return { token: fastify.jwt.sign({ username }) }
1482 | }
1483 | )
1484 | }
1485 | ```
1486 |
1487 | ---
1488 |
1489 | # Step 14: Solution/3
1490 |
1491 | ```ts
1492 | // plugins/authenticate.ts
1493 | async function authenticate(
1494 | fastify: FastifyInstance,
1495 | opts: FastifyPluginOptions
1496 | ): Promise {
1497 | fastify.register(fastifyJwt, { secret: opts.JWT_SECRET })
1498 | fastify.decorate(
1499 | 'authenticate',
1500 | async (req: FastifyRequest, reply: FastifyReply) => {
1501 | try {
1502 | await req.jwtVerify()
1503 | } catch (err) {
1504 | reply.send(err)
1505 | }
1506 | }
1507 | )
1508 | }
1509 |
1510 | export default fp(authenticate)
1511 | ```
1512 |
1513 | ---
1514 |
1515 | # Step 14: Solution/4
1516 |
1517 | ```ts
1518 | // @types/index.d.ts
1519 | import type { FastifyRequest, FastifyReply } from 'fastify'
1520 |
1521 | declare module 'fastify' {
1522 | export interface FastifyInstance {
1523 | authenticate: (
1524 | request: FastifyRequest,
1525 | reply: FastifyReply
1526 | ) => Promise
1527 | }
1528 | }
1529 | ```
1530 |
1531 | It adds the `authenticate` property to `FastifyInstance`:
1532 |
1533 |
1534 |
1535 | ---
1536 |
1537 | # 🏆 Write Tests 🏆
1538 |
1539 | > 💡 inspire from the code in the completed steps
1540 |
1541 | ---
1542 |
1543 | # Thanks For Having Us!
1544 |
1545 | ## 👏👏👏
1546 |
--------------------------------------------------------------------------------
/src/start-here/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "start-here",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node --watch server",
9 | "test": "echo 'todo'"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/start-here/server.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nearform/the-fastify-workshop/7af45f3bef5e321bbadc3c0d81fbad5d39d0c21c/src/start-here/server.js
--------------------------------------------------------------------------------
/src/step-01-hello-world/README.md:
--------------------------------------------------------------------------------
1 | # step-1
2 |
3 | ## Setup
4 |
5 | - `npm run start`
6 |
--------------------------------------------------------------------------------
/src/step-01-hello-world/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "hello-world",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-01-hello-world/server.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | const start = async function () {
4 | const fastify = Fastify()
5 |
6 | fastify.get('/', async () => {
7 | return { hello: 'world' }
8 | })
9 |
10 | try {
11 | await fastify.listen({ port: 3000 })
12 | } catch (err) {
13 | fastify.log.error(err)
14 | process.exit(1)
15 | }
16 | }
17 |
18 | start()
19 |
--------------------------------------------------------------------------------
/src/step-02-plugins/README.md:
--------------------------------------------------------------------------------
1 | # step-2
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 |
7 | http://localhost:3000/users
8 |
--------------------------------------------------------------------------------
/src/step-02-plugins/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer() {
4 | const fastify = Fastify()
5 |
6 | fastify.register(import('./routes/users.js'))
7 |
8 | return fastify
9 | }
10 |
11 | export default buildServer
12 |
--------------------------------------------------------------------------------
/src/step-02-plugins/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "plugins",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-02-plugins/routes/users.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('fastify').FastifyPluginAsync}
3 | */
4 | export default async function users(fastify) {
5 | fastify.get('/users', async () => [
6 | { username: 'alice' },
7 | { username: 'bob' },
8 | ])
9 | }
10 |
--------------------------------------------------------------------------------
/src/step-02-plugins/server.js:
--------------------------------------------------------------------------------
1 | import buildServer from './index.js'
2 |
3 | const fastify = buildServer()
4 |
5 | const start = async function () {
6 | try {
7 | await fastify.listen({ port: 3000 })
8 | } catch (err) {
9 | fastify.log.error(err)
10 | process.exit(1)
11 | }
12 | }
13 |
14 | start()
15 |
--------------------------------------------------------------------------------
/src/step-03-logging/README.md:
--------------------------------------------------------------------------------
1 | # step-3
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 |
--------------------------------------------------------------------------------
/src/step-03-logging/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer() {
4 | const fastify = Fastify({
5 | logger: {
6 | transport: {
7 | target: 'pino-pretty',
8 | },
9 | },
10 | })
11 |
12 | fastify.register(import('./routes/users.js'))
13 |
14 | fastify.log.info('Fastify is starting up!')
15 |
16 | return fastify
17 | }
18 |
19 | export default buildServer
20 |
--------------------------------------------------------------------------------
/src/step-03-logging/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "logging",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-03-logging/routes/users.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('fastify').FastifyPluginAsync}
3 | * */
4 | export default async function users(fastify) {
5 | fastify.get('/users', async req => {
6 | req.log.info('Users route called')
7 |
8 | return [{ username: 'alice' }, { username: 'bob' }]
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-03-logging/server.js:
--------------------------------------------------------------------------------
1 | import buildServer from './index.js'
2 |
3 | const fastify = buildServer()
4 |
5 | const start = async function () {
6 | try {
7 | await fastify.listen({ port: 3000 })
8 | } catch (err) {
9 | fastify.log.error(err)
10 | process.exit(1)
11 | }
12 | }
13 |
14 | start()
15 |
--------------------------------------------------------------------------------
/src/step-04-validation/README.md:
--------------------------------------------------------------------------------
1 | # step-4
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 |
--------------------------------------------------------------------------------
/src/step-04-validation/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer() {
4 | const fastify = Fastify({
5 | logger: {
6 | transport: {
7 | target: 'pino-pretty',
8 | },
9 | },
10 | })
11 |
12 | fastify.register(import('./routes/login.js'))
13 | fastify.register(import('./routes/users.js'))
14 |
15 | fastify.log.info('Fastify is starting up!')
16 |
17 | return fastify
18 | }
19 |
20 | export default buildServer
21 |
--------------------------------------------------------------------------------
/src/step-04-validation/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "validation",
3 | "version": "1.0.0",
4 | "lockfileVersion": 2,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "validation",
9 | "version": "1.0.0",
10 | "license": "CC-BY-SA-4.0"
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/step-04-validation/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "validation",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-04-validation/routes/login.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | body: S.object()
5 | .prop('username', S.string().required())
6 | .prop('password', S.string().required()),
7 | }
8 |
9 | /**
10 | * @type {import('fastify').FastifyPluginAsync}
11 | * */
12 | export default async function login(fastify) {
13 | fastify.post(
14 | '/login',
15 | { schema },
16 | /**
17 | * @type {import('fastify').RouteHandler<{ Body: { username: string; password: string } }>}
18 | * */
19 | async req => {
20 | const { username, password } = req.body
21 | return { username, password }
22 | },
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/step-04-validation/routes/users.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('fastify').FastifyPluginAsync}
3 | * */
4 | export default async function users(fastify) {
5 | fastify.get('/users', async req => {
6 | req.log.info('Users route called')
7 |
8 | return [{ username: 'alice' }, { username: 'bob' }]
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-04-validation/server.js:
--------------------------------------------------------------------------------
1 | import buildServer from './index.js'
2 |
3 | const fastify = buildServer()
4 |
5 | const start = async function () {
6 | try {
7 | await fastify.listen({ port: 3000 })
8 | } catch (err) {
9 | fastify.log.error(err)
10 | process.exit(1)
11 | }
12 | }
13 |
14 | start()
15 |
--------------------------------------------------------------------------------
/src/step-05-constraints/README.md:
--------------------------------------------------------------------------------
1 | # step-5
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 |
--------------------------------------------------------------------------------
/src/step-05-constraints/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer() {
4 | const fastify = Fastify({
5 | logger: {
6 | transport: {
7 | target: 'pino-pretty',
8 | },
9 | },
10 | })
11 |
12 | fastify.register(import('./routes/login.js'))
13 | fastify.register(import('./routes/users.js'))
14 | fastify.register(import('./routes/version.js'))
15 |
16 | fastify.log.info('Fastify is starting up!')
17 |
18 | return fastify
19 | }
20 |
21 | export default buildServer
22 |
--------------------------------------------------------------------------------
/src/step-05-constraints/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "validation",
3 | "version": "1.0.0",
4 | "lockfileVersion": 2,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "validation",
9 | "version": "1.0.0",
10 | "license": "CC-BY-SA-4.0"
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/step-05-constraints/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "constraints",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-05-constraints/routes/login.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | body: S.object()
5 | .prop('username', S.string().required())
6 | .prop('password', S.string().required()),
7 | }
8 |
9 | /**
10 | * @type {import('fastify').FastifyPluginAsync}
11 | * */
12 | export default async function login(fastify) {
13 | fastify.post(
14 | '/login',
15 | { schema },
16 | /**
17 | * @type {import('fastify').RouteHandler<{ Body: { username: string; password: string } }>}
18 | * */
19 | async req => {
20 | const { username, password } = req.body
21 | return { username, password }
22 | },
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/step-05-constraints/routes/users.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('fastify').FastifyPluginAsync}
3 | * */
4 | export default async function users(fastify) {
5 | fastify.get('/users', async req => {
6 | req.log.info('Users route called')
7 |
8 | return [{ username: 'alice' }, { username: 'bob' }]
9 | })
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-05-constraints/routes/version.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('fastify').FastifyPluginAsync}
3 | * */
4 | export default async function version(fastify) {
5 | fastify.route({
6 | method: 'GET',
7 | url: '/version',
8 | constraints: { version: '1.0.0' },
9 | handler: async () => {
10 | return { version: '1.0.0' }
11 | },
12 | })
13 | }
14 |
--------------------------------------------------------------------------------
/src/step-05-constraints/server.js:
--------------------------------------------------------------------------------
1 | import buildServer from './index.js'
2 |
3 | const fastify = buildServer()
4 |
5 | const start = async function () {
6 | try {
7 | await fastify.listen({ port: 3000 })
8 | } catch (err) {
9 | fastify.log.error(err)
10 | process.exit(1)
11 | }
12 | }
13 |
14 | start()
15 |
--------------------------------------------------------------------------------
/src/step-06-testing/README.md:
--------------------------------------------------------------------------------
1 | # step-6
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 | - run the tests with `npm run test`
7 |
--------------------------------------------------------------------------------
/src/step-06-testing/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer() {
4 | const fastify = Fastify({
5 | logger: {
6 | transport: {
7 | target: 'pino-pretty',
8 | },
9 | },
10 | })
11 |
12 | fastify.register(import('./routes/users.js'))
13 | fastify.register(import('./routes/login.js'))
14 |
15 | fastify.log.info('Fastify is starting up!')
16 |
17 | return fastify
18 | }
19 |
20 | export default buildServer
21 |
--------------------------------------------------------------------------------
/src/step-06-testing/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "testing",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server",
9 | "test": "c8 --check-coverage --100 node --test"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/step-06-testing/routes/login.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | body: S.object()
5 | .prop('username', S.string().required())
6 | .prop('password', S.string().required()),
7 | }
8 |
9 | /**
10 | * @type {import('fastify').FastifyPluginAsync}
11 | * */
12 | export default async function login(fastify) {
13 | fastify.post(
14 | '/login',
15 | { schema },
16 | /**
17 | * @type {import('fastify').RouteHandler<{ Body: { username: string; password: string } }>}
18 | * */
19 | async req => {
20 | const { username, password } = req.body
21 | return { username, password }
22 | },
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/step-06-testing/routes/users.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('fastify').FastifyPluginAsync}
3 | * */
4 | export default async function users(fastify) {
5 | fastify.get('/users', async req => {
6 | req.log.info('Users route called')
7 | return [{ username: 'alice' }, { username: 'bob' }]
8 | })
9 | }
10 |
--------------------------------------------------------------------------------
/src/step-06-testing/server.js:
--------------------------------------------------------------------------------
1 | import buildServer from './index.js'
2 |
3 | const fastify = buildServer()
4 |
5 | const start = async function () {
6 | try {
7 | await fastify.listen({ port: 3000 })
8 | } catch (err) {
9 | fastify.log.error(err)
10 | process.exit(1)
11 | }
12 | }
13 |
14 | start()
15 |
--------------------------------------------------------------------------------
/src/step-06-testing/test/login.test.js:
--------------------------------------------------------------------------------
1 | import { test } from 'node:test'
2 | import assert from 'node:assert/strict'
3 |
4 | import buildServer from '../index.js'
5 |
6 | test('POST /login', async t => {
7 | await t.test(
8 | 'returns 400 with an invalid post payload',
9 | async () => {
10 | const fastify = buildServer()
11 |
12 | const res = await fastify.inject({
13 | url: '/login',
14 | method: 'POST',
15 | body: {
16 | name: 'alice',
17 | passcode: 'alice',
18 | },
19 | })
20 |
21 | assert.equal(res.statusCode, 400)
22 | },
23 | )
24 |
25 | await t.test(
26 | 'returns the data with valid post payload',
27 | async () => {
28 | const fastify = buildServer()
29 |
30 | const res = await fastify.inject({
31 | url: '/login',
32 | method: 'POST',
33 | body: {
34 | username: 'alice',
35 | password: 'alice',
36 | },
37 | })
38 |
39 | assert.equal(res.statusCode, 200)
40 | assert.deepEqual(res.json(), {
41 | username: 'alice',
42 | password: 'alice',
43 | })
44 | },
45 | )
46 | })
47 |
--------------------------------------------------------------------------------
/src/step-06-testing/test/users.test.js:
--------------------------------------------------------------------------------
1 | import { test } from 'node:test'
2 | import assert from 'node:assert/strict'
3 |
4 | import buildServer from '../index.js'
5 |
6 | test('GET /users', async t => {
7 | await t.test('returns users', async () => {
8 | const fastify = buildServer()
9 |
10 | const res = await fastify.inject('/users')
11 |
12 | assert.equal(res.statusCode, 200)
13 |
14 | assert.deepEqual(res.json(), [
15 | { username: 'alice' },
16 | { username: 'bob' },
17 | ])
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/src/step-07-serialization/README.md:
--------------------------------------------------------------------------------
1 | # step-7
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 | - run the tests with `npm run test`
7 |
--------------------------------------------------------------------------------
/src/step-07-serialization/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer() {
4 | const fastify = Fastify({
5 | logger: {
6 | transport: {
7 | target: 'pino-pretty',
8 | },
9 | },
10 | })
11 |
12 | fastify.register(import('./routes/login.js'))
13 | fastify.register(import('./routes/users.js'))
14 |
15 | fastify.log.info('Fastify is starting up!')
16 |
17 | return fastify
18 | }
19 |
20 | export default buildServer
21 |
--------------------------------------------------------------------------------
/src/step-07-serialization/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "serialization",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server",
9 | "test": "c8 --check-coverage --100 node --test"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/step-07-serialization/routes/login.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | body: S.object()
5 | .prop('username', S.string().required())
6 | .prop('password', S.string().required()),
7 | response: {
8 | 200: S.object()
9 | .prop('username', S.string().required())
10 | .prop('password', S.string().required()),
11 | },
12 | }
13 |
14 | /**
15 | * @type {import('fastify').FastifyPluginAsync}
16 | * */
17 | export default async function login(fastify) {
18 | fastify.post(
19 | '/login',
20 | { schema },
21 | /**
22 | * @type {import('fastify').RouteHandler<{ Body: { username: string; password: string } }>}
23 | * */
24 | async req => {
25 | const { username, password } = req.body
26 | return { username, password }
27 | },
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/src/step-07-serialization/routes/users.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.array().items(
6 | S.object().prop('username', S.string().required()),
7 | ),
8 | },
9 | }
10 |
11 | /**
12 | * @type {import('fastify').FastifyPluginAsync}
13 | * */
14 | export default async function users(fastify) {
15 | fastify.get('/users', { schema }, async req => {
16 | req.log.info('Users route called')
17 |
18 | return [{ username: 'alice' }, { username: 'bob' }]
19 | })
20 | }
21 |
--------------------------------------------------------------------------------
/src/step-07-serialization/server.js:
--------------------------------------------------------------------------------
1 | import buildServer from './index.js'
2 |
3 | const fastify = buildServer()
4 |
5 | const start = async function () {
6 | try {
7 | await fastify.listen({ port: 3000 })
8 | } catch (err) {
9 | fastify.log.error(err)
10 | process.exit(1)
11 | }
12 | }
13 |
14 | start()
15 |
--------------------------------------------------------------------------------
/src/step-07-serialization/test/login.test.js:
--------------------------------------------------------------------------------
1 | import { test } from 'node:test'
2 | import assert from 'node:assert/strict'
3 |
4 | import buildServer from '../index.js'
5 |
6 | test('POST /login', async t => {
7 | await t.test(
8 | 'returns 400 with an invalid post payload',
9 | async () => {
10 | const fastify = buildServer()
11 |
12 | const res = await fastify.inject({
13 | url: '/login',
14 | method: 'POST',
15 | body: {
16 | name: 'alice',
17 | passcode: 'alice',
18 | },
19 | })
20 |
21 | assert.equal(res.statusCode, 400)
22 | },
23 | )
24 |
25 | await t.test(
26 | 'returns the data with valid post payload',
27 | async () => {
28 | const fastify = buildServer()
29 |
30 | const res = await fastify.inject({
31 | url: '/login',
32 | method: 'POST',
33 | body: {
34 | username: 'alice',
35 | password: 'alice',
36 | },
37 | })
38 |
39 | assert.equal(res.statusCode, 200)
40 | assert.deepEqual(res.json(), {
41 | username: 'alice',
42 | password: 'alice',
43 | })
44 | },
45 | )
46 | })
47 |
--------------------------------------------------------------------------------
/src/step-07-serialization/test/users.test.js:
--------------------------------------------------------------------------------
1 | import { test } from 'node:test'
2 | import assert from 'node:assert/strict'
3 |
4 | import buildServer from '../index.js'
5 |
6 | test('GET /users', async t => {
7 | await t.test('returns users', async () => {
8 | const fastify = buildServer()
9 |
10 | const res = await fastify.inject('/users')
11 |
12 | assert.equal(res.statusCode, 200)
13 |
14 | assert.deepEqual(res.json(), [
15 | { username: 'alice' },
16 | { username: 'bob' },
17 | ])
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/src/step-08-authentication/README.md:
--------------------------------------------------------------------------------
1 | # step-8
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 | - run the tests with `npm run test`
7 |
--------------------------------------------------------------------------------
/src/step-08-authentication/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer() {
4 | const fastify = Fastify({
5 | logger: {
6 | transport: {
7 | target: 'pino-pretty',
8 | },
9 | },
10 | })
11 |
12 | fastify.register(import('@fastify/jwt'), {
13 | secret: 'supersecret',
14 | })
15 | fastify.register(import('./routes/login.js'))
16 | fastify.register(import('./routes/users.js'))
17 |
18 | fastify.log.info('Fastify is starting up!')
19 |
20 | return fastify
21 | }
22 |
23 | export default buildServer
24 |
--------------------------------------------------------------------------------
/src/step-08-authentication/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "authentication",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server",
9 | "test": "c8 --check-coverage --100 node --test"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/step-08-authentication/routes/login.js:
--------------------------------------------------------------------------------
1 | import errors from 'http-errors'
2 | import S from 'fluent-json-schema'
3 |
4 | const schema = {
5 | body: S.object()
6 | .prop('username', S.string().required())
7 | .prop('password', S.string().required()),
8 | response: {
9 | 200: S.object().prop('token', S.string().required()),
10 | },
11 | }
12 |
13 | export default async function login(fastify) {
14 | fastify.post('/login', { schema }, async req => {
15 | const { username, password } = req.body
16 |
17 | // sample auth check
18 | if (username !== password) {
19 | throw errors.Unauthorized()
20 | }
21 |
22 | return { token: fastify.jwt.sign({ username }) }
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/src/step-08-authentication/routes/users.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.array().items(
6 | S.object().prop('username', S.string().required()),
7 | ),
8 | },
9 | }
10 |
11 | export default async function users(fastify) {
12 | fastify.get('/users', { schema }, async req => {
13 | req.log.info('Users route called')
14 |
15 | return [{ username: 'alice' }, { username: 'bob' }]
16 | })
17 | }
18 |
--------------------------------------------------------------------------------
/src/step-08-authentication/server.js:
--------------------------------------------------------------------------------
1 | import buildServer from './index.js'
2 |
3 | const fastify = buildServer()
4 |
5 | const start = async function () {
6 | try {
7 | await fastify.listen({ port: 3000 })
8 | } catch (err) {
9 | fastify.log.error(err)
10 | process.exit(1)
11 | }
12 | }
13 |
14 | start()
15 |
--------------------------------------------------------------------------------
/src/step-08-authentication/test/login.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import sinon from 'sinon'
6 |
7 | function buildServer() {
8 | return fastify()
9 | .decorate('jwt', { sign: sinon.stub() })
10 | .register(import('../routes/login.js'))
11 | }
12 |
13 | test('POST /login', async t => {
14 | await t.test('returns 400 with missing credentials', async () => {
15 | const fastify = buildServer()
16 |
17 | const res = await fastify.inject({
18 | url: '/login',
19 | method: 'POST',
20 | })
21 |
22 | assert.equal(res.statusCode, 400)
23 | })
24 |
25 | await t.test('returns 400 with partial credentials', async () => {
26 | const fastify = buildServer()
27 |
28 | const res = await fastify.inject({
29 | url: '/login',
30 | method: 'POST',
31 | body: {
32 | username: 'alice',
33 | },
34 | })
35 |
36 | assert.equal(res.statusCode, 400)
37 | })
38 |
39 | await t.test('returns 401 with wrong credentials', async () => {
40 | const fastify = buildServer()
41 |
42 | const res = await fastify.inject({
43 | url: '/login',
44 | method: 'POST',
45 | body: {
46 | username: 'alice',
47 | password: 'wrong password',
48 | },
49 | })
50 |
51 | assert.equal(res.statusCode, 401)
52 | })
53 |
54 | await t.test('obtains a token with right credentials', async () => {
55 | const fastify = buildServer()
56 |
57 | fastify.jwt.sign.returns('jwt token')
58 |
59 | const res = await fastify.inject({
60 | url: '/login',
61 | method: 'POST',
62 | body: {
63 | username: 'alice',
64 | password: 'alice',
65 | },
66 | })
67 |
68 | assert.equal(res.statusCode, 200)
69 | assert.equal(res.json().token, 'jwt token')
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/src/step-08-authentication/test/users.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import buildServer from '../index.js'
5 |
6 | test('GET /users', async t => {
7 | await t.test('returns users', async () => {
8 | const fastify = buildServer()
9 |
10 | const res = await fastify.inject('/users')
11 |
12 | assert.equal(res.statusCode, 200)
13 |
14 | assert.deepEqual(res.json(), [
15 | { username: 'alice' },
16 | { username: 'bob' },
17 | ])
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/src/step-09-config/.env:
--------------------------------------------------------------------------------
1 | JWT_SECRET=supersecret
--------------------------------------------------------------------------------
/src/step-09-config/README.md:
--------------------------------------------------------------------------------
1 | # step-9
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 | - run the tests with `npm run test`
7 |
--------------------------------------------------------------------------------
/src/step-09-config/config.js:
--------------------------------------------------------------------------------
1 | import { join } from 'desm'
2 | import envSchema from 'env-schema'
3 | import S from 'fluent-json-schema'
4 |
5 | const schema = S.object()
6 | .prop('JWT_SECRET', S.string().required())
7 | .prop('LOG_LEVEL', S.string().default('info'))
8 | .prop('PRETTY_PRINT', S.string().default(true))
9 |
10 | export default envSchema({
11 | schema,
12 | dotenv: { path: join(import.meta.url, '.env') },
13 | })
14 |
--------------------------------------------------------------------------------
/src/step-09-config/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer(config) {
4 | const opts = {
5 | ...config,
6 | logger: {
7 | level: config.LOG_LEVEL,
8 | ...(config.PRETTY_PRINT && {
9 | transport: {
10 | target: 'pino-pretty',
11 | },
12 | }),
13 | },
14 | }
15 |
16 | const fastify = Fastify(opts)
17 |
18 | fastify.register(import('@fastify/jwt'), {
19 | secret: opts.JWT_SECRET,
20 | })
21 | fastify.register(import('./routes/login.js'))
22 | fastify.register(import('./routes/users.js'))
23 |
24 | fastify.log.info('Fastify is starting up!')
25 |
26 | return fastify
27 | }
28 |
29 | export default buildServer
30 |
--------------------------------------------------------------------------------
/src/step-09-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "config",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server",
9 | "test": "c8 --check-coverage --100 node --test"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/step-09-config/routes/login.js:
--------------------------------------------------------------------------------
1 | import errors from 'http-errors'
2 | import S from 'fluent-json-schema'
3 |
4 | const schema = {
5 | body: S.object()
6 | .prop('username', S.string().required())
7 | .prop('password', S.string().required()),
8 | response: {
9 | 200: S.object().prop('token', S.string().required()),
10 | },
11 | }
12 |
13 | export default async function login(fastify) {
14 | fastify.post('/login', { schema }, async req => {
15 | const { username, password } = req.body
16 |
17 | // sample auth check
18 | if (username !== password) {
19 | throw errors.Unauthorized()
20 | }
21 |
22 | return { token: fastify.jwt.sign({ username }) }
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/src/step-09-config/routes/users.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.array().items(
6 | S.object().prop('username', S.string().required()),
7 | ),
8 | },
9 | }
10 |
11 | export default async function users(fastify) {
12 | fastify.get('/users', { schema }, async req => {
13 | req.log.info('Users route called')
14 | return [{ username: 'alice' }, { username: 'bob' }]
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/src/step-09-config/server.js:
--------------------------------------------------------------------------------
1 | import config from './config.js'
2 |
3 | import buildServer from './index.js'
4 |
5 | const fastify = buildServer(config)
6 |
7 | const start = async function () {
8 | try {
9 | await fastify.listen({ port: 3000 })
10 | } catch (err) {
11 | fastify.log.error(err)
12 | process.exit(1)
13 | }
14 | }
15 |
16 | start()
17 |
--------------------------------------------------------------------------------
/src/step-09-config/test/login.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import sinon from 'sinon'
6 |
7 | function buildServer() {
8 | return fastify()
9 | .decorate('jwt', { sign: sinon.stub() })
10 | .register(import('../routes/login.js'))
11 | }
12 |
13 | test('POST /login', async t => {
14 | await t.test('returns 400 with missing credentials', async () => {
15 | const fastify = buildServer()
16 |
17 | const res = await fastify.inject({
18 | url: '/login',
19 | method: 'POST',
20 | })
21 |
22 | assert.equal(res.statusCode, 400)
23 | })
24 |
25 | await t.test('returns 400 with partial credentials', async () => {
26 | const fastify = buildServer()
27 |
28 | const res = await fastify.inject({
29 | url: '/login',
30 | method: 'POST',
31 | body: {
32 | username: 'alice',
33 | },
34 | })
35 |
36 | assert.equal(res.statusCode, 400)
37 | })
38 |
39 | await t.test('returns 401 with wrong credentials', async () => {
40 | const fastify = buildServer()
41 |
42 | const res = await fastify.inject({
43 | url: '/login',
44 | method: 'POST',
45 | body: {
46 | username: 'alice',
47 | password: 'wrong password',
48 | },
49 | })
50 |
51 | assert.equal(res.statusCode, 401)
52 | })
53 |
54 | await t.test('obtains a token with right credentials', async () => {
55 | const fastify = buildServer()
56 |
57 | fastify.jwt.sign.returns('jwt token')
58 |
59 | const res = await fastify.inject({
60 | url: '/login',
61 | method: 'POST',
62 | body: {
63 | username: 'alice',
64 | password: 'alice',
65 | },
66 | })
67 |
68 | assert.equal(res.statusCode, 200)
69 | assert.equal(res.json().token, 'jwt token')
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/src/step-09-config/test/users.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../config.js'
5 | import buildServer from '../index.js'
6 |
7 | test('GET /users', async t => {
8 | await t.test('returns users', async () => {
9 | const fastify = buildServer(config)
10 |
11 | const res = await fastify.inject('/users')
12 |
13 | assert.equal(res.statusCode, 200)
14 |
15 | assert.deepEqual(res.json(), [
16 | { username: 'alice' },
17 | { username: 'bob' },
18 | ])
19 | })
20 | })
21 |
--------------------------------------------------------------------------------
/src/step-10-decorators/.env:
--------------------------------------------------------------------------------
1 | JWT_SECRET=supersecret
--------------------------------------------------------------------------------
/src/step-10-decorators/README.md:
--------------------------------------------------------------------------------
1 | # step-10
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 | - run the tests with `npm run test`
7 |
--------------------------------------------------------------------------------
/src/step-10-decorators/config.js:
--------------------------------------------------------------------------------
1 | import { join } from 'desm'
2 | import envSchema from 'env-schema'
3 | import S from 'fluent-json-schema'
4 |
5 | const schema = S.object()
6 | .prop('JWT_SECRET', S.string().required())
7 | .prop('LOG_LEVEL', S.string().default('info'))
8 | .prop('PRETTY_PRINT', S.string().default(true))
9 |
10 | export default envSchema({
11 | schema,
12 | dotenv: { path: join(import.meta.url, '.env') },
13 | })
14 |
--------------------------------------------------------------------------------
/src/step-10-decorators/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer(config) {
4 | const opts = {
5 | ...config,
6 | logger: {
7 | level: config.LOG_LEVEL,
8 | ...(config.PRETTY_PRINT && {
9 | transport: {
10 | target: 'pino-pretty',
11 | },
12 | }),
13 | },
14 | }
15 |
16 | const fastify = Fastify(opts)
17 |
18 | fastify.register(import('./plugins/authenticate.js'), opts)
19 |
20 | fastify.register(import('./routes/login.js'))
21 | fastify.register(import('./routes/users.js'))
22 |
23 | fastify.log.info('Fastify is starting up!')
24 |
25 | return fastify
26 | }
27 |
28 | export default buildServer
29 |
--------------------------------------------------------------------------------
/src/step-10-decorators/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "decorators",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server",
9 | "test": "c8 --check-coverage --100 node --test"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/step-10-decorators/plugins/authenticate.js:
--------------------------------------------------------------------------------
1 | async function authenticate(fastify, opts) {
2 | fastify.register(import('@fastify/jwt'), {
3 | secret: opts.JWT_SECRET,
4 | })
5 |
6 | fastify.decorate('authenticate', async (req, reply) => {
7 | try {
8 | await req.jwtVerify()
9 | } catch (err) {
10 | reply.send(err)
11 | }
12 | })
13 | }
14 |
15 | authenticate[Symbol.for('skip-override')] = true
16 |
17 | export default authenticate
18 |
--------------------------------------------------------------------------------
/src/step-10-decorators/routes/login.js:
--------------------------------------------------------------------------------
1 | import errors from 'http-errors'
2 | import S from 'fluent-json-schema'
3 |
4 | const schema = {
5 | body: S.object()
6 | .prop('username', S.string().required())
7 | .prop('password', S.string().required()),
8 | response: {
9 | 200: S.object().prop('token', S.string().required()),
10 | },
11 | }
12 |
13 | export default async function login(fastify) {
14 | fastify.post('/login', { schema }, async req => {
15 | const { username, password } = req.body
16 |
17 | // sample auth check
18 | if (username !== password) {
19 | throw errors.Unauthorized()
20 | }
21 |
22 | return { token: fastify.jwt.sign({ username }) }
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/src/step-10-decorators/routes/users.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.array().items(
6 | S.object().prop('username', S.string().required()),
7 | ),
8 | },
9 | }
10 |
11 | export default async function users(fastify) {
12 | fastify.get('/users', { schema }, async req => {
13 | req.log.info('Users route called')
14 | return [{ username: 'alice' }, { username: 'bob' }]
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/src/step-10-decorators/server.js:
--------------------------------------------------------------------------------
1 | import config from './config.js'
2 |
3 | import buildServer from './index.js'
4 |
5 | const fastify = buildServer(config)
6 |
7 | const start = async function () {
8 | try {
9 | await fastify.listen({ port: 3000 })
10 | } catch (err) {
11 | fastify.log.error(err)
12 | process.exit(1)
13 | }
14 | }
15 |
16 | start()
17 |
--------------------------------------------------------------------------------
/src/step-10-decorators/test/authenticate.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import errors from 'http-errors'
6 | import sinon from 'sinon'
7 |
8 | function buildServer(opts) {
9 | return fastify().register(
10 | import('../plugins/authenticate.js'),
11 | opts,
12 | )
13 | }
14 |
15 | await test('authenticate', async t => {
16 | await t.test(
17 | 'replies with error when authentication fails',
18 | async () => {
19 | const fastify = await buildServer({
20 | JWT_SECRET: 'supersecret',
21 | })
22 |
23 | await fastify.ready()
24 |
25 | const error = errors.Unauthorized()
26 |
27 | const req = { jwtVerify: sinon.stub().rejects(error) }
28 | const reply = { send: sinon.stub() }
29 | await assert.doesNotReject(fastify.authenticate(req, reply))
30 | sinon.assert.calledWith(reply.send, error)
31 | },
32 | )
33 |
34 | await t.test(
35 | 'resolves successfully when authentication succeeds',
36 | async () => {
37 | const fastify = await buildServer({
38 | JWT_SECRET: 'supersecret',
39 | })
40 | const req = { jwtVerify: sinon.stub().resolves() }
41 | const reply = { send: sinon.stub() }
42 | await assert.doesNotReject(fastify.authenticate(req, reply))
43 | sinon.assert.notCalled(reply.send)
44 | },
45 | )
46 | })
47 |
--------------------------------------------------------------------------------
/src/step-10-decorators/test/index.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../config.js'
5 | import buildServer from '../index.js'
6 |
7 | test('Startup', async t => {
8 | await t.test('it registers the JWT plugin', async () => {
9 | const fastify = buildServer(config)
10 | await fastify.ready()
11 | assert.ok(fastify.jwt)
12 | })
13 | })
14 |
--------------------------------------------------------------------------------
/src/step-10-decorators/test/login.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import sinon from 'sinon'
6 |
7 | function buildServer() {
8 | return fastify()
9 | .decorate('jwt', { sign: sinon.stub() })
10 | .register(import('../routes/login.js'))
11 | }
12 |
13 | test('POST /login', async t => {
14 | await t.test('returns 400 with missing credentials', async () => {
15 | const fastify = buildServer()
16 |
17 | const res = await fastify.inject({
18 | url: '/login',
19 | method: 'POST',
20 | })
21 |
22 | assert.equal(res.statusCode, 400)
23 | })
24 |
25 | await t.test('returns 400 with partial credentials', async () => {
26 | const fastify = buildServer()
27 |
28 | const res = await fastify.inject({
29 | url: '/login',
30 | method: 'POST',
31 | body: {
32 | username: 'alice',
33 | },
34 | })
35 |
36 | assert.equal(res.statusCode, 400)
37 | })
38 |
39 | await t.test('returns 401 with wrong credentials', async () => {
40 | const fastify = buildServer()
41 |
42 | const res = await fastify.inject({
43 | url: '/login',
44 | method: 'POST',
45 | body: {
46 | username: 'alice',
47 | password: 'wrong password',
48 | },
49 | })
50 |
51 | assert.equal(res.statusCode, 401)
52 | })
53 |
54 | await t.test('obtains a token with right credentials', async () => {
55 | const fastify = buildServer()
56 |
57 | fastify.jwt.sign.returns('jwt token')
58 |
59 | const res = await fastify.inject({
60 | url: '/login',
61 | method: 'POST',
62 | body: {
63 | username: 'alice',
64 | password: 'alice',
65 | },
66 | })
67 |
68 | assert.equal(res.statusCode, 200)
69 | assert.equal(res.json().token, 'jwt token')
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/src/step-10-decorators/test/users.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../config.js'
5 | import buildServer from '../index.js'
6 |
7 | test('GET /users', async t => {
8 | await t.test('returns users', async () => {
9 | const fastify = buildServer(config)
10 |
11 | const res = await fastify.inject('/users')
12 |
13 | assert.equal(res.statusCode, 200)
14 |
15 | assert.deepEqual(res.json(), [
16 | { username: 'alice' },
17 | { username: 'bob' },
18 | ])
19 | })
20 | })
21 |
--------------------------------------------------------------------------------
/src/step-11-hooks/.env:
--------------------------------------------------------------------------------
1 | JWT_SECRET=supersecret
--------------------------------------------------------------------------------
/src/step-11-hooks/README.md:
--------------------------------------------------------------------------------
1 | # step-11
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 | - run the tests with `npm run test`
7 |
--------------------------------------------------------------------------------
/src/step-11-hooks/config.js:
--------------------------------------------------------------------------------
1 | import { join } from 'desm'
2 | import envSchema from 'env-schema'
3 | import S from 'fluent-json-schema'
4 |
5 | const schema = S.object()
6 | .prop('JWT_SECRET', S.string().required())
7 | .prop('LOG_LEVEL', S.string().default('info'))
8 | .prop('PRETTY_PRINT', S.string().default(true))
9 |
10 | export default envSchema({
11 | schema,
12 | dotenv: { path: join(import.meta.url, '.env') },
13 | })
14 |
--------------------------------------------------------------------------------
/src/step-11-hooks/index.js:
--------------------------------------------------------------------------------
1 | import Fastify from 'fastify'
2 |
3 | function buildServer(config) {
4 | const opts = {
5 | ...config,
6 | logger: {
7 | level: config.LOG_LEVEL,
8 | ...(config.PRETTY_PRINT && {
9 | transport: {
10 | target: 'pino-pretty',
11 | },
12 | }),
13 | },
14 | }
15 |
16 | const fastify = Fastify(opts)
17 |
18 | fastify.register(import('./plugins/authenticate.js'), opts)
19 |
20 | fastify.register(import('./routes/user/index.js'))
21 | fastify.register(import('./routes/login.js'))
22 | fastify.register(import('./routes/users.js'))
23 |
24 | fastify.log.info('Fastify is starting up!')
25 |
26 | return fastify
27 | }
28 |
29 | export default buildServer
30 |
--------------------------------------------------------------------------------
/src/step-11-hooks/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "hooks",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server",
9 | "test": "c8 --check-coverage --100 node --test"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/step-11-hooks/plugins/authenticate.js:
--------------------------------------------------------------------------------
1 | async function authenticate(fastify, opts) {
2 | fastify.register(import('@fastify/jwt'), {
3 | secret: opts.JWT_SECRET,
4 | })
5 |
6 | fastify.decorate('authenticate', async (req, reply) => {
7 | try {
8 | await req.jwtVerify()
9 | } catch (err) {
10 | reply.send(err)
11 | }
12 | })
13 | }
14 |
15 | authenticate[Symbol.for('skip-override')] = true
16 |
17 | export default authenticate
18 |
--------------------------------------------------------------------------------
/src/step-11-hooks/routes/login.js:
--------------------------------------------------------------------------------
1 | import errors from 'http-errors'
2 | import S from 'fluent-json-schema'
3 |
4 | const schema = {
5 | body: S.object()
6 | .prop('username', S.string().required())
7 | .prop('password', S.string().required()),
8 | response: {
9 | 200: S.object().prop('token', S.string().required()),
10 | },
11 | }
12 |
13 | export default async function login(fastify) {
14 | fastify.post('/login', { schema }, async req => {
15 | const { username, password } = req.body
16 |
17 | // sample auth check
18 | if (username !== password) {
19 | throw errors.Unauthorized()
20 | }
21 |
22 | return { token: fastify.jwt.sign({ username }) }
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/src/step-11-hooks/routes/user/index.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.object().prop('username', S.string().required()),
6 | },
7 | }
8 |
9 | export default async function user(fastify) {
10 | fastify.get(
11 | '/user',
12 | {
13 | onRequest: [fastify.authenticate],
14 | schema,
15 | },
16 | async req => req.user,
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/step-11-hooks/routes/users.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.array().items(
6 | S.object().prop('username', S.string().required()),
7 | ),
8 | },
9 | }
10 |
11 | export default async function users(fastify) {
12 | fastify.get('/users', { schema }, async req => {
13 | req.log.info('Users route called')
14 |
15 | return [{ username: 'alice' }, { username: 'bob' }]
16 | })
17 | }
18 |
--------------------------------------------------------------------------------
/src/step-11-hooks/server.js:
--------------------------------------------------------------------------------
1 | import config from './config.js'
2 |
3 | import buildServer from './index.js'
4 |
5 | const fastify = buildServer(config)
6 |
7 | const start = async function () {
8 | try {
9 | await fastify.listen({ port: 3000 })
10 | } catch (err) {
11 | fastify.log.error(err)
12 | process.exit(1)
13 | }
14 | }
15 |
16 | start()
17 |
--------------------------------------------------------------------------------
/src/step-11-hooks/test/unit/authenticate.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import errors from 'http-errors'
6 | import sinon from 'sinon'
7 |
8 | function buildServer(opts) {
9 | return fastify().register(
10 | import('../../plugins/authenticate.js'),
11 | opts,
12 | )
13 | }
14 |
15 | test('authenticate', async t => {
16 | await t.test(
17 | 'replies with error when authentication fails',
18 | async () => {
19 | const fastify = await buildServer({
20 | JWT_SECRET: 'supersecret',
21 | })
22 |
23 | const error = errors.Unauthorized()
24 | const req = { jwtVerify: sinon.stub().rejects(error) }
25 | const reply = { send: sinon.stub() }
26 |
27 | await assert.doesNotReject(fastify.authenticate(req, reply))
28 | sinon.assert.calledWith(reply.send, error)
29 | },
30 | )
31 |
32 | await t.test(
33 | 'resolves successfully when authentication succeeds',
34 | async () => {
35 | const fastify = await buildServer({
36 | JWT_SECRET: 'supersecret',
37 | })
38 |
39 | const req = { jwtVerify: sinon.stub().resolves() }
40 | const reply = { send: sinon.stub() }
41 |
42 | await assert.doesNotReject(fastify.authenticate(req, reply))
43 | sinon.assert.notCalled(reply.send)
44 | },
45 | )
46 | })
47 |
--------------------------------------------------------------------------------
/src/step-11-hooks/test/unit/index.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../../config.js'
5 | import buildServer from '../../index.js'
6 |
7 | test('Startup', async t => {
8 | await t.test('it registers the JWT plugin', async () => {
9 | const fastify = buildServer(config)
10 |
11 | await fastify.ready()
12 |
13 | assert.ok(fastify.jwt)
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/src/step-11-hooks/test/unit/login.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import sinon from 'sinon'
6 |
7 | function buildServer() {
8 | return fastify()
9 | .decorate('jwt', { sign: sinon.stub() })
10 | .register(import('../../routes/login.js'))
11 | }
12 |
13 | test('POST /login', async t => {
14 | await t.test('returns 400 with missing credentials', async () => {
15 | const fastify = buildServer()
16 |
17 | const res = await fastify.inject({
18 | url: '/login',
19 | method: 'POST',
20 | })
21 |
22 | assert.equal(res.statusCode, 400)
23 | })
24 |
25 | await t.test('returns 400 with partial credentials', async () => {
26 | const fastify = buildServer()
27 |
28 | const res = await fastify.inject({
29 | url: '/login',
30 | method: 'POST',
31 | body: {
32 | username: 'alice',
33 | },
34 | })
35 |
36 | assert.equal(res.statusCode, 400)
37 | })
38 |
39 | await t.test('returns 401 with wrong credentials', async () => {
40 | const fastify = buildServer()
41 |
42 | const res = await fastify.inject({
43 | url: '/login',
44 | method: 'POST',
45 | body: {
46 | username: 'alice',
47 | password: 'wrong password',
48 | },
49 | })
50 |
51 | assert.equal(res.statusCode, 401)
52 | })
53 |
54 | await t.test('obtains a token with right credentials', async () => {
55 | const fastify = buildServer()
56 |
57 | fastify.jwt.sign.returns('jwt token')
58 |
59 | const res = await fastify.inject({
60 | url: '/login',
61 | method: 'POST',
62 | body: {
63 | username: 'alice',
64 | password: 'alice',
65 | },
66 | })
67 |
68 | assert.equal(res.statusCode, 200)
69 | assert.equal(res.json().token, 'jwt token')
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/src/step-11-hooks/test/unit/user.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import errors from 'http-errors'
6 | import sinon from 'sinon'
7 |
8 | function buildServer() {
9 | return fastify()
10 | .decorate('authenticate', sinon.stub())
11 | .register(import('../../routes/user/index.js'))
12 | }
13 |
14 | test('GET /user', async t => {
15 | await t.test(
16 | 'returns error when authentication fails',
17 | async () => {
18 | const fastify = buildServer()
19 |
20 | fastify.authenticate.rejects(errors.Unauthorized())
21 |
22 | const res = await fastify.inject('/user')
23 |
24 | sinon.assert.called(fastify.authenticate)
25 | assert.equal(res.statusCode, 401)
26 | },
27 | )
28 |
29 | await t.test(
30 | 'returns current user when authentication succeeds',
31 | async () => {
32 | const fastify = buildServer()
33 |
34 | fastify.authenticate.callsFake(async request => {
35 | request.user = { username: 'alice' }
36 | })
37 |
38 | const res = await fastify.inject('/user')
39 |
40 | assert.equal(res.statusCode, 200)
41 | assert.deepEqual(res.json(), { username: 'alice' })
42 | },
43 | )
44 | })
45 |
--------------------------------------------------------------------------------
/src/step-11-hooks/test/unit/users.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../../config.js'
5 | import buildServer from '../../index.js'
6 |
7 | test('GET /users', async t => {
8 | await t.test('returns users', async () => {
9 | const fastify = buildServer(config)
10 |
11 | const res = await fastify.inject('/users')
12 |
13 | assert.equal(res.statusCode, 200)
14 |
15 | assert.deepEqual(res.json(), [
16 | { username: 'alice' },
17 | { username: 'bob' },
18 | ])
19 | })
20 | })
21 |
--------------------------------------------------------------------------------
/src/step-12-autoload/.env:
--------------------------------------------------------------------------------
1 | JWT_SECRET=supersecret
--------------------------------------------------------------------------------
/src/step-12-autoload/README.md:
--------------------------------------------------------------------------------
1 | # step-12
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 | - run the tests with `npm run test`
7 |
--------------------------------------------------------------------------------
/src/step-12-autoload/config.js:
--------------------------------------------------------------------------------
1 | import { join } from 'desm'
2 | import envSchema from 'env-schema'
3 | import S from 'fluent-json-schema'
4 |
5 | const schema = S.object()
6 | .prop('JWT_SECRET', S.string().required())
7 | .prop('LOG_LEVEL', S.string().default('info'))
8 | .prop('PRETTY_PRINT', S.string().default(true))
9 |
10 | export default envSchema({
11 | schema,
12 | dotenv: { path: join(import.meta.url, '.env') },
13 | })
14 |
--------------------------------------------------------------------------------
/src/step-12-autoload/index.js:
--------------------------------------------------------------------------------
1 | import { join } from 'desm'
2 | import Fastify from 'fastify'
3 | import autoload from '@fastify/autoload'
4 |
5 | function buildServer(config) {
6 | const opts = {
7 | ...config,
8 | logger: {
9 | level: config.LOG_LEVEL,
10 | ...(config.PRETTY_PRINT && {
11 | transport: {
12 | target: 'pino-pretty',
13 | },
14 | }),
15 | },
16 | }
17 |
18 | const fastify = Fastify(opts)
19 |
20 | fastify.register(autoload, {
21 | dir: join(import.meta.url, 'plugins'),
22 | options: opts,
23 | })
24 |
25 | fastify.register(autoload, {
26 | dir: join(import.meta.url, 'routes'),
27 | options: opts,
28 | })
29 |
30 | fastify.log.info('Fastify is starting up!')
31 |
32 | return fastify
33 | }
34 |
35 | export default buildServer
36 |
--------------------------------------------------------------------------------
/src/step-12-autoload/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "autoload",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server",
9 | "test": "c8 --check-coverage --100 node --test"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/step-12-autoload/plugins/authenticate.js:
--------------------------------------------------------------------------------
1 | async function authenticate(fastify, opts) {
2 | fastify.register(import('@fastify/jwt'), {
3 | secret: opts.JWT_SECRET,
4 | })
5 |
6 | fastify.decorate('authenticate', async (req, reply) => {
7 | try {
8 | await req.jwtVerify()
9 | } catch (err) {
10 | reply.send(err)
11 | }
12 | })
13 | }
14 |
15 | authenticate[Symbol.for('skip-override')] = true
16 |
17 | export default authenticate
18 |
--------------------------------------------------------------------------------
/src/step-12-autoload/routes/login.js:
--------------------------------------------------------------------------------
1 | import errors from 'http-errors'
2 | import S from 'fluent-json-schema'
3 |
4 | const schema = {
5 | body: S.object()
6 | .prop('username', S.string().required())
7 | .prop('password', S.string().required()),
8 | response: {
9 | 200: S.object().prop('token', S.string().required()),
10 | },
11 | }
12 |
13 | export default async function login(fastify) {
14 | fastify.post('/login', { schema }, async req => {
15 | const { username, password } = req.body
16 |
17 | // sample auth check
18 | if (username !== password) {
19 | throw errors.Unauthorized()
20 | }
21 |
22 | return { token: fastify.jwt.sign({ username }) }
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/src/step-12-autoload/routes/user/index.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.object().prop('username', S.string().required()),
6 | },
7 | }
8 |
9 | export default async function user(fastify) {
10 | fastify.get(
11 | '/',
12 | {
13 | onRequest: [fastify.authenticate],
14 | schema,
15 | },
16 | async req => req.user,
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/step-12-autoload/routes/users.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.array().items(
6 | S.object().prop('username', S.string().required()),
7 | ),
8 | },
9 | }
10 |
11 | export default async function users(fastify) {
12 | fastify.get('/users', { schema }, async req => {
13 | req.log.info('Users route called')
14 | return [{ username: 'alice' }, { username: 'bob' }]
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/src/step-12-autoload/server.js:
--------------------------------------------------------------------------------
1 | import config from './config.js'
2 |
3 | import buildServer from './index.js'
4 |
5 | const fastify = buildServer(config)
6 |
7 | const start = async function () {
8 | try {
9 | await fastify.listen({ port: 3000 })
10 | } catch (err) {
11 | fastify.log.error(err)
12 | process.exit(1)
13 | }
14 | }
15 |
16 | start()
17 |
--------------------------------------------------------------------------------
/src/step-12-autoload/test/integration/index.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../../config.js'
5 | import buildServer from '../../index.js'
6 |
7 | test('server', async t => {
8 | let fastify
9 |
10 | t.beforeEach(async () => {
11 | fastify = buildServer(config)
12 | })
13 |
14 | t.afterEach(() => fastify && fastify.close())
15 |
16 | await t.test(
17 | 'authenticates user and returns current user',
18 | async () => {
19 | const loginRes = await fastify.inject({
20 | url: '/login',
21 | method: 'POST',
22 | body: {
23 | username: 'alice',
24 | password: 'alice',
25 | },
26 | })
27 |
28 | const { token } = await loginRes.json()
29 |
30 | assert.equal(loginRes.statusCode, 200)
31 | assert.equal(typeof token, 'string')
32 |
33 | const userRes = await fastify.inject({
34 | url: '/user',
35 | headers: {
36 | authorization: `bearer ${token}`,
37 | },
38 | })
39 |
40 | assert.equal(userRes.statusCode, 200)
41 | assert.deepEqual(userRes.json(), { username: 'alice' })
42 | },
43 | )
44 | })
45 |
--------------------------------------------------------------------------------
/src/step-12-autoload/test/unit/authenticate.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import errors from 'http-errors'
6 | import sinon from 'sinon'
7 |
8 | function buildServer(opts) {
9 | return fastify().register(
10 | import('../../plugins/authenticate.js'),
11 | opts,
12 | )
13 | }
14 |
15 | test('authenticate', async t => {
16 | await t.test(
17 | 'replies with error when authentication fails',
18 | async () => {
19 | const fastify = await buildServer({
20 | JWT_SECRET: 'supersecret',
21 | })
22 |
23 | const error = errors.Unauthorized()
24 | const req = { jwtVerify: sinon.stub().rejects(error) }
25 | const reply = { send: sinon.stub() }
26 |
27 | await assert.doesNotReject(fastify.authenticate(req, reply))
28 | sinon.assert.calledWith(reply.send, error)
29 | },
30 | )
31 |
32 | await t.test(
33 | 'resolves successfully when authentication succeeds',
34 | async () => {
35 | const fastify = await buildServer({
36 | JWT_SECRET: 'supersecret',
37 | })
38 |
39 | const req = { jwtVerify: sinon.stub().resolves() }
40 | const reply = { send: sinon.stub() }
41 |
42 | await assert.doesNotReject(fastify.authenticate(req, reply))
43 | sinon.assert.notCalled(reply.send)
44 | },
45 | )
46 | })
47 |
--------------------------------------------------------------------------------
/src/step-12-autoload/test/unit/index.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../../config.js'
5 | import buildServer from '../../index.js'
6 |
7 | test('Startup', async t => {
8 | await t.test('it registers the JWT plugin', async () => {
9 | const fastify = buildServer(config)
10 |
11 | await fastify.ready()
12 |
13 | assert.ok(fastify.jwt)
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/src/step-12-autoload/test/unit/login.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import sinon from 'sinon'
6 |
7 | function buildServer() {
8 | return fastify()
9 | .decorate('jwt', { sign: sinon.stub() })
10 | .register(import('../../routes/login.js'))
11 | }
12 |
13 | test('POST /login', async t => {
14 | await t.test('returns 400 with missing credentials', async () => {
15 | const fastify = buildServer()
16 |
17 | const res = await fastify.inject({
18 | url: '/login',
19 | method: 'POST',
20 | })
21 |
22 | assert.equal(res.statusCode, 400)
23 | })
24 |
25 | await t.test('returns 400 with partial credentials', async () => {
26 | const fastify = buildServer()
27 |
28 | const res = await fastify.inject({
29 | url: '/login',
30 | method: 'POST',
31 | body: {
32 | username: 'alice',
33 | },
34 | })
35 |
36 | assert.equal(res.statusCode, 400)
37 | })
38 |
39 | await t.test('returns 401 with wrong credentials', async () => {
40 | const fastify = buildServer()
41 |
42 | const res = await fastify.inject({
43 | url: '/login',
44 | method: 'POST',
45 | body: {
46 | username: 'alice',
47 | password: 'wrong password',
48 | },
49 | })
50 |
51 | assert.equal(res.statusCode, 401)
52 | })
53 |
54 | await t.test('obtains a token with right credentials', async () => {
55 | const fastify = buildServer()
56 |
57 | fastify.jwt.sign.returns('jwt token')
58 |
59 | const res = await fastify.inject({
60 | url: '/login',
61 | method: 'POST',
62 | body: {
63 | username: 'alice',
64 | password: 'alice',
65 | },
66 | })
67 |
68 | assert.equal(res.statusCode, 200)
69 | assert.equal(res.json().token, 'jwt token')
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/src/step-12-autoload/test/unit/user.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import errors from 'http-errors'
6 | import sinon from 'sinon'
7 |
8 | function buildServer() {
9 | return fastify()
10 | .decorate('authenticate', sinon.stub())
11 | .register(import('../../routes/user/index.js'))
12 | }
13 |
14 | test('GET /', async t => {
15 | await t.test(
16 | 'returns error when authentication fails',
17 | async () => {
18 | const fastify = buildServer()
19 |
20 | fastify.authenticate.rejects(errors.Unauthorized())
21 |
22 | const res = await fastify.inject('/')
23 |
24 | sinon.assert.called(fastify.authenticate)
25 | assert.equal(res.statusCode, 401)
26 | },
27 | )
28 |
29 | await t.test(
30 | 'returns current user when authentication succeeds',
31 | async () => {
32 | const fastify = buildServer()
33 |
34 | fastify.authenticate.callsFake(async request => {
35 | request.user = { username: 'alice' }
36 | })
37 |
38 | const res = await fastify.inject('/')
39 |
40 | assert.equal(res.statusCode, 200)
41 | assert.deepEqual(res.json(), { username: 'alice' })
42 | },
43 | )
44 | })
45 |
--------------------------------------------------------------------------------
/src/step-12-autoload/test/unit/users.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../../config.js'
5 | import buildServer from '../../index.js'
6 |
7 | test('GET /users', async t => {
8 | await t.test('returns users', async () => {
9 | const fastify = buildServer(config)
10 |
11 | const res = await fastify.inject('/users')
12 |
13 | assert.equal(res.statusCode, 200)
14 |
15 | assert.deepEqual(res.json(), [
16 | { username: 'alice' },
17 | { username: 'bob' },
18 | ])
19 | })
20 | })
21 |
--------------------------------------------------------------------------------
/src/step-13-database/.env:
--------------------------------------------------------------------------------
1 | PG_CONNECTION_STRING=postgres://postgres:postgres@0.0.0.0:5433/postgres
2 | JWT_SECRET=supersecret
3 |
--------------------------------------------------------------------------------
/src/step-13-database/README.md:
--------------------------------------------------------------------------------
1 | # step-13
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 | - run the tests with `npm run test`
7 |
--------------------------------------------------------------------------------
/src/step-13-database/config.js:
--------------------------------------------------------------------------------
1 | import { join } from 'desm'
2 | import envSchema from 'env-schema'
3 | import S from 'fluent-json-schema'
4 |
5 | const schema = S.object()
6 | .prop('PG_CONNECTION_STRING', S.string().required())
7 | .prop('JWT_SECRET', S.string().required())
8 | .prop('LOG_LEVEL', S.string().default('info'))
9 | .prop('PRETTY_PRINT', S.string().default(true))
10 |
11 | export default envSchema({
12 | schema,
13 | dotenv: { path: join(import.meta.url, '.env') },
14 | })
15 |
--------------------------------------------------------------------------------
/src/step-13-database/index.js:
--------------------------------------------------------------------------------
1 | import { join } from 'desm'
2 | import Fastify from 'fastify'
3 | import autoload from '@fastify/autoload'
4 |
5 | function buildServer(config) {
6 | const opts = {
7 | ...config,
8 | logger: {
9 | level: config.LOG_LEVEL,
10 | ...(config.PRETTY_PRINT && {
11 | transport: {
12 | target: 'pino-pretty',
13 | },
14 | }),
15 | },
16 | }
17 |
18 | const fastify = Fastify(opts)
19 |
20 | fastify.register(import('@fastify/postgres'), {
21 | connectionString: opts.PG_CONNECTION_STRING,
22 | })
23 |
24 | fastify.register(autoload, {
25 | dir: join(import.meta.url, 'plugins'),
26 | options: opts,
27 | })
28 |
29 | fastify.register(autoload, {
30 | dir: join(import.meta.url, 'routes'),
31 | options: opts,
32 | })
33 |
34 | fastify.log.info('Fastify is starting up!')
35 |
36 | return fastify
37 | }
38 |
39 | export default buildServer
40 |
--------------------------------------------------------------------------------
/src/step-13-database/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "database",
4 | "main": "server.js",
5 | "type": "module",
6 | "version": "1.0.0",
7 | "scripts": {
8 | "start": "node server",
9 | "test": "c8 --check-coverage --100 node --test"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/step-13-database/plugins/authenticate.js:
--------------------------------------------------------------------------------
1 | async function authenticate(fastify, opts) {
2 | fastify.register(import('@fastify/jwt'), {
3 | secret: opts.JWT_SECRET,
4 | })
5 |
6 | fastify.decorate('authenticate', async (req, reply) => {
7 | try {
8 | await req.jwtVerify()
9 | } catch (err) {
10 | reply.send(err)
11 | }
12 | })
13 | }
14 |
15 | authenticate[Symbol.for('skip-override')] = true
16 |
17 | export default authenticate
18 |
--------------------------------------------------------------------------------
/src/step-13-database/routes/login.js:
--------------------------------------------------------------------------------
1 | import errors from 'http-errors'
2 | import S from 'fluent-json-schema'
3 | import SQL from '@nearform/sql'
4 |
5 | const schema = {
6 | body: S.object()
7 | .prop('username', S.string().required())
8 | .prop('password', S.string().required()),
9 | response: {
10 | 200: S.object().prop('token', S.string().required()),
11 | },
12 | }
13 |
14 | export default async function login(fastify) {
15 | fastify.post('/login', { schema }, async req => {
16 | const { username, password } = req.body
17 |
18 | // sample auth check
19 | if (username !== password) {
20 | throw errors.Unauthorized()
21 | }
22 |
23 | const {
24 | rows: [user],
25 | } = await fastify.pg.query(
26 | SQL`SELECT id, username FROM users WHERE username = ${username}`,
27 | )
28 |
29 | if (!user) {
30 | throw errors.Unauthorized()
31 | }
32 |
33 | return { token: fastify.jwt.sign({ username }) }
34 | })
35 | }
36 |
--------------------------------------------------------------------------------
/src/step-13-database/routes/user/index.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.object().prop('username', S.string().required()),
6 | },
7 | }
8 |
9 | export default async function user(fastify) {
10 | fastify.get(
11 | '/',
12 | {
13 | onRequest: [fastify.authenticate],
14 | schema,
15 | },
16 | async req => req.user,
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/step-13-database/routes/users/index.js:
--------------------------------------------------------------------------------
1 | import S from 'fluent-json-schema'
2 |
3 | const schema = {
4 | response: {
5 | 200: S.array().items(
6 | S.object()
7 | .prop('id', S.integer().required())
8 | .prop('username', S.string().required()),
9 | ),
10 | },
11 | }
12 |
13 | export default async function users(fastify) {
14 | fastify.get(
15 | '/',
16 | { onRequest: [fastify.authenticate], schema },
17 | async req => {
18 | req.log.info('Users route called')
19 |
20 | const { rows: users } = await fastify.pg.query(
21 | 'SELECT id, username FROM users',
22 | )
23 |
24 | return users
25 | },
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/step-13-database/server.js:
--------------------------------------------------------------------------------
1 | import config from './config.js'
2 |
3 | import buildServer from './index.js'
4 |
5 | const fastify = buildServer(config)
6 |
7 | const start = async function () {
8 | try {
9 | await fastify.listen({ port: 3000 })
10 | } catch (err) {
11 | fastify.log.error(err)
12 | process.exit(1)
13 | }
14 | }
15 |
16 | start()
17 |
--------------------------------------------------------------------------------
/src/step-13-database/test/integration/index.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../../config.js'
5 | import buildServer from '../../index.js'
6 |
7 | test('server', async t => {
8 | let fastify
9 |
10 | t.beforeEach(async () => {
11 | fastify = buildServer(config)
12 | })
13 |
14 | t.afterEach(() => fastify.close())
15 |
16 | await t.test('authenticates users and lists users', async () => {
17 | const loginRes = await fastify.inject({
18 | url: '/login',
19 | method: 'POST',
20 | body: {
21 | username: 'alice',
22 | password: 'alice',
23 | },
24 | })
25 |
26 | const { token } = await loginRes.json()
27 |
28 | assert.equal(loginRes.statusCode, 200)
29 | assert.equal(typeof token, 'string')
30 |
31 | const usersRes = await fastify.inject({
32 | url: '/users',
33 | headers: {
34 | authorization: `bearer ${token}`,
35 | },
36 | })
37 |
38 | assert.equal(usersRes.statusCode, 200)
39 |
40 | const users = await usersRes.json()
41 |
42 | assert.ok(Array.isArray(users))
43 |
44 | assert.deepEqual(users, [
45 | { id: 1, username: 'alice' },
46 | { id: 2, username: 'bob' },
47 | ])
48 |
49 | fastify.close()
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/src/step-13-database/test/unit/authenticate.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import errors from 'http-errors'
6 | import sinon from 'sinon'
7 |
8 | function buildServer(opts) {
9 | return fastify().register(
10 | import('../../plugins/authenticate.js'),
11 | opts,
12 | )
13 | }
14 |
15 | test('authenticate', async t => {
16 | await t.test(
17 | 'replies with error when authentication fails',
18 | async () => {
19 | const fastify = await buildServer({
20 | JWT_SECRET: 'supersecret',
21 | })
22 |
23 | const error = errors.Unauthorized()
24 | const req = { jwtVerify: sinon.stub().rejects(error) }
25 | const reply = { send: sinon.stub() }
26 |
27 | await assert.doesNotReject(fastify.authenticate(req, reply))
28 | sinon.assert.calledWith(reply.send, error)
29 | await fastify.close()
30 | },
31 | )
32 |
33 | await t.test(
34 | 'resolves successfully when authentication succeeds',
35 | async () => {
36 | const fastify = await buildServer({
37 | JWT_SECRET: 'supersecret',
38 | })
39 |
40 | const req = { jwtVerify: sinon.stub().resolves() }
41 | const reply = { send: sinon.stub() }
42 |
43 | await assert.doesNotReject(fastify.authenticate(req, reply))
44 | sinon.assert.notCalled(reply.send)
45 | await fastify.close()
46 | },
47 | )
48 | })
49 |
--------------------------------------------------------------------------------
/src/step-13-database/test/unit/index.test.js:
--------------------------------------------------------------------------------
1 | import { test } from 'node:test'
2 | import assert from 'node:assert/strict'
3 |
4 | import buildServer from '../../index.js'
5 | import config from '../../config.js'
6 |
7 | test('Startup', async t => {
8 | await t.test('it registers the JWT plugin', async () => {
9 | const fastify = buildServer(config)
10 |
11 | await fastify.ready()
12 |
13 | assert.ok(fastify.jwt)
14 | await fastify.close()
15 | })
16 | })
17 |
--------------------------------------------------------------------------------
/src/step-13-database/test/unit/login.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import sinon from 'sinon'
6 |
7 | function buildServer() {
8 | return fastify()
9 | .decorate('pg', { query: sinon.stub() })
10 | .decorate('jwt', { sign: sinon.stub() })
11 | .register(import('../../routes/login.js'))
12 | }
13 |
14 | test('POST /login', async t => {
15 | await t.test('returns 400 with missing credentials', async () => {
16 | const fastify = buildServer()
17 |
18 | const res = await fastify.inject({
19 | url: '/login',
20 | method: 'POST',
21 | })
22 |
23 | assert.equal(res.statusCode, 400)
24 | await fastify.close()
25 | })
26 |
27 | await t.test('returns 400 with partial credentials', async () => {
28 | const fastify = buildServer()
29 |
30 | const res = await fastify.inject({
31 | url: '/login',
32 | method: 'POST',
33 | body: {
34 | username: 'alice',
35 | },
36 | })
37 |
38 | assert.equal(res.statusCode, 400)
39 | await fastify.close()
40 | })
41 |
42 | await t.test('returns 401 with wrong credentials', async () => {
43 | const fastify = buildServer()
44 |
45 | const res = await fastify.inject({
46 | url: '/login',
47 | method: 'POST',
48 | body: {
49 | username: 'alice',
50 | password: 'wrong password',
51 | },
52 | })
53 |
54 | assert.equal(res.statusCode, 401)
55 | await fastify.close()
56 | })
57 |
58 | await t.test(
59 | 'returns 401 when user is not found in database',
60 | async () => {
61 | const fastify = buildServer()
62 |
63 | fastify.pg.query.resolves({ rows: [] })
64 |
65 | const res = await fastify.inject({
66 | url: '/login',
67 | method: 'POST',
68 | body: {
69 | username: 'alice',
70 | password: 'alice',
71 | },
72 | })
73 |
74 | assert.equal(res.statusCode, 401)
75 | await fastify.close()
76 | },
77 | )
78 |
79 | await t.test('returns 500 when database errors', async () => {
80 | const fastify = buildServer()
81 |
82 | fastify.pg.query.rejects(new Error('boom'))
83 |
84 | const res = await fastify.inject({
85 | url: '/login',
86 | method: 'POST',
87 | body: {
88 | username: 'alice',
89 | password: 'alice',
90 | },
91 | })
92 |
93 | assert.equal(res.statusCode, 500)
94 | await fastify.close()
95 | })
96 |
97 | await t.test('obtains a token with right credentials', async () => {
98 | const fastify = buildServer()
99 |
100 | fastify.pg.query.resolves({
101 | rows: [{ id: 1, username: 'alice' }],
102 | })
103 | fastify.jwt.sign.returns('jwt token')
104 |
105 | const res = await fastify.inject({
106 | url: '/login',
107 | method: 'POST',
108 | body: {
109 | username: 'alice',
110 | password: 'alice',
111 | },
112 | })
113 |
114 | assert.equal(res.statusCode, 200)
115 | assert.equal(res.json().token, 'jwt token')
116 | await fastify.close()
117 | })
118 |
119 | await t.test('stores the signed JWT', async () => {
120 | const fastify = buildServer()
121 |
122 | fastify.pg.query.resolves({
123 | rows: [{ id: 1, username: 'alice' }],
124 | })
125 | fastify.jwt.sign.returns('jwt token')
126 |
127 | await fastify.inject({
128 | url: '/login',
129 | method: 'POST',
130 | body: {
131 | username: 'alice',
132 | password: 'alice',
133 | },
134 | })
135 |
136 | sinon.assert.called(fastify.jwt.sign)
137 | sinon.assert.calledWith(fastify.jwt.sign, {
138 | username: 'alice',
139 | })
140 | await fastify.close()
141 | })
142 | })
143 |
--------------------------------------------------------------------------------
/src/step-13-database/test/unit/user.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import errors from 'http-errors'
6 | import sinon from 'sinon'
7 |
8 | function buildServer() {
9 | return fastify()
10 | .decorate('authenticate', sinon.stub())
11 | .register(import('../../routes/user/index.js'))
12 | }
13 |
14 | test('GET /', async t => {
15 | await t.test(
16 | 'returns error when authentication fails',
17 | async () => {
18 | const fastify = buildServer()
19 |
20 | fastify.authenticate.rejects(errors.Unauthorized())
21 |
22 | const res = await fastify.inject('/')
23 |
24 | sinon.assert.called(fastify.authenticate)
25 | assert.equal(res.statusCode, 401)
26 | await fastify.close()
27 | },
28 | )
29 |
30 | await t.test(
31 | 'returns current user when authentication succeeds',
32 | async () => {
33 | const fastify = buildServer()
34 |
35 | fastify.authenticate.callsFake(async request => {
36 | request.user = { username: 'alice' }
37 | })
38 |
39 | const res = await fastify.inject('/')
40 |
41 | assert.equal(res.statusCode, 200)
42 | assert.deepEqual(res.json(), { username: 'alice' })
43 | await fastify.close()
44 | },
45 | )
46 | })
47 |
--------------------------------------------------------------------------------
/src/step-13-database/test/unit/users.test.js:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify from 'fastify'
5 | import errors from 'http-errors'
6 | import sinon from 'sinon'
7 |
8 | function buildServer() {
9 | return fastify()
10 | .decorate('pg', { query: sinon.stub() })
11 | .decorate('authenticate', sinon.stub())
12 | .register(import('../../routes/users/index.js'))
13 | }
14 |
15 | test('GET /', async t => {
16 | await t.test(
17 | 'returns error when authentication fails',
18 | async () => {
19 | const fastify = buildServer()
20 |
21 | fastify.authenticate.rejects(errors.Unauthorized())
22 |
23 | const res = await fastify.inject('/')
24 |
25 | sinon.assert.called(fastify.authenticate)
26 | assert.equal(res.statusCode, 401)
27 | await fastify.close()
28 | },
29 | )
30 |
31 | await t.test('returns error when database errors', async () => {
32 | const fastify = buildServer()
33 |
34 | fastify.authenticate.resolves()
35 | fastify.pg.query.rejects(new Error('database error'))
36 |
37 | const res = await fastify.inject('/')
38 |
39 | assert.equal(res.statusCode, 500)
40 | await fastify.close()
41 | })
42 |
43 | await t.test('returns users loaded from the database', async () => {
44 | const fastify = buildServer()
45 |
46 | fastify.authenticate.resolves()
47 | fastify.pg.query.resolves({
48 | rows: [{ id: 1, username: 'alice' }],
49 | })
50 |
51 | const res = await fastify.inject('/')
52 |
53 | assert.equal(res.statusCode, 200)
54 | assert.deepEqual(res.json(), [{ id: 1, username: 'alice' }])
55 | await fastify.close()
56 | })
57 | })
58 |
--------------------------------------------------------------------------------
/src/step-14-typescript/.env:
--------------------------------------------------------------------------------
1 | JWT_SECRET=supersecret
2 |
--------------------------------------------------------------------------------
/src/step-14-typescript/@types/index.d.ts:
--------------------------------------------------------------------------------
1 | import type { FastifyRequest, FastifyReply } from 'fastify'
2 |
3 | declare module 'fastify' {
4 | export interface FastifyInstance {
5 | authenticate: (
6 | request: FastifyRequest,
7 | reply: FastifyReply,
8 | ) => Promise
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-14-typescript/README.md:
--------------------------------------------------------------------------------
1 | # step-14
2 |
3 | ## Setup
4 |
5 | - start the server with `npm run start`
6 | - run the tests with `npm run test`
7 |
--------------------------------------------------------------------------------
/src/step-14-typescript/config.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path'
2 |
3 | import envSchema from 'env-schema'
4 | import S from 'fluent-json-schema'
5 |
6 | const schema = S.object()
7 | .prop('JWT_SECRET', S.string().required())
8 | .prop('LOG_LEVEL', S.string().default('info'))
9 | .prop('PRETTY_PRINT', S.string().default(true))
10 | .valueOf()
11 |
12 | export default envSchema({
13 | schema,
14 | dotenv: { path: join(__dirname, '.env') },
15 | })
16 |
17 | export type EnvConfig = {
18 | JWT_SECRET: string
19 | PRETTY_PRINT: boolean
20 | LOG_LEVEL: string
21 | }
22 |
--------------------------------------------------------------------------------
/src/step-14-typescript/index.ts:
--------------------------------------------------------------------------------
1 | import Fastify, { FastifyInstance } from 'fastify'
2 |
3 | import loginRoute from './routes/login'
4 | import usersRoute from './routes/users'
5 | import authenticatePlugin from './plugins/authenticate'
6 | import type { EnvConfig } from './config'
7 |
8 | function buildServer(config: EnvConfig): FastifyInstance {
9 | const opts = {
10 | logger: {
11 | level: config.LOG_LEVEL,
12 | ...(config.PRETTY_PRINT && {
13 | transport: {
14 | target: 'pino-pretty',
15 | },
16 | }),
17 | },
18 | }
19 |
20 | const fastify = Fastify(opts)
21 |
22 | fastify.register(authenticatePlugin, config)
23 | fastify.register(loginRoute)
24 | fastify.register(usersRoute)
25 |
26 | fastify.log.info('Fastify is starting up!')
27 |
28 | return fastify
29 | }
30 |
31 | export default buildServer
32 |
--------------------------------------------------------------------------------
/src/step-14-typescript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "license": "CC-BY-SA-4.0",
3 | "name": "typescript-t",
4 | "main": "server.ts",
5 | "version": "1.0.0",
6 | "scripts": {
7 | "start": "ts-node-dev --project ./tsconfig.json server.ts",
8 | "test": "npx c8 node --test -r ts-node/register ./test/*.test.ts"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/step-14-typescript/plugins/authenticate.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FastifyInstance,
3 | FastifyPluginOptions,
4 | FastifyReply,
5 | FastifyRequest,
6 | } from 'fastify'
7 | import fastifyJwt from '@fastify/jwt'
8 | import fp from 'fastify-plugin'
9 |
10 | async function authenticate(
11 | fastify: FastifyInstance,
12 | opts: FastifyPluginOptions,
13 | ): Promise {
14 | fastify.register(fastifyJwt, {
15 | secret: opts.JWT_SECRET,
16 | })
17 |
18 | fastify.decorate(
19 | 'authenticate',
20 | async (req: FastifyRequest, reply: FastifyReply) => {
21 | try {
22 | await req.jwtVerify()
23 | } catch (err) {
24 | reply.send(err)
25 | }
26 | },
27 | )
28 | }
29 |
30 | export default fp(authenticate)
31 |
--------------------------------------------------------------------------------
/src/step-14-typescript/routes/login.ts:
--------------------------------------------------------------------------------
1 | import { Type, Static } from '@sinclair/typebox'
2 | import { FastifyInstance, FastifyRequest } from 'fastify'
3 | import errors from 'http-errors'
4 |
5 | const BodySchema = Type.Object({
6 | username: Type.String(),
7 | password: Type.String(),
8 | })
9 |
10 | type BodySchema = Static
11 |
12 | const ResponseSchema = Type.Object({
13 | token: Type.String(),
14 | })
15 |
16 | type ResponseSchema = Static
17 |
18 | const schema = {
19 | body: BodySchema,
20 | response: {
21 | 200: ResponseSchema,
22 | },
23 | }
24 |
25 | export default async function login(fastify: FastifyInstance) {
26 | fastify.post(
27 | '/login',
28 | { schema },
29 | async (
30 | req: FastifyRequest<{ Body: BodySchema }>,
31 | ): Promise => {
32 | const { username, password } = req.body
33 |
34 | if (username !== password) {
35 | throw new errors.Unauthorized()
36 | }
37 |
38 | return { token: fastify.jwt.sign({ username }) }
39 | },
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/src/step-14-typescript/routes/users.ts:
--------------------------------------------------------------------------------
1 | import { Static, Type } from '@sinclair/typebox'
2 | import { FastifyInstance, FastifyRequest } from 'fastify'
3 |
4 | const ResponseSchema = Type.Array(
5 | Type.Object({
6 | username: Type.String(),
7 | }),
8 | )
9 |
10 | type ResponseSchema = Static
11 |
12 | const schema = {
13 | response: {
14 | 200: ResponseSchema,
15 | },
16 | }
17 |
18 | export default async function users(fastify: FastifyInstance) {
19 | fastify.get(
20 | '/users',
21 | { schema },
22 | async (req: FastifyRequest): Promise => {
23 | req.log.info('Users route called')
24 |
25 | return [{ username: 'alice' }, { username: 'bob' }]
26 | },
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/src/step-14-typescript/server.ts:
--------------------------------------------------------------------------------
1 | import configSchema from './config'
2 |
3 | import buildServer from './index'
4 |
5 | const fastify = buildServer(configSchema)
6 |
7 | const start = async function (): Promise {
8 | try {
9 | await fastify.listen({ port: 3000 })
10 | } catch (err) {
11 | fastify.log.error(err)
12 | process.exit(1)
13 | }
14 | }
15 |
16 | start()
17 |
--------------------------------------------------------------------------------
/src/step-14-typescript/test/authenticate.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../config'
5 | import buildServer from '../index'
6 |
7 | test('Startup', async t => {
8 | await t.test('it registers the JWT plugin', async () => {
9 | const fastify = buildServer(config)
10 |
11 | await fastify.ready()
12 |
13 | assert.ok(fastify.jwt)
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/src/step-14-typescript/test/index.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import errors from 'http-errors'
5 | import sinon, { SinonStub } from 'sinon'
6 | import fastify, {
7 | FastifyInstance,
8 | FastifyReply,
9 | FastifyRequest,
10 | } from 'fastify'
11 |
12 | import pluginAuthenticate from '../plugins/authenticate'
13 |
14 | async function buildServer(opts: {
15 | JWT_SECRET: string
16 | }): Promise {
17 | const app = fastify()
18 | await app.register(pluginAuthenticate, opts)
19 | return app
20 | }
21 |
22 | test('authenticate', async t => {
23 | await t.test(
24 | 'replies with error when authentication fails',
25 | async () => {
26 | const fastify = await buildServer({
27 | JWT_SECRET: 'supersecret',
28 | })
29 | const error = new errors.Unauthorized()
30 | const req = {}
31 | req.jwtVerify = sinon.stub().rejects(error)
32 | const reply = {}
33 | reply.send = sinon.stub()
34 |
35 | await assert.doesNotReject(fastify.authenticate(req, reply))
36 | sinon.assert.calledWith(reply.send, error)
37 | },
38 | )
39 |
40 | await t.test(
41 | 'resolves successfully when authentication succeeds',
42 | async () => {
43 | const fastify = await buildServer({
44 | JWT_SECRET: 'supersecret',
45 | })
46 |
47 | const req = {}
48 | req.jwtVerify = sinon.stub().resolves()
49 | const reply = {}
50 | reply.send = sinon.stub()
51 |
52 | await assert.doesNotReject(fastify.authenticate(req, reply))
53 | sinon.assert.notCalled(reply.send)
54 | },
55 | )
56 | })
57 |
--------------------------------------------------------------------------------
/src/step-14-typescript/test/login.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import fastify, { FastifyInstance } from 'fastify'
5 | import sinon from 'sinon'
6 |
7 | import loginRoute from '../routes/login'
8 |
9 | function buildServer(): FastifyInstance {
10 | return fastify()
11 | .decorate('jwt', { sign: sinon.stub() })
12 | .register(loginRoute)
13 | }
14 |
15 | test('POST /login', async t => {
16 | await t.test('returns 400 with missing credentials', async () => {
17 | const app = buildServer()
18 |
19 | const res = await app.inject({
20 | url: '/login',
21 | method: 'POST',
22 | })
23 |
24 | assert.equal(res.statusCode, 400)
25 | })
26 |
27 | await t.test('returns 400 with partial credentials', async () => {
28 | const app = buildServer()
29 |
30 | const res = await app.inject({
31 | url: '/login',
32 | method: 'POST',
33 | payload: {
34 | username: 'alice',
35 | },
36 | })
37 |
38 | assert.equal(res.statusCode, 400)
39 | })
40 |
41 | await t.test('returns 401 with wrong credentials', async () => {
42 | const app = buildServer()
43 |
44 | const res = await app.inject({
45 | url: '/login',
46 | method: 'POST',
47 | payload: {
48 | username: 'alice',
49 | password: 'wrong password',
50 | },
51 | })
52 |
53 | assert.equal(res.statusCode, 401)
54 | })
55 |
56 | await t.test('obtains a token with right credentials', async () => {
57 | const app = buildServer()
58 |
59 | app.jwt.sign = sinon.stub().returns('jwt token')
60 |
61 | const res = await app.inject({
62 | url: '/login',
63 | method: 'POST',
64 | payload: {
65 | username: 'alice',
66 | password: 'alice',
67 | },
68 | })
69 |
70 | assert.equal(res.statusCode, 200)
71 | assert.equal(res.json().token, 'jwt token')
72 | })
73 | })
74 |
--------------------------------------------------------------------------------
/src/step-14-typescript/test/users.test.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert/strict'
2 | import { test } from 'node:test'
3 |
4 | import config from '../config'
5 | import buildServer from '../index'
6 |
7 | test('GET /users', async t => {
8 | await t.test('returns users', async () => {
9 | const fastify = buildServer(config)
10 |
11 | const res = await fastify.inject('/users')
12 |
13 | assert.equal(res.statusCode, 200)
14 | assert.deepEqual(res.json(), [
15 | { username: 'alice' },
16 | { username: 'bob' },
17 | ])
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/src/step-14-typescript/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node18/tsconfig.json",
3 | "ts-node": {
4 | "transpileOnly": true,
5 | },
6 | "compilerOptions": {
7 | "esModuleInterop": true,
8 | },
9 | }
10 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | img {
2 | width: 100%;
3 | }
4 |
5 | #slide-empty-center-page {
6 | text-align: center;
7 | }
8 |
9 | #slide-empty-center-page img {
10 | width: 50%;
11 | }
12 |
13 | #slide-using-inspector p img {
14 | width: 80%;
15 | }
16 |
17 | #slide-clinic p {
18 | text-align: center;
19 | }
20 |
21 | #slide-clinic p img {
22 | width: 50% !important;
23 | }
24 |
25 | #slide-deopt p {
26 | text-align: center;
27 | }
28 |
29 | #slide-deopt p img {
30 | width: 90%;
31 | }
32 |
33 | .no-border h1 {
34 | border-bottom: none;
35 | padding-top: 20%;
36 | text-align: center;
37 | }
38 |
39 | ul,
40 | ol {
41 | font-size: 1.75rem;
42 | }
43 |
44 | #slide-clinic-flame-img img,
45 | #slide-clinic-flame-img-zoomed img {
46 | height: 80%;
47 | }
48 |
49 | #slide-shapes-image img,
50 | #slide-shapes-transitions-image img {
51 | height: 25rem;
52 | }
53 |
54 | .slidev-layout {
55 | display: flex;
56 | flex-direction: column;
57 | }
58 | .slidev-code-wrapper {
59 | overflow-y: auto;
60 | }
61 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node18/tsconfig.json"
3 | }
4 |
--------------------------------------------------------------------------------