├── .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 |
--------------------------------------------------------------------------------