├── .env.template ├── .gitignore ├── jsconfig.json ├── .prettierrc.js ├── .eslintrc.js ├── index.html ├── server.js ├── knexfile.js ├── plugins ├── jwt.js └── swagger.js ├── LICENSE ├── services └── users │ ├── models │ ├── Role.js │ ├── RefreshToken.js │ └── User.js │ ├── service.js │ └── index.js ├── app.js ├── database ├── seeds │ └── 001_users.js └── migrations │ └── 20210301153506_users.js ├── package.json └── README.md /.env.template: -------------------------------------------------------------------------------- 1 | NODE_ENV = development 2 | JWT_SECRET= __your__jwt__secret__ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | data/ 4 | .vscode/ 5 | .jsconfig.json 6 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6" 5 | } 6 | // "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | endOfLine: 'lf', 7 | }; 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commonjs: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ['standard', 'prettier'], 8 | plugins: ['html'], 9 | parserOptions: { 10 | ecmaVersion: 12, 11 | }, 12 | rules: {}, 13 | }; 14 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 9 | Fundi 10 | 11 | 12 |

Welcome in Fundi

13 |

REST API Documentation

14 | 15 | 16 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Read the .env file. 4 | require('dotenv').config(); 5 | 6 | // Require the framework 7 | const Fastify = require('fastify'); 8 | 9 | // Instantiate Fastify with some config 10 | const app = Fastify({ 11 | logger: true, 12 | pluginTimeout: 10000, 13 | }); 14 | 15 | // Register your application as a normal plugin. 16 | app.register(require('./app.js')); 17 | 18 | // Start listening. 19 | app.listen(process.env.PORT || 3000, (err) => { 20 | if (err) { 21 | app.log.error(err); 22 | process.exit(1); 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | const { knexSnakeCaseMapper } = require('objection'); 2 | const { join } = require('path'); 3 | 4 | // const debug = process.env.NODE_ENV !== 'production'; 5 | const debug = false; 6 | 7 | module.exports = { 8 | development: { 9 | client: 'sqlite3', 10 | debug, 11 | connection: { 12 | filename: './data/dev.sqlite3', 13 | }, 14 | useNullAsDefault: true, 15 | migrations: { 16 | directory: join(__dirname, 'database/migrations'), 17 | }, 18 | seeds: { 19 | directory: join(__dirname, 'database/seeds'), 20 | }, 21 | ...knexSnakeCaseMapper, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /plugins/jwt.js: -------------------------------------------------------------------------------- 1 | const fp = require('fastify-plugin'); 2 | 3 | module.exports = fp(async function (app, opts) { 4 | app.register(require('fastify-jwt'), { 5 | secret: app.config.JWT_SECRET, 6 | }); 7 | 8 | app.decorate('authenticate', async function (request, reply) { 9 | try { 10 | await request.jwtVerify(); 11 | } catch (error) { 12 | reply.send(error); 13 | } 14 | }); 15 | 16 | app.decorate('checkToken', async function (request, reply) { 17 | try { 18 | await request.jwtVerify({ ignoreExpiration: true }); 19 | } catch (error) { 20 | reply.send(error); 21 | } 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Stefano Giraldi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /services/users/models/Role.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const S = require('fluent-json-schema'); 4 | const { Model } = require('objection'); 5 | 6 | class Role extends Model { 7 | static get tableName() { 8 | return 'roles'; 9 | } 10 | 11 | static get idColumn() { 12 | return 'id'; 13 | } 14 | 15 | static get jsonSchema() { 16 | return S.object() 17 | .prop('name', S.string().minLength(1).maxLength(255).required()) 18 | .valueOf(); 19 | } 20 | 21 | static get relationMappings() { 22 | const User = require('./User'); 23 | return { 24 | users: { 25 | relation: Model.ManyToManyRelation, 26 | modelClass: User, 27 | join: { 28 | from: 'roles.id', 29 | through: { 30 | from: 'users_roles.role_id', 31 | to: 'users_roles.user_id', 32 | }, 33 | to: 'users.id', 34 | }, 35 | }, 36 | }; 37 | } 38 | 39 | async $beforeInsert() { 40 | this.created_at = this.updated_at = new Date().toISOString(); 41 | } 42 | 43 | async $beforeUpdate() { 44 | this.updated_at = new Date().toISOString(); 45 | } 46 | } 47 | 48 | module.exports = Role; 49 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Autoload = require('fastify-autoload'); 4 | const S = require('fluent-json-schema'); 5 | const fs = require('fs'); 6 | const { join } = require('path'); 7 | 8 | module.exports = async function (app, opts) { 9 | await app.register(require('fastify-env'), { 10 | confKey: 'config', 11 | schema: S.object() 12 | .prop('NODE_ENV', S.string().default('development')) 13 | .prop('JWT_SECRET', S.string().required()) 14 | .additionalProperties(true), 15 | dotenv: true, 16 | }); 17 | 18 | const knexConfig = await require('./knexfile')[app.config.NODE_ENV]; 19 | 20 | await app.register(require('fastify-objectionjs'), { 21 | knexConfig, 22 | }); 23 | 24 | await app.register(require('fastify-sensible')); 25 | 26 | await app.register(Autoload, { 27 | dir: join(__dirname, 'plugins'), 28 | options: Object.assign({}, opts), 29 | }); 30 | 31 | await app.register(Autoload, { 32 | dir: join(__dirname, 'services'), 33 | ignorePattern: /models/, 34 | options: Object.assign({}, opts), 35 | prefix: '/api', 36 | }); 37 | 38 | app.get('/', async function (request, reply) { 39 | const index = fs.createReadStream('./index.html'); 40 | reply.type('text/html').send(index); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /plugins/swagger.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fp = require('fastify-plugin'); 4 | 5 | const { author } = require('../package.json'); 6 | 7 | module.exports = fp(async function (app, opts) { 8 | await app.register(require('fastify-swagger'), { 9 | routePrefix: '/documentation', 10 | exposeRoute: app.config.NODE_ENV !== 'production', 11 | openapi: { 12 | info: { 13 | title: 'Fundi REST Api', 14 | description: 'REST Api Backend using Fastify', 15 | contact: author, 16 | license: { 17 | name: 'Apache 2.0', 18 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html', 19 | }, 20 | version: '1.0.0', 21 | }, 22 | host: 'localhost:3000', 23 | basePath: '/', 24 | schemes: ['http', 'https'], 25 | consumes: ['application/json'], 26 | produces: ['application/json'], 27 | tags: [ 28 | { 29 | name: 'Auth', 30 | description: 'Operations about Authentication', 31 | }, 32 | ], 33 | components: { 34 | securitySchemes: { 35 | ApiKey: { 36 | type: 'http', 37 | scheme: 'bearer', 38 | bearerFormat: 'JWT', 39 | }, 40 | }, 41 | }, 42 | }, 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /database/seeds/001_users.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt'); 2 | 3 | const password = 'demo'; 4 | const hash = bcrypt.hashSync(password, 12); 5 | 6 | exports.seed = async function (knex) { 7 | // Deletes ALL existing entries 8 | await knex('users').del(); 9 | await knex('users_roles').del(); 10 | await knex('users').insert([ 11 | { 12 | id: 1, 13 | email: 'guest@test.com', 14 | password: hash, 15 | created_at: new Date().toISOString(), 16 | updated_at: new Date().toISOString(), 17 | }, 18 | { 19 | id: 2, 20 | email: 'admin@test.com', 21 | password: hash, 22 | created_at: new Date().toISOString(), 23 | updated_at: new Date().toISOString(), 24 | }, 25 | { 26 | id: 3, 27 | email: 'superadmin@test.com', 28 | password: hash, 29 | created_at: new Date().toISOString(), 30 | updated_at: new Date().toISOString(), 31 | }, 32 | ]); 33 | 34 | await knex('roles').del(); 35 | await knex('roles').insert([ 36 | { id: 1, name: 'guest' }, 37 | { id: 2, name: 'admin' }, 38 | { id: 3, name: 'superadmin' }, 39 | ]); 40 | 41 | await knex('users_roles').del(); 42 | await knex('users_roles').insert([ 43 | { user_id: 1, role_id: 1 }, 44 | { user_id: 2, role_id: 2 }, 45 | { user_id: 3, role_id: 3 }, 46 | ]); 47 | }; 48 | -------------------------------------------------------------------------------- /services/users/models/RefreshToken.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const S = require('fluent-json-schema'); 4 | const addDays = require('date-fns/addDays'); 5 | const randtoken = require('rand-token'); 6 | const { Model } = require('objection'); 7 | 8 | class RefreshToken extends Model { 9 | static get tableName() { 10 | return 'refresh_tokens'; 11 | } 12 | 13 | static get idColumn() { 14 | return 'id'; 15 | } 16 | 17 | static get jsonSchema() { 18 | return S.object() 19 | .prop('id', S.integer()) 20 | .prop('userId', S.integer()) 21 | .prop('token', S.string()) 22 | .prop( 23 | 'clientData', 24 | S.object() 25 | .prop('userAgent', S.string()) 26 | .prop('remoteIp', S.string()) 27 | .valueOf() 28 | ) 29 | .prop('isBlacklisted', S.boolean()) 30 | .valueOf(); 31 | } 32 | 33 | static get relationMappings() { 34 | const User = require('./User'); 35 | return { 36 | users: { 37 | relation: Model.BelongsToOneRelation, 38 | modelClass: User, 39 | join: { 40 | from: 'refresh_tokens.user_id', 41 | to: 'users.id', 42 | }, 43 | }, 44 | }; 45 | } 46 | 47 | async $beforeInsert() { 48 | this.isBlacklisted = false; 49 | this.token = randtoken.suid(255); 50 | const date = new Date(); 51 | this.created_at = this.updated_at = date.toISOString(); 52 | this.expirationDate = addDays(date, 30).toISOString(); 53 | } 54 | 55 | async $beforeUpdate() { 56 | this.updated_at = new Date().toISOString(); 57 | } 58 | } 59 | 60 | module.exports = RefreshToken; 61 | -------------------------------------------------------------------------------- /services/users/models/User.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Password = require('objection-password')(); 4 | const { Model } = require('objection'); 5 | const S = require('fluent-json-schema'); 6 | 7 | class User extends Password(Model) { 8 | static get tableName() { 9 | return 'users'; 10 | } 11 | 12 | static get idColumn() { 13 | return 'id'; 14 | } 15 | 16 | static get jsonSchema() { 17 | return S.object() 18 | .prop('id', S.integer()) 19 | .prop('email', S.string().minLength(1).maxLength(255).required()) 20 | .prop('password', S.string().minLength(1).maxLength(255).required()) 21 | .valueOf(); 22 | } 23 | 24 | static get relationMappings() { 25 | const Role = require('./Role'); 26 | const RefreshToken = require('./RefreshToken'); 27 | return { 28 | roles: { 29 | relation: Model.ManyToManyRelation, 30 | modelClass: Role, 31 | join: { 32 | from: 'users.id', 33 | through: { 34 | from: 'users_roles.user_id', 35 | to: 'users_roles.role_id', 36 | }, 37 | to: 'roles.id', 38 | }, 39 | }, 40 | refreshTokens: { 41 | relation: Model.HasManyRelation, 42 | modelClass: RefreshToken, 43 | join: { 44 | from: 'users.id', 45 | to: 'refresh_tokens.user_id', 46 | }, 47 | }, 48 | }; 49 | } 50 | 51 | async $beforeInsert() { 52 | this.created_at = this.updated_at = new Date().toISOString(); 53 | } 54 | 55 | async $beforeUpdate() { 56 | this.updated_at = new Date().toISOString(); 57 | } 58 | } 59 | 60 | module.exports = User; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fundi", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "fastify start -l info app.js", 9 | "dev": "SET DEBUG=knex:tx && SET FASTIFY_VERBOSE_WATCH=Y && fastify start -w --ignore-watch='data' -l debug -P app.js", 10 | "db:migrate:make": "npx knex migrate:make", 11 | "db:migrate:latest": "SET DEBUG=knex:tx && npx knex migrate:latest --knexfile ./knexfile.js", 12 | "db:migrate:rollback": "SET DEBUG=knex:tx && npx knex migrate:rollback --knexfile ./knexfile.js", 13 | "db:migrate:rollback:debug": "SET DEBUG=knex:tx && npx knex migrate:rollback --knexfile ./knexfile.js", 14 | "db:seed:make": "SET DEBUG=knex:tx && npx knex seed:make --knexfile ./knexfile.js", 15 | "db:seed:run": "SET DEBUG=knex:tx && npx knex seed:run --knexfile ./knexfile.js" 16 | }, 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "bcrypt": "^5.0.1", 21 | "date-fns": "^2.19.0", 22 | "fastify": "^3.13.0", 23 | "fastify-autoload": "^3.5.2", 24 | "fastify-env": "^2.1.0", 25 | "fastify-jwt": "^2.3.0", 26 | "fastify-objectionjs": "^0.3.0", 27 | "fastify-plugin": "^3.0.0", 28 | "fastify-sensible": "^3.1.0", 29 | "fastify-swagger": "^4.3.3", 30 | "fluent-json-schema": "^2.0.4", 31 | "glob": "^7.1.6", 32 | "objection-password": "^3.0.0", 33 | "rand-token": "^1.0.1", 34 | "sqlite3": "^5.0.2" 35 | }, 36 | "devDependencies": { 37 | "eslint": "^7.21.0", 38 | "eslint-config-prettier": "^8.1.0", 39 | "eslint-config-standard": "^16.0.2", 40 | "eslint-plugin-import": "^2.22.1", 41 | "eslint-plugin-node": "^11.1.0", 42 | "eslint-plugin-promise": "^4.3.1", 43 | "prettier": "^2.2.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /services/users/service.js: -------------------------------------------------------------------------------- 1 | const fp = require('fastify-plugin'); 2 | 3 | module.exports = fp(async function userService(app, opts) { 4 | const User = require('./models/User.js'); 5 | const RefreshToken = require('./models/RefreshToken.js'); 6 | 7 | async function getUserByEmail(email) { 8 | const user = await User.query().findOne({ email }); 9 | return user; 10 | } 11 | 12 | const getUserByEmailWithRoles = async function (email) { 13 | return await User.query().withGraphJoined('[roles]').findOne({ email }); 14 | }; 15 | 16 | const createUser = async ({ email, password }) => { 17 | return await User.query().insert({ 18 | email, 19 | password, 20 | }); 21 | }; 22 | 23 | const updateUser = async (id, payload) => { 24 | return await User.query().patchAndFetchById(id, payload).debug(); 25 | }; 26 | 27 | const deleteUserById = async (id) => { 28 | return await User.query().deleteById(id); 29 | }; 30 | 31 | const getNewRefreshToken = async (userId, clientData) => { 32 | return await User.relatedQuery('refreshTokens') 33 | .for(userId) 34 | .insert({ clientData }); 35 | }; 36 | 37 | const checkRefreshToken = async (userId, refreshToken) => { 38 | const result = await User.relatedQuery('refreshTokens') 39 | .for(userId) 40 | .findOne({ token: refreshToken }); 41 | return result instanceof RefreshToken; 42 | }; 43 | 44 | const deleteRefreshToken = async (userId, refreshToken) => { 45 | return await User.relatedQuery('refreshTokens') 46 | .for(userId) 47 | .delete() 48 | .where('token', refreshToken); 49 | }; 50 | 51 | app.decorate('userService', { 52 | getUserByEmail, 53 | getUserByEmailWithRoles, 54 | createUser, 55 | updateUser, 56 | deleteUserById, 57 | getNewRefreshToken, 58 | checkRefreshToken, 59 | deleteRefreshToken, 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /database/migrations/20210301153506_users.js: -------------------------------------------------------------------------------- 1 | function users(table) { 2 | table.increments(); 3 | table.string('email', 128).notNullable().index().unique(); 4 | table.string('password', 64).notNullable(); 5 | table.timestamps(true, true); 6 | } 7 | 8 | function roles(table) { 9 | table.increments('id'); 10 | table.string('name'); 11 | } 12 | 13 | // eslint-disable-next-line camelcase 14 | function users_roles(table) { 15 | table.increments('id'); 16 | table 17 | .integer('user_id') 18 | .unsigned() 19 | .notNullable() 20 | .references('id') 21 | .inTable('users'); 22 | table 23 | .integer('role_id') 24 | .unsigned() 25 | .notNullable() 26 | .references('id') 27 | .inTable('roles'); 28 | } 29 | 30 | // eslint-disable-next-line camelcase 31 | function refresh_tokens(table) { 32 | table.increments('id'); 33 | table 34 | .integer('user_id') 35 | .unsigned() 36 | .notNullable() 37 | .references('id') 38 | .inTable('users'); 39 | table.string('token').notNullable().index().unique(); 40 | table.timestamp('expiration_date').notNullable(); 41 | table.string('client_data'); 42 | table.boolean('is_blacklisted').notNullable().defaultTo(false); 43 | table.timestamps(true, true); 44 | } 45 | 46 | exports.up = async function (knex) { 47 | await knex.schema.createTable('users', users); 48 | await knex.schema.createTable('roles', roles); 49 | await knex.schema.createTable('users_roles', users_roles); 50 | await knex.schema.createTable('refresh_tokens', refresh_tokens); 51 | }; 52 | 53 | exports.down = async function (knex) { 54 | knex.raw('SET foreign_key_checks = 0;'); 55 | await Promise.all([ 56 | knex.schema.dropTableIfExists('users'), 57 | knex.schema.dropTableIfExists('roles'), 58 | knex.schema.dropTableIfExists('users_roles'), 59 | knex.schema.dropTableIfExists('refresh_tokens'), 60 | ]); 61 | knex.raw('SET foreign_key_checks = 1;'); 62 | }; 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fundi - A REST API backend based on Fastify 2 | 3 | **Fundi** is an application scaffold for REST API backend based on **[Fastify](https://www.fastify.io)**. 4 | 5 | > **NB:** This project should be considered WIP and it doesn't aim to reflect the best way Fastify should be used, but it's rather an experiment to help driving into Fastify best practices. 6 | 7 | 8 | ### Why *Fundi* name? 9 | 10 | **Fundi** comes from Swahili and means technician, artisan, craftsman. 11 | 12 | **Fundi** is intended to be a scaffold to build business web application using Fasify. 13 | 14 | ### Why *Fastify*? 15 | 16 | Because **[Fastify](https://www.fastify.io)** is an interesting and fast-growing framework to build server-side web applications focused on performance. 17 | 18 | Even if this project is not indeed to be "fast", the Fastify framework is chosen for its service/plugin/middleware architecture approach, which could be a key feature to build flexible and extensible software in a business application context. 19 | 20 | There are plenty of "batteries-included" "opinionated" frameworks that can solve this problem, most of them are based on Express.js, this would be a scaffold built from scratch using Fastify. 21 | 22 | ## Contribute 23 | 24 | This project is a prototype open to everyone who wants to contribute to defining what could be the best approach for a better developer experience. Feel free to open a pull request or open an issue. 25 | 26 | ## How to run this project in 4 steps 27 | 28 | 1. Clone repository and run `npm install` 29 | 30 | ```sh 31 | $ git clone git@github.com:etino/fundi.git 32 | $ cd fundi 33 | $ npm install 34 | ``` 35 | 36 | 2. Copy `.env.template` in `.env` and set your environment variables 37 | 38 | 3. Database initialization (default engine `sqlite3` in `/data` folder - need to be created) 39 | 40 | ```sh 41 | $ mkdir data 42 | $ npm run db:migrate:latest 43 | $ npm run db:seed:run // load data with defaults 44 | ``` 45 | 46 | 4. Run the server and open [http://localhost:3000/documentation](http://localhost:3000/documentation) 47 | 48 | ```sh 49 | $ npm run start 50 | ``` 51 | 52 | ## Service architecture 53 | 54 | Implemented services are located in `service` directory. Actually only `users` service is implemented. 55 | 56 | Every service has the following architecture 57 | 58 | - `index.js` for routes and handler functions 59 | - `service.js` for business logic 60 | - `models` directory for ORM (Objection.js) models. 61 | 62 | ## TODO 63 | 64 | - [ ] define a standard (automatic) CRUD implementation 65 | - [ ] evaluate ORM alternatives (for example Prisma.io) 66 | - [ ] convert to Typescript(?) 67 | - [ ] implement a test strategy 68 | 69 | ## References 70 | 71 | ### Plugins and Libraries used 72 | 73 | - [fastify-cli](https://github.com/fastify/fastify-cli) 74 | - [fastify-autoload](https://github.com/fastify/fastify-autoload) 75 | - [fastify-env](https://github.com/fastify/fastify-env) 76 | - [fastify-jwt](https://github.com/fastify/fastify-jwt) 77 | - [fastify-objectionjs](https://github.com/jarcodallo/fastify-objectionjs) based on [knex.js](http://knexjs.org/) and [Objection.js](https://vincit.github.io/objection.js/) 78 | - [fastify-swagger](https://github.com/fastify/fastify-swagger) based on [Swagger.io](https://swagger.io/) with [OpenAPI 3.0 Specification](https://swagger.io/specification/) 79 | 80 | 81 | ### Project References 82 | 83 | Fundi is build starting from this examples projects 84 | - [Fastify twitter clone](https://github.com/fastify/fastify-example-twitter) by [@fastify](https://github.com/fastify/) 85 | - [Fastify App Example](https://github.com/delvedor/fastify-example) by [@delvedor](https://github.com/delvedor) 86 | - [fastify/objection/jwt](https://github.com/asdelatoile/fastify-objection-jwt) by [@asdelatoile](https://github.com/asdelatoile/) -------------------------------------------------------------------------------- /services/users/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const S = require('fluent-json-schema'); 4 | 5 | module.exports = async function (app, opts) { 6 | await app.register(require('./service')); 7 | 8 | const { httpErrors } = app; 9 | 10 | app.route({ 11 | method: 'GET', 12 | url: '/test', 13 | handler: onTest, 14 | }); 15 | 16 | async function onTest(request, reply) { 17 | const user = await app.userService.getUserByEmail('guest@test.com'); 18 | return { message: 'Test - OK', user }; 19 | } 20 | 21 | app.route({ 22 | method: 'POST', 23 | url: '/signup', 24 | schema: { 25 | description: 'Signup', 26 | summary: 'User Signup', 27 | tags: ['User'], 28 | body: S.object() 29 | .prop('email', S.string().required()) 30 | .prop('password', S.string().required()) 31 | .additionalProperties(false), 32 | response: { 33 | 200: S.object().prop('message', S.string()), 34 | 401: S.object().prop('message', S.string()), 35 | }, 36 | }, 37 | handler: onSignup, 38 | }); 39 | 40 | async function onSignup(request, reply) { 41 | const { email, password } = request.body; 42 | 43 | const user = await app.userService.getUserByEmail(email); 44 | if (user) { 45 | throw httpErrors.conflict('User already registered'); 46 | } 47 | 48 | const newUser = await app.userService.createUser({ email, password }); 49 | app.log.info(`User ${newUser.email} created with id ${newUser.id}`); 50 | return { message: 'User created' }; 51 | } 52 | 53 | app.route({ 54 | method: 'POST', 55 | url: '/login', 56 | schema: { 57 | description: 'Login', 58 | summary: 'User Login', 59 | tags: ['User'], 60 | body: S.object() 61 | .prop('email', S.string().required()) 62 | .prop('password', S.string().required()) 63 | .additionalProperties(false), 64 | response: { 65 | 200: S.object() 66 | .prop('id', S.integer()) 67 | .prop('accessToken', S.string()) 68 | .prop('refreshToken', S.string()), 69 | 401: S.object().prop('message', S.string()), 70 | }, 71 | }, 72 | handler: async function onLogin(request, reply) { 73 | const { email, password } = request.body; 74 | 75 | const user = await app.userService.getUserByEmailWithRoles(email); 76 | if (!user) { 77 | throw httpErrors.unauthorized(); 78 | } 79 | 80 | const checkPassword = await user.verifyPassword(password); 81 | if (!checkPassword) { 82 | throw httpErrors.unauthorized(); 83 | } 84 | 85 | const clientData = { 86 | userAgent: request.headers['user-agent'], 87 | remoteIp: request.connection.remoteAddress, 88 | }; 89 | 90 | const { id, roles } = user; 91 | 92 | const { token: refreshToken } = await app.userService.getNewRefreshToken( 93 | id, 94 | clientData 95 | ); 96 | 97 | const accessToken = await app.jwt.sign({ id, roles }); 98 | 99 | return { id, accessToken, refreshToken }; 100 | }, 101 | }); 102 | 103 | app.route({ 104 | method: 'POST', 105 | url: '/refresh', 106 | preValidation: [app.checkToken], 107 | schema: { 108 | description: 'Refresh', 109 | summary: 'Refresh Token', 110 | tags: ['User'], 111 | body: S.object() 112 | .prop('refreshToken', S.string().required()) 113 | .additionalProperties(false), 114 | response: { 115 | 200: S.object().prop('id', S.integer()).prop('accessToken', S.string()), 116 | 401: S.object().prop('message', S.string()), 117 | }, 118 | security: [{ ApiKey: [] }], 119 | }, 120 | handler: async function onRefresh(request, reply) { 121 | const { refreshToken } = request.body; 122 | 123 | const { id, roles } = request.user; 124 | const checkRefreshToken = await app.userService.checkRefreshToken( 125 | id, 126 | refreshToken 127 | ); 128 | if (!checkRefreshToken) { 129 | throw httpErrors.unauthorized(); 130 | } 131 | 132 | const accessToken = await app.jwt.sign({ id, roles }); 133 | 134 | return { id, accessToken }; 135 | }, 136 | }); 137 | 138 | app.route({ 139 | method: 'POST', 140 | url: '/logout', 141 | preValidation: [app.checkToken], 142 | schema: { 143 | description: 'Logout', 144 | summary: 'Logout', 145 | tags: ['User'], 146 | body: S.object() 147 | .prop('refreshToken', S.string().required()) 148 | .additionalProperties(false), 149 | response: { 150 | 200: S.object().prop('message', S.string()), 151 | 401: S.object().prop('message', S.string()), 152 | }, 153 | security: [{ ApiKey: [] }], 154 | }, 155 | handler: async function onLogout(request, reply) { 156 | const { refreshToken } = request.body; 157 | 158 | const { id } = request.user; 159 | const checkRefreshToken = await app.userService.checkRefreshToken( 160 | id, 161 | refreshToken 162 | ); 163 | if (!checkRefreshToken) { 164 | throw httpErrors.unauthorized(); 165 | } 166 | 167 | const result = await app.userService.deleteRefreshToken(id, refreshToken); 168 | 169 | if (!result) { 170 | throw httpErrors.unauthorized(); 171 | } 172 | 173 | return { message: 'Logout executed' }; 174 | }, 175 | }); 176 | }; 177 | --------------------------------------------------------------------------------