├── .eslintignore ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── check-linked-issues.yml │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .postgratorrc.json ├── .prettierrc ├── .taprc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets ├── benchmarks.png ├── declaration-merging.png ├── extensibility.png ├── fastify-light.svg ├── fastify.png ├── hello-world.png ├── hooks.png └── who.png ├── components.d.ts ├── components └── Copyright.ts ├── docker-compose.yml ├── migrations ├── 001.do.sql ├── 001.undo.sql ├── 002.do.sql └── 002.undo.sql ├── package-lock.json ├── package.json ├── public └── images │ └── nearform.svg ├── slides.md ├── src ├── start-here │ ├── package.json │ └── server.js ├── step-01-hello-world │ ├── README.md │ ├── package.json │ └── server.js ├── step-02-plugins │ ├── README.md │ ├── index.js │ ├── package.json │ ├── routes │ │ └── users.js │ └── server.js ├── step-03-logging │ ├── README.md │ ├── index.js │ ├── package.json │ ├── routes │ │ └── users.js │ └── server.js ├── step-04-validation │ ├── README.md │ ├── index.js │ ├── package-lock.json │ ├── package.json │ ├── routes │ │ ├── login.js │ │ └── users.js │ └── server.js ├── step-05-constraints │ ├── README.md │ ├── index.js │ ├── package-lock.json │ ├── package.json │ ├── routes │ │ ├── login.js │ │ ├── users.js │ │ └── version.js │ └── server.js ├── step-06-testing │ ├── README.md │ ├── index.js │ ├── package.json │ ├── routes │ │ ├── login.js │ │ └── users.js │ ├── server.js │ └── test │ │ ├── login.test.js │ │ └── users.test.js ├── step-07-serialization │ ├── README.md │ ├── index.js │ ├── package.json │ ├── routes │ │ ├── login.js │ │ └── users.js │ ├── server.js │ └── test │ │ ├── login.test.js │ │ └── users.test.js ├── step-08-authentication │ ├── README.md │ ├── index.js │ ├── package.json │ ├── routes │ │ ├── login.js │ │ └── users.js │ ├── server.js │ └── test │ │ ├── login.test.js │ │ └── users.test.js ├── step-09-config │ ├── .env │ ├── README.md │ ├── config.js │ ├── index.js │ ├── package.json │ ├── routes │ │ ├── login.js │ │ └── users.js │ ├── server.js │ └── test │ │ ├── login.test.js │ │ └── users.test.js ├── step-10-decorators │ ├── .env │ ├── README.md │ ├── config.js │ ├── index.js │ ├── package.json │ ├── plugins │ │ └── authenticate.js │ ├── routes │ │ ├── login.js │ │ └── users.js │ ├── server.js │ └── test │ │ ├── authenticate.test.js │ │ ├── index.test.js │ │ ├── login.test.js │ │ └── users.test.js ├── step-11-hooks │ ├── .env │ ├── README.md │ ├── config.js │ ├── index.js │ ├── package.json │ ├── plugins │ │ └── authenticate.js │ ├── routes │ │ ├── login.js │ │ ├── user │ │ │ └── index.js │ │ └── users.js │ ├── server.js │ └── test │ │ └── unit │ │ ├── authenticate.test.js │ │ ├── index.test.js │ │ ├── login.test.js │ │ ├── user.test.js │ │ └── users.test.js ├── step-12-autoload │ ├── .env │ ├── README.md │ ├── config.js │ ├── index.js │ ├── package.json │ ├── plugins │ │ └── authenticate.js │ ├── routes │ │ ├── login.js │ │ ├── user │ │ │ └── index.js │ │ └── users.js │ ├── server.js │ └── test │ │ ├── integration │ │ └── index.test.js │ │ └── unit │ │ ├── authenticate.test.js │ │ ├── index.test.js │ │ ├── login.test.js │ │ ├── user.test.js │ │ └── users.test.js ├── step-13-database │ ├── .env │ ├── README.md │ ├── config.js │ ├── index.js │ ├── package.json │ ├── plugins │ │ └── authenticate.js │ ├── routes │ │ ├── login.js │ │ ├── user │ │ │ └── index.js │ │ └── users │ │ │ └── index.js │ ├── server.js │ └── test │ │ ├── integration │ │ └── index.test.js │ │ └── unit │ │ ├── authenticate.test.js │ │ ├── index.test.js │ │ ├── login.test.js │ │ ├── user.test.js │ │ └── users.test.js └── step-14-typescript │ ├── .env │ ├── @types │ └── index.d.ts │ ├── README.md │ ├── config.ts │ ├── index.ts │ ├── package.json │ ├── plugins │ └── authenticate.ts │ ├── routes │ ├── login.ts │ └── users.ts │ ├── server.ts │ ├── test │ ├── authenticate.test.ts │ ├── index.test.ts │ ├── login.test.ts │ └── users.test.ts │ └── tsconfig.json ├── styles.css └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | dist/ 4 | public/ 5 | migrations/ 6 | theme/ 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:prettier/recommended", 6 | ], 7 | "parser": "@typescript-eslint/parser", 8 | "plugins": ["sql", "@typescript-eslint", "eslint-plugin-import"], 9 | "parserOptions": { 10 | "sourceType": "module", 11 | "ecmaVersion": 2020, 12 | }, 13 | "env": { 14 | "es6": true, 15 | "node": true, 16 | }, 17 | "rules": { 18 | "import/order": [ 19 | "error", 20 | { 21 | "newlines-between":"always", 22 | "groups": [ 23 | "builtin", 24 | "external", 25 | "parent", 26 | "sibling", 27 | "index", 28 | ], 29 | }, 30 | ], 31 | "sql/format": "error", 32 | "sql/no-unsafe-query": ["error", { "sqlTag": "SQL" }], 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /.github/workflows/check-linked-issues.yml: -------------------------------------------------------------------------------- 1 | name: Check Linked Issues 2 | 'on': 3 | pull_request_target: 4 | types: 5 | - opened 6 | - edited 7 | - reopened 8 | - synchronize 9 | jobs: 10 | check_pull_requests: 11 | runs-on: ubuntu-latest 12 | name: Check linked issues 13 | steps: 14 | - uses: nearform-actions/github-action-check-linked-issues@v1 15 | with: 16 | github-token: ${{ secrets.GITHUB_TOKEN }} 17 | exclude-branches: release/**, dependabot/** 18 | permissions: 19 | issues: read 20 | pull-requests: write 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | jobs: 10 | build_linux: 11 | runs-on: ubuntu-latest 12 | services: 13 | postgres: 14 | image: postgres:alpine 15 | env: 16 | POSTGRES_PASSWORD: postgres 17 | ports: 18 | - 5433:5432 19 | options: 20 | --health-cmd pg_isready --health-interval 10s --health-timeout 5s 21 | --health-retries 5 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version-file: .nvmrc 27 | - run: | 28 | npm ci 29 | npm run db:migrate 30 | npm run lint 31 | npm test 32 | 33 | build_windows: 34 | runs-on: windows-latest 35 | steps: 36 | - name: Setup PostgreSQL for Linux/macOS/Windows 37 | uses: ikalnytskyi/action-setup-postgres@v7 38 | id: postgres 39 | with: 40 | port: 5433 41 | - uses: actions/checkout@v4 42 | - uses: actions/setup-node@v4 43 | with: 44 | node-version-file: .nvmrc 45 | - run: npm ci 46 | - run: npm run db:migrate 47 | - run: npm test 48 | 49 | automerge: 50 | needs: [build_linux, build_windows] 51 | runs-on: ubuntu-latest 52 | permissions: 53 | pull-requests: write 54 | contents: write 55 | steps: 56 | - uses: fastify/github-action-merge-dependabot@v3 57 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version-file: .nvmrc 19 | - run: npm ci 20 | - run: npm run build -- --base /the-fastify-workshop/ 21 | - uses: peaceiris/actions-gh-pages@v4 22 | with: 23 | github_token: ${{ secrets.GITHUB_TOKEN }} 24 | publish_dir: dist 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | index.html 4 | coverage/ 5 | dist/ 6 | .tap 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | if-present = true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* -------------------------------------------------------------------------------- /.postgratorrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "migrationPattern": "migrations/*.sql", 3 | "driver": "pg", 4 | "host": "127.0.0.1", 5 | "port": 5433, 6 | "database": "postgres", 7 | "username": "postgres", 8 | "password": "postgres" 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "printWidth": 70 6 | } 7 | -------------------------------------------------------------------------------- /.taprc: -------------------------------------------------------------------------------- 1 | coverage-report: 2 | - none 3 | jobs: 1 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.insertFinalNewline": true 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Attribution-ShareAlike 4.0 International 2 | 3 | ======================================================================= 4 | 5 | Creative Commons Corporation ("Creative Commons") is not a law firm and 6 | does not provide legal services or legal advice. Distribution of 7 | Creative Commons public licenses does not create a lawyer-client or 8 | other relationship. Creative Commons makes its licenses and related 9 | information available on an "as-is" basis. Creative Commons gives no 10 | warranties regarding its licenses, any material licensed under their 11 | terms and conditions, or any related information. Creative Commons 12 | disclaims all liability for damages resulting from their use to the 13 | fullest extent possible. 14 | 15 | Using Creative Commons Public Licenses 16 | 17 | Creative Commons public licenses provide a standard set of terms and 18 | conditions that creators and other rights holders may use to share 19 | original works of authorship and other material subject to copyright 20 | and certain other rights specified in the public license below. The 21 | following considerations are for informational purposes only, are not 22 | exhaustive, and do not form part of our licenses. 23 | 24 | Considerations for licensors: Our public licenses are 25 | intended for use by those authorized to give the public 26 | permission to use material in ways otherwise restricted by 27 | copyright and certain other rights. Our licenses are 28 | irrevocable. Licensors should read and understand the terms 29 | and conditions of the license they choose before applying it. 30 | Licensors should also secure all rights necessary before 31 | applying our licenses so that the public can reuse the 32 | material as expected. Licensors should clearly mark any 33 | material not subject to the license. This includes other CC- 34 | licensed material, or material used under an exception or 35 | limitation to copyright. More considerations for licensors: 36 | wiki.creativecommons.org/Considerations_for_licensors 37 | 38 | Considerations for the public: By using one of our public 39 | licenses, a licensor grants the public permission to use the 40 | licensed material under specified terms and conditions. If 41 | the licensor's permission is not necessary for any reason--for 42 | example, because of any applicable exception or limitation to 43 | copyright--then that use is not regulated by the license. Our 44 | licenses grant only permissions under copyright and certain 45 | other rights that a licensor has authority to grant. Use of 46 | the licensed material may still be restricted for other 47 | reasons, including because others have copyright or other 48 | rights in the material. A licensor may make special requests, 49 | such as asking that all changes be marked or described. 50 | Although not required by our licenses, you are encouraged to 51 | respect those requests where reasonable. More considerations 52 | for the public: 53 | wiki.creativecommons.org/Considerations_for_licensees 54 | 55 | ======================================================================= 56 | 57 | Creative Commons Attribution-ShareAlike 4.0 International Public 58 | License 59 | 60 | By exercising the Licensed Rights (defined below), You accept and agree 61 | to be bound by the terms and conditions of this Creative Commons 62 | Attribution-ShareAlike 4.0 International Public License ("Public 63 | License"). To the extent this Public License may be interpreted as a 64 | contract, You are granted the Licensed Rights in consideration of Your 65 | acceptance of these terms and conditions, and the Licensor grants You 66 | such rights in consideration of benefits the Licensor receives from 67 | making the Licensed Material available under these terms and 68 | conditions. 69 | 70 | 71 | Section 1 -- Definitions. 72 | 73 | a. Adapted Material means material subject to Copyright and Similar 74 | Rights that is derived from or based upon the Licensed Material 75 | and in which the Licensed Material is translated, altered, 76 | arranged, transformed, or otherwise modified in a manner requiring 77 | permission under the Copyright and Similar Rights held by the 78 | Licensor. For purposes of this Public License, where the Licensed 79 | Material is a musical work, performance, or sound recording, 80 | Adapted Material is always produced where the Licensed Material is 81 | synched in timed relation with a moving image. 82 | 83 | b. Adapter's License means the license You apply to Your Copyright 84 | and Similar Rights in Your contributions to Adapted Material in 85 | accordance with the terms and conditions of this Public License. 86 | 87 | c. BY-SA Compatible License means a license listed at 88 | creativecommons.org/compatiblelicenses, approved by Creative 89 | Commons as essentially the equivalent of this Public License. 90 | 91 | d. Copyright and Similar Rights means copyright and/or similar rights 92 | closely related to copyright including, without limitation, 93 | performance, broadcast, sound recording, and Sui Generis Database 94 | Rights, without regard to how the rights are labeled or 95 | categorized. For purposes of this Public License, the rights 96 | specified in Section 2(b)(1)-(2) are not Copyright and Similar 97 | Rights. 98 | 99 | e. Effective Technological Measures means those measures that, in the 100 | absence of proper authority, may not be circumvented under laws 101 | fulfilling obligations under Article 11 of the WIPO Copyright 102 | Treaty adopted on December 20, 1996, and/or similar international 103 | agreements. 104 | 105 | f. Exceptions and Limitations means fair use, fair dealing, and/or 106 | any other exception or limitation to Copyright and Similar Rights 107 | that applies to Your use of the Licensed Material. 108 | 109 | g. License Elements means the license attributes listed in the name 110 | of a Creative Commons Public License. The License Elements of this 111 | Public License are Attribution and ShareAlike. 112 | 113 | h. Licensed Material means the artistic or literary work, database, 114 | or other material to which the Licensor applied this Public 115 | License. 116 | 117 | i. Licensed Rights means the rights granted to You subject to the 118 | terms and conditions of this Public License, which are limited to 119 | all Copyright and Similar Rights that apply to Your use of the 120 | Licensed Material and that the Licensor has authority to license. 121 | 122 | j. Licensor means the individual(s) or entity(ies) granting rights 123 | under this Public License. 124 | 125 | k. Share means to provide material to the public by any means or 126 | process that requires permission under the Licensed Rights, such 127 | as reproduction, public display, public performance, distribution, 128 | dissemination, communication, or importation, and to make material 129 | available to the public including in ways that members of the 130 | public may access the material from a place and at a time 131 | individually chosen by them. 132 | 133 | l. Sui Generis Database Rights means rights other than copyright 134 | resulting from Directive 96/9/EC of the European Parliament and of 135 | the Council of 11 March 1996 on the legal protection of databases, 136 | as amended and/or succeeded, as well as other essentially 137 | equivalent rights anywhere in the world. 138 | 139 | m. You means the individual or entity exercising the Licensed Rights 140 | under this Public License. Your has a corresponding meaning. 141 | 142 | 143 | Section 2 -- Scope. 144 | 145 | a. License grant. 146 | 147 | 1. Subject to the terms and conditions of this Public License, 148 | the Licensor hereby grants You a worldwide, royalty-free, 149 | non-sublicensable, non-exclusive, irrevocable license to 150 | exercise the Licensed Rights in the Licensed Material to: 151 | 152 | a. reproduce and Share the Licensed Material, in whole or 153 | in part; and 154 | 155 | b. produce, reproduce, and Share Adapted Material. 156 | 157 | 2. Exceptions and Limitations. For the avoidance of doubt, where 158 | Exceptions and Limitations apply to Your use, this Public 159 | License does not apply, and You do not need to comply with 160 | its terms and conditions. 161 | 162 | 3. Term. The term of this Public License is specified in Section 163 | 6(a). 164 | 165 | 4. Media and formats; technical modifications allowed. The 166 | Licensor authorizes You to exercise the Licensed Rights in 167 | all media and formats whether now known or hereafter created, 168 | and to make technical modifications necessary to do so. The 169 | Licensor waives and/or agrees not to assert any right or 170 | authority to forbid You from making technical modifications 171 | necessary to exercise the Licensed Rights, including 172 | technical modifications necessary to circumvent Effective 173 | Technological Measures. For purposes of this Public License, 174 | simply making modifications authorized by this Section 2(a) 175 | (4) never produces Adapted Material. 176 | 177 | 5. Downstream recipients. 178 | 179 | a. Offer from the Licensor -- Licensed Material. Every 180 | recipient of the Licensed Material automatically 181 | receives an offer from the Licensor to exercise the 182 | Licensed Rights under the terms and conditions of this 183 | Public License. 184 | 185 | b. Additional offer from the Licensor -- Adapted Material. 186 | Every recipient of Adapted Material from You 187 | automatically receives an offer from the Licensor to 188 | exercise the Licensed Rights in the Adapted Material 189 | under the conditions of the Adapter's License You apply. 190 | 191 | c. No downstream restrictions. You may not offer or impose 192 | any additional or different terms or conditions on, or 193 | apply any Effective Technological Measures to, the 194 | Licensed Material if doing so restricts exercise of the 195 | Licensed Rights by any recipient of the Licensed 196 | Material. 197 | 198 | 6. No endorsement. Nothing in this Public License constitutes or 199 | may be construed as permission to assert or imply that You 200 | are, or that Your use of the Licensed Material is, connected 201 | with, or sponsored, endorsed, or granted official status by, 202 | the Licensor or others designated to receive attribution as 203 | provided in Section 3(a)(1)(A)(i). 204 | 205 | b. Other rights. 206 | 207 | 1. Moral rights, such as the right of integrity, are not 208 | licensed under this Public License, nor are publicity, 209 | privacy, and/or other similar personality rights; however, to 210 | the extent possible, the Licensor waives and/or agrees not to 211 | assert any such rights held by the Licensor to the limited 212 | extent necessary to allow You to exercise the Licensed 213 | Rights, but not otherwise. 214 | 215 | 2. Patent and trademark rights are not licensed under this 216 | Public License. 217 | 218 | 3. To the extent possible, the Licensor waives any right to 219 | collect royalties from You for the exercise of the Licensed 220 | Rights, whether directly or through a collecting society 221 | under any voluntary or waivable statutory or compulsory 222 | licensing scheme. In all other cases the Licensor expressly 223 | reserves any right to collect such royalties. 224 | 225 | 226 | Section 3 -- License Conditions. 227 | 228 | Your exercise of the Licensed Rights is expressly made subject to the 229 | following conditions. 230 | 231 | a. Attribution. 232 | 233 | 1. If You Share the Licensed Material (including in modified 234 | form), You must: 235 | 236 | a. retain the following if it is supplied by the Licensor 237 | with the Licensed Material: 238 | 239 | i. identification of the creator(s) of the Licensed 240 | Material and any others designated to receive 241 | attribution, in any reasonable manner requested by 242 | the Licensor (including by pseudonym if 243 | designated); 244 | 245 | ii. a copyright notice; 246 | 247 | iii. a notice that refers to this Public License; 248 | 249 | iv. a notice that refers to the disclaimer of 250 | warranties; 251 | 252 | v. a URI or hyperlink to the Licensed Material to the 253 | extent reasonably practicable; 254 | 255 | b. indicate if You modified the Licensed Material and 256 | retain an indication of any previous modifications; and 257 | 258 | c. indicate the Licensed Material is licensed under this 259 | Public License, and include the text of, or the URI or 260 | hyperlink to, this Public License. 261 | 262 | 2. You may satisfy the conditions in Section 3(a)(1) in any 263 | reasonable manner based on the medium, means, and context in 264 | which You Share the Licensed Material. For example, it may be 265 | reasonable to satisfy the conditions by providing a URI or 266 | hyperlink to a resource that includes the required 267 | information. 268 | 269 | 3. If requested by the Licensor, You must remove any of the 270 | information required by Section 3(a)(1)(A) to the extent 271 | reasonably practicable. 272 | 273 | b. ShareAlike. 274 | 275 | In addition to the conditions in Section 3(a), if You Share 276 | Adapted Material You produce, the following conditions also apply. 277 | 278 | 1. The Adapter's License You apply must be a Creative Commons 279 | license with the same License Elements, this version or 280 | later, or a BY-SA Compatible License. 281 | 282 | 2. You must include the text of, or the URI or hyperlink to, the 283 | Adapter's License You apply. You may satisfy this condition 284 | in any reasonable manner based on the medium, means, and 285 | context in which You Share Adapted Material. 286 | 287 | 3. You may not offer or impose any additional or different terms 288 | or conditions on, or apply any Effective Technological 289 | Measures to, Adapted Material that restrict exercise of the 290 | rights granted under the Adapter's License You apply. 291 | 292 | 293 | Section 4 -- Sui Generis Database Rights. 294 | 295 | Where the Licensed Rights include Sui Generis Database Rights that 296 | apply to Your use of the Licensed Material: 297 | 298 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right 299 | to extract, reuse, reproduce, and Share all or a substantial 300 | portion of the contents of the database; 301 | 302 | b. if You include all or a substantial portion of the database 303 | contents in a database in which You have Sui Generis Database 304 | Rights, then the database in which You have Sui Generis Database 305 | Rights (but not its individual contents) is Adapted Material, 306 | 307 | including for purposes of Section 3(b); and 308 | c. You must comply with the conditions in Section 3(a) if You Share 309 | all or a substantial portion of the contents of the database. 310 | 311 | For the avoidance of doubt, this Section 4 supplements and does not 312 | replace Your obligations under this Public License where the Licensed 313 | Rights include other Copyright and Similar Rights. 314 | 315 | 316 | Section 5 -- Disclaimer of Warranties and Limitation of Liability. 317 | 318 | a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE 319 | EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS 320 | AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF 321 | ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, 322 | IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, 323 | WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR 324 | PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, 325 | ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT 326 | KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT 327 | ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. 328 | 329 | b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE 330 | TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, 331 | NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, 332 | INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, 333 | COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR 334 | USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN 335 | ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR 336 | DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR 337 | IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. 338 | 339 | c. The disclaimer of warranties and limitation of liability provided 340 | above shall be interpreted in a manner that, to the extent 341 | possible, most closely approximates an absolute disclaimer and 342 | waiver of all liability. 343 | 344 | 345 | Section 6 -- Term and Termination. 346 | 347 | a. This Public License applies for the term of the Copyright and 348 | Similar Rights licensed here. However, if You fail to comply with 349 | this Public License, then Your rights under this Public License 350 | terminate automatically. 351 | 352 | b. Where Your right to use the Licensed Material has terminated under 353 | Section 6(a), it reinstates: 354 | 355 | 1. automatically as of the date the violation is cured, provided 356 | it is cured within 30 days of Your discovery of the 357 | violation; or 358 | 359 | 2. upon express reinstatement by the Licensor. 360 | 361 | For the avoidance of doubt, this Section 6(b) does not affect any 362 | right the Licensor may have to seek remedies for Your violations 363 | of this Public License. 364 | 365 | c. For the avoidance of doubt, the Licensor may also offer the 366 | Licensed Material under separate terms or conditions or stop 367 | distributing the Licensed Material at any time; however, doing so 368 | will not terminate this Public License. 369 | 370 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public 371 | License. 372 | 373 | 374 | Section 7 -- Other Terms and Conditions. 375 | 376 | a. The Licensor shall not be bound by any additional or different 377 | terms or conditions communicated by You unless expressly agreed. 378 | 379 | b. Any arrangements, understandings, or agreements regarding the 380 | Licensed Material not stated herein are separate from and 381 | independent of the terms and conditions of this Public License. 382 | 383 | 384 | Section 8 -- Interpretation. 385 | 386 | a. For the avoidance of doubt, this Public License does not, and 387 | shall not be interpreted to, reduce, limit, restrict, or impose 388 | conditions on any use of the Licensed Material that could lawfully 389 | be made without permission under this Public License. 390 | 391 | b. To the extent possible, if any provision of this Public License is 392 | deemed unenforceable, it shall be automatically reformed to the 393 | minimum extent necessary to make it enforceable. If the provision 394 | cannot be reformed, it shall be severed from this Public License 395 | without affecting the enforceability of the remaining terms and 396 | conditions. 397 | 398 | c. No term or condition of this Public License will be waived and no 399 | failure to comply consented to unless expressly agreed to by the 400 | Licensor. 401 | 402 | d. Nothing in this Public License constitutes or may be interpreted 403 | as a limitation upon, or waiver of, any privileges and immunities 404 | that apply to the Licensor or You, including from the legal 405 | processes of any jurisdiction or authority. 406 | 407 | 408 | ======================================================================= 409 | 410 | Creative Commons is not a party to its public licenses. 411 | Notwithstanding, Creative Commons may elect to apply one of its public 412 | licenses to material it publishes and in those instances will be 413 | considered the “Licensor.” The text of the Creative Commons public 414 | licenses is dedicated to the public domain under the CC0 Public Domain 415 | Dedication. Except for the limited purpose of indicating that material 416 | is shared under a Creative Commons public license or as otherwise 417 | permitted by the Creative Commons policies published at 418 | creativecommons.org/policies, Creative Commons does not authorize the 419 | use of the trademark "Creative Commons" or any other trademark or logo 420 | of Creative Commons without its prior written consent including, 421 | without limitation, in connection with any unauthorized modifications 422 | to any of its public licenses or any other arrangements, 423 | understandings, or agreements concerning use of licensed material. For 424 | the avoidance of doubt, this paragraph does not form part of the public 425 | licenses. 426 | 427 | Creative Commons may be contacted at creativecommons.org. 428 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Fastify Workshop 2 | 3 | ![ci](https://github.com/nearform/the-fastify-workshop/workflows/ci/badge.svg) 4 | 5 | ## Slideshow Presentation 6 | 7 | [The Fastify Workshop Slides](https://nearform.github.io/the-fastify-workshop) 8 | 9 | ## Requirements 10 | 11 | - Node LTS 12 | - npm >=7 - you can install it with [`npm install -g npm@latest`](https://docs.npmjs.com/try-the-latest-stable-version-of-npm) 13 | - docker 14 | - docker-compose 15 | 16 | ## Setup 17 | 18 | - `npm ci` 19 | - `npm run db:up` 20 | - `npm run db:migrate` 21 | 22 | ### Run automated tests 23 | 24 | - `npm test --workspaces` 25 | 26 | #### Run automated tests on a single project 27 | 28 | - `npm test -w src/step-06-testing` 29 | 30 | ## Running the modules 31 | 32 | - `cd src/step-{n}-{name}` 33 | - check each module's README file to see which scripts are available 34 | 35 | ## Presenting 36 | 37 | - `npm start` 38 | 39 | [![banner](https://raw.githubusercontent.com/nearform/.github/refs/heads/master/assets/os-banner-green.svg)](https://www.nearform.com/contact/?utm_source=open-source&utm_medium=banner&utm_campaign=os-project-pages) 40 | -------------------------------------------------------------------------------- /assets/benchmarks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/the-fastify-workshop/7af45f3bef5e321bbadc3c0d81fbad5d39d0c21c/assets/benchmarks.png -------------------------------------------------------------------------------- /assets/declaration-merging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/the-fastify-workshop/7af45f3bef5e321bbadc3c0d81fbad5d39d0c21c/assets/declaration-merging.png -------------------------------------------------------------------------------- /assets/extensibility.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/the-fastify-workshop/7af45f3bef5e321bbadc3c0d81fbad5d39d0c21c/assets/extensibility.png -------------------------------------------------------------------------------- /assets/fastify-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /assets/fastify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/the-fastify-workshop/7af45f3bef5e321bbadc3c0d81fbad5d39d0c21c/assets/fastify.png -------------------------------------------------------------------------------- /assets/hello-world.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/the-fastify-workshop/7af45f3bef5e321bbadc3c0d81fbad5d39d0c21c/assets/hello-world.png -------------------------------------------------------------------------------- /assets/hooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/the-fastify-workshop/7af45f3bef5e321bbadc3c0d81fbad5d39d0c21c/assets/hooks.png -------------------------------------------------------------------------------- /assets/who.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nearform/the-fastify-workshop/7af45f3bef5e321bbadc3c0d81fbad5d39d0c21c/assets/who.png -------------------------------------------------------------------------------- /components.d.ts: -------------------------------------------------------------------------------- 1 | // generated by unplugin-vue-components 2 | // We suggest you to commit this file into source control 3 | // Read more: https://github.com/vuejs/core/pull/3399 4 | import '@vue/runtime-core' 5 | 6 | export {} 7 | 8 | declare module '@vue/runtime-core' { 9 | export interface GlobalComponents { 10 | Arrow: typeof import('./node_modules/@slidev/client/builtin/Arrow.vue')['default'] 11 | AutoFitText: typeof import('./node_modules/@slidev/client/builtin/AutoFitText.vue')['default'] 12 | 'Carbon:account': typeof import('~icons/carbon/account')['default'] 13 | 'Carbon:alignBoxBottomRight': typeof import('~icons/carbon/align-box-bottom-right')['default'] 14 | 'Carbon:apps': typeof import('~icons/carbon/apps')['default'] 15 | 'Carbon:arrowLeft': typeof import('~icons/carbon/arrow-left')['default'] 16 | 'Carbon:arrowRight': typeof import('~icons/carbon/arrow-right')['default'] 17 | 'Carbon:arrowUpRight': typeof import('~icons/carbon/arrow-up-right')['default'] 18 | 'Carbon:checkbox': typeof import('~icons/carbon/checkbox')['default'] 19 | 'Carbon:checkmark': typeof import('~icons/carbon/checkmark')['default'] 20 | 'Carbon:chevronUp': typeof import('~icons/carbon/chevron-up')['default'] 21 | 'Carbon:close': typeof import('~icons/carbon/close')['default'] 22 | 'Carbon:closeOutline': typeof import('~icons/carbon/close-outline')['default'] 23 | 'Carbon:delete': typeof import('~icons/carbon/delete')['default'] 24 | 'Carbon:download': typeof import('~icons/carbon/download')['default'] 25 | 'Carbon:error': typeof import('~icons/carbon/error')['default'] 26 | 'Carbon:information': typeof import('~icons/carbon/information')['default'] 27 | 'Carbon:launch': typeof import('~icons/carbon/launch')['default'] 28 | 'Carbon:maximize': typeof import('~icons/carbon/maximize')['default'] 29 | 'Carbon:minimize': typeof import('~icons/carbon/minimize')['default'] 30 | 'Carbon:pen': typeof import('~icons/carbon/pen')['default'] 31 | 'Carbon:pin': typeof import('~icons/carbon/pin')['default'] 32 | 'Carbon:pinFilled': typeof import('~icons/carbon/pin-filled')['default'] 33 | 'Carbon:presentationFile': typeof import('~icons/carbon/presentation-file')['default'] 34 | 'Carbon:radioButton': typeof import('~icons/carbon/radio-button')['default'] 35 | 'Carbon:redo': typeof import('~icons/carbon/redo')['default'] 36 | 'Carbon:renew': typeof import('~icons/carbon/renew')['default'] 37 | 'Carbon:settingsAdjust': typeof import('~icons/carbon/settings-adjust')['default'] 38 | 'Carbon:stopOutline': typeof import('~icons/carbon/stop-outline')['default'] 39 | 'Carbon:textAnnotationToggle': typeof import('~icons/carbon/text-annotation-toggle')['default'] 40 | 'Carbon:time': typeof import('~icons/carbon/time')['default'] 41 | 'Carbon:undo': typeof import('~icons/carbon/undo')['default'] 42 | 'Carbon:userAvatar': typeof import('~icons/carbon/user-avatar')['default'] 43 | 'Carbon:userSpeaker': typeof import('~icons/carbon/user-speaker')['default'] 44 | 'Carbon:video': typeof import('~icons/carbon/video')['default'] 45 | CarbonMoon: typeof import('~icons/carbon/moon')['default'] 46 | CarbonSun: typeof import('~icons/carbon/sun')['default'] 47 | CodeBlockWrapper: typeof import('./node_modules/@slidev/client/builtin/CodeBlockWrapper.vue')['default'] 48 | CodeHighlightController: typeof import('./node_modules/@slidev/client/builtin/CodeHighlightController.vue')['default'] 49 | Link: typeof import('./node_modules/@slidev/client/builtin/Link.vue')['default'] 50 | Mermaid: typeof import('./node_modules/@slidev/client/builtin/Mermaid.vue')['default'] 51 | Monaco: typeof import('./node_modules/@slidev/client/builtin/Monaco.vue')['default'] 52 | 'Ph:cursorDuotone': typeof import('~icons/ph/cursor-duotone')['default'] 53 | 'Ph:cursorFill': typeof import('~icons/ph/cursor-fill')['default'] 54 | PhCheckCircle: typeof import('~icons/ph/check-circle')['default'] 55 | PhClipboard: typeof import('~icons/ph/clipboard')['default'] 56 | PhCursorDuotone: typeof import('~icons/ph/cursor-duotone')['default'] 57 | PhCursorFill: typeof import('~icons/ph/cursor-fill')['default'] 58 | PlantUml: typeof import('./node_modules/@slidev/client/builtin/PlantUml.vue')['default'] 59 | RenderWhen: typeof import('./node_modules/@slidev/client/builtin/RenderWhen.vue')['default'] 60 | RouterLink: typeof import('vue-router')['RouterLink'] 61 | RouterView: typeof import('vue-router')['RouterView'] 62 | SlideCurrentNo: typeof import('./node_modules/@slidev/client/builtin/SlideCurrentNo.vue')['default'] 63 | SlidesTotal: typeof import('./node_modules/@slidev/client/builtin/SlidesTotal.vue')['default'] 64 | Starport: typeof import('vue-starport')['Starport'] 65 | StarportCarrier: typeof import('vue-starport')['StarportCarrier'] 66 | Toc: typeof import('./node_modules/@slidev/client/builtin/Toc.vue')['default'] 67 | TocList: typeof import('./node_modules/@slidev/client/builtin/TocList.vue')['default'] 68 | Transform: typeof import('./node_modules/@slidev/client/builtin/Transform.vue')['default'] 69 | Tweet: typeof import('./node_modules/@slidev/client/builtin/Tweet.vue')['default'] 70 | VAfter: typeof import('./node_modules/@slidev/client/builtin/VAfter.ts')['default'] 71 | VClick: typeof import('./node_modules/@slidev/client/builtin/VClick.ts')['default'] 72 | VClicks: typeof import('./node_modules/@slidev/client/builtin/VClicks.ts')['default'] 73 | Youtube: typeof import('./node_modules/@slidev/client/builtin/Youtube.vue')['default'] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /components/Copyright.ts: -------------------------------------------------------------------------------- 1 | const Copyright = () => `© Copyright ${new Date().getFullYear()} Nearform Ltd. All Rights Reserved.` 2 | 3 | export default Copyright 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | postgres: 4 | image: postgres:alpine 5 | environment: 6 | POSTGRES_PASSWORD: postgres 7 | ports: 8 | - '5433:5432' 9 | -------------------------------------------------------------------------------- /migrations/001.do.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users(id SERIAL PRIMARY KEY, username VARCHAR (50) NOT NULL); 2 | -------------------------------------------------------------------------------- /migrations/001.undo.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE users; 2 | -------------------------------------------------------------------------------- /migrations/002.do.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO users (username) VALUES ('alice'), ('bob'); 2 | -------------------------------------------------------------------------------- /migrations/002.undo.sql: -------------------------------------------------------------------------------- 1 | TRUNCATE TABLE users; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "the-fastify-workshop", 3 | "private": true, 4 | "workspaces": [ 5 | "src/*" 6 | ], 7 | "version": "1.0.0", 8 | "license": "CC-BY-SA-4.0", 9 | "author": "Simone Busoli ", 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 | 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 | ![Who is using Fastify](/assets/who.png) 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 |
103 | 104 | 105 | 106 |
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 | --------------------------------------------------------------------------------