diff --git a/README.md b/README.md index 5f7d430..84dc3d0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,10 @@ -## REST API Node Boilerplate +# REST API Boilerplate for Node.js in TypeScript -### Base services setup +#### A simple REST API boilerplate created using Hapi, Boom, Joiful, Pagination, Swagger and OAuth2. -> Warning: for production ready environments change this part as you prefer. This is a simple example, setting up a strong OAuth2 authorization server. Advanced configurations are out of the scope of this guide +### Base services setup -Services spawned in this demo are: - - `mysql` database - - `postgresql` database - - `hydra-migrate` +> Warning: for production ready environments change this part as you prefer. This is a simple example, setup of a strong OAuth2 authorization server and advanced configurations are out of the scope of this guide Generate OAuth2 server secret: ```bash @@ -28,7 +25,7 @@ Stop all services with: $ docker-compose down ``` -Create your first client: +Create your first OAuth client: ```bash $ docker exec hydra \ hydra clients create \ @@ -40,7 +37,7 @@ $ docker exec hydra \ --callbacks http://localhost:3000/api/oauth/authorize ``` -Deploy a sample Login & Consent App: +Deploy a sample Login & Consent OAuth App: ```bash $ docker run -d \ --name hydra-consent \ @@ -51,31 +48,31 @@ $ docker run -d \ oryd/hydra-login-consent-node:v1.3.2 ``` -Create first user: +Create first user in database: ```bash $ npm run seed ``` -### Configure environement +### Configure environment -Set `.env` dev environement: +Set `.env` dev environment: ```bash $ cp .env.dev .env ``` -Set `.env` production environement: +Set `.env` production environment: ```bash $ cp .env.prod .env ``` ### Prisma client -Prisma migration in dev +Prisma migration in dev: ```bash $ npx prisma migrate dev --name init ``` -Prisma migration in prod +Prisma migration in prod: ```bash $ npx prisma migrate deploy ``` @@ -92,6 +89,62 @@ To introspect database use: $ npx prisma studio ``` +### Endpoints: + +REST API endpoint: +``` +http://localhost:3000/api +``` + +REST API endpoint: +``` +http://localhost:3000/api +``` + +### Workflow + +Follow these steps to complete the setup + +1. Prepare environment: +```bash +$ cp .env.dev .env +$ docker-compose up -d +$ npx prisma migrate dev --name init +$ npm run seed +$ npm run dev +``` + +2. Go to swagger endpoint: +``` +http://localhost:3000/api-docs#/ +``` + +3. Use default credentials: +``` +Username: admin +Password: admin +``` + +4. Make a `GET` request to `/api/oauth/authenticate` + +5. Copy the `authUrl` in the response body and open it in a new browser window + +6. Login to sample app with credentials `foo@bar.com` as email, `foobar` as password and give consent + +7. After authentication completed, copy the JWT token provided and use it to authorize REST API requests as JWT Bearer token + +### Token refresh lifecycle + +In order to prevent access token expiration issues an access token lifecycle has been implemented to refresh it when it expires. +If this situation occurs the current request is authorized anyways but starting from next one you should provide newly generated access token, coming back in the `Authorization` header of the current response. + +> This allows full transparency of access token expiration and refresh to end-user using the API + +### Pull requests and Issues + +Feel free to submit issues and pull requests if you want :smile: + ### TODO -> Write tests +- [ ] Write tests +- [ ] Checks other TODOs in code diff --git a/config/default.json b/config/default.json index e6644de..eb3e120 100644 --- a/config/default.json +++ b/config/default.json @@ -1,8 +1,14 @@ { "showErrors": true, + "oauth": { + "openidConfigURI": "http://localhost:4444/.well-known/openid-configuration", + "introspectionURI": "http://localhost:4445/oauth2/introspect", + "redirectUri": "http://localhost:3000/api/oauth/authorize", + "version": 2 + }, "swagger": { "title": "My First Example API", - "description": "A simple API create using Hapi, Boom, Joiful, Pagination plugin, Swagger, OAuth2.", + "description": "A simple REST API boilerplate created using Hapi, Boom, Joiful, Pagination, Swagger and OAuth2.", "contact": { "name": "Potito Aghilar", "url": "https://potito.web.app/", @@ -12,11 +18,5 @@ "username": "admin", "password": "$2a$10$jbRbGT26vBCAAZnRfVbT9uUWUXQnQbF.FAS9Kwpd4NTXSN62OBCUq" } - }, - "oauth": { - "openidConfigURI": "http://localhost:4444/.well-known/openid-configuration", - "introspectionURI": "http://localhost:4445/oauth2/introspect", - "redirectUri": "http://localhost:3000/api/oauth/authorize", - "version": 2 } } diff --git a/package.json b/package.json index 9f883eb..b1661f0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "restapi", "version": "1.0.0", - "description": "A simple API create using Hapi, Boom, Joiful, Pagination plugin, Swagger, OAuth2.", + "description": "A simple REST API boilerplate created using Hapi, Boom, Joiful, Pagination, Swagger and OAuth2.", "main": "index.js", "scripts": { "dev": "ts-node-dev --respawn ./src/server.ts", diff --git a/src/plugins/users.ts b/src/controllers/users.ts similarity index 88% rename from src/plugins/users.ts rename to src/controllers/users.ts index 6138804..9828c00 100644 --- a/src/plugins/users.ts +++ b/src/controllers/users.ts @@ -1,11 +1,12 @@ import Hapi from '@hapi/hapi' -import handleValidationError from "../shared/validator/error" +import handleValidationError from "../core/plugins/validator/error" import User from "../models/user" -import {idValidatorObject} from "../shared/validator/id-validator" +import {idValidatorObject} from "../core/plugins/validator/validators/id-validator" import UserRepository from "../repositories/user-repository" import boom from '@hapi/boom' -import {healthPluginName} from "./core/health" -import {prismaPluginName} from "./core/prisma" +import {healthPluginName} from "../core/controllers/health" +import {prismaPluginName} from "../core/plugins/prisma/prisma" +import PageRequest from "../core/models/paginator/page-request"; const usersPluginName = 'app/users' const controllerName = 'UserController' @@ -23,6 +24,9 @@ const usersController: Hapi.Plugin = { description: 'Get all users', notes: 'Get all users in the system.', tags: ['api', controllerName], + validate: { + query: PageRequest.getValidator() + }, auth: 'jwt' } }, @@ -92,7 +96,8 @@ const usersController: Hapi.Plugin = { } async function getUsersHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) { - const users = await UserRepository.getUsers() + const pageRequest = PageRequest.fromJSON(request.query) as PageRequest + const users = await UserRepository.getUsers(pageRequest) return h.response(users).code(200) } diff --git a/src/auth/basic-auth.ts b/src/core/auth/basic-auth.ts similarity index 100% rename from src/auth/basic-auth.ts rename to src/core/auth/basic-auth.ts diff --git a/src/auth/jwt.ts b/src/core/auth/jwt-oauth.ts similarity index 92% rename from src/auth/jwt.ts rename to src/core/auth/jwt-oauth.ts index de1a96e..e87b6cb 100644 --- a/src/auth/jwt.ts +++ b/src/core/auth/jwt-oauth.ts @@ -1,15 +1,13 @@ import Hapi, {Lifecycle} from "@hapi/hapi" import fetch from "node-fetch" -import Oauth2Provider from "../repositories/core/oauth2/oauth2-provider" -import PrismaProvider from "../repositories/core/prisma/prisma-provider" -import Profile from "../models/core/oauth2/profile" -import TokenUserBind from "../models/core/oauth2/token-user-bind" +import Oauth2Provider from "../providers/oauth2-provider" +import Profile from "../models/oauth2/profile" import Method = Lifecycle.Method const jwt2 = require('hapi-auth-jwt2') import boom from '@hapi/boom' import jwt from "jsonwebtoken"; import ClientOAuth2 from "client-oauth2"; -import TokenRepository from "../repositories/core/oauth2/token-repository"; +import TokenRepository from "../repositories/oauth2/token-repository"; export default async function registerBearerTokenStrategy(server: Hapi.Server) { diff --git a/src/plugins/core/health.ts b/src/core/controllers/health.ts similarity index 88% rename from src/plugins/core/health.ts rename to src/core/controllers/health.ts index 25d5c69..f4744e9 100644 --- a/src/plugins/core/health.ts +++ b/src/core/controllers/health.ts @@ -10,7 +10,7 @@ const healthController: Hapi.Plugin = { method: 'GET', path: '/health', handler: (_, h: ResponseToolkit) => { - return h.response({ status: 'HEALTHY' }).code(200) + return h.response({ status: 'healthy' }).code(200) }, options: { auth: false diff --git a/src/plugins/core/oauth2.ts b/src/core/controllers/oauth2.ts similarity index 88% rename from src/plugins/core/oauth2.ts rename to src/core/controllers/oauth2.ts index 0465aa1..8a5576d 100644 --- a/src/plugins/core/oauth2.ts +++ b/src/core/controllers/oauth2.ts @@ -1,19 +1,17 @@ import Hapi from '@hapi/hapi' -import {prismaPluginName} from "./prisma" +import {prismaPluginName} from "../plugins/prisma/prisma" import {healthPluginName} from "./health" import boom from '@hapi/boom' import Utils from "../../helpers/utils" -import StateCodeRepository from "../../repositories/core/oauth2/state-code-repository" -import Oauth2Provider from "../../repositories/core/oauth2/oauth2-provider" -import AuthorizationRequest from "../../models/core/oauth2/authorization-request" +import StateCodeRepository from "../repositories/oauth2/state-code-repository" +import Oauth2Provider from "../providers/oauth2-provider" +import AuthorizationRequest from "../models/oauth2/authorization-request" import ClientOAuth2 from "client-oauth2" -import TokenRepository from "../../repositories/core/oauth2/token-repository" +import TokenRepository from "../repositories/oauth2/token-repository" import fetch from "node-fetch"; -import Profile from "../../models/core/oauth2/profile"; -import {add} from "date-fns"; -import StateIpBind from '../../models/core/oauth2/stateIpBind' -import SignedRequest from "../../models/core/oauth2/signed-request"; -import TokenUserBind from "../../models/core/oauth2/token-user-bind"; +import Profile from "../models/oauth2/profile"; +import StateIpBind from '../models/oauth2/stateIpBind' +import SignedRequest from "../models/oauth2/signed-request"; import jwt from "jsonwebtoken"; import crypto from "crypto"; diff --git a/src/models/core/oauth2/authorization-request.ts b/src/core/models/oauth2/authorization-request.ts similarity index 100% rename from src/models/core/oauth2/authorization-request.ts rename to src/core/models/oauth2/authorization-request.ts diff --git a/src/models/core/oauth2/iopenid-config.ts b/src/core/models/oauth2/interfaces/iopenid-config.ts similarity index 100% rename from src/models/core/oauth2/iopenid-config.ts rename to src/core/models/oauth2/interfaces/iopenid-config.ts diff --git a/src/models/core/oauth2/profile.ts b/src/core/models/oauth2/profile.ts similarity index 100% rename from src/models/core/oauth2/profile.ts rename to src/core/models/oauth2/profile.ts diff --git a/src/models/core/oauth2/signed-request.ts b/src/core/models/oauth2/signed-request.ts similarity index 100% rename from src/models/core/oauth2/signed-request.ts rename to src/core/models/oauth2/signed-request.ts diff --git a/src/models/core/oauth2/stateIpBind.ts b/src/core/models/oauth2/stateIpBind.ts similarity index 100% rename from src/models/core/oauth2/stateIpBind.ts rename to src/core/models/oauth2/stateIpBind.ts diff --git a/src/models/core/oauth2/token-user-bind.ts b/src/core/models/oauth2/token-user-bind.ts similarity index 100% rename from src/models/core/oauth2/token-user-bind.ts rename to src/core/models/oauth2/token-user-bind.ts diff --git a/src/core/models/paginator/interfaces/ipage-response.ts b/src/core/models/paginator/interfaces/ipage-response.ts new file mode 100644 index 0000000..e38f79c --- /dev/null +++ b/src/core/models/paginator/interfaces/ipage-response.ts @@ -0,0 +1,8 @@ + +export default interface IPageResponse { + totalElements: number + totalPages: number + currentPage: number + pageSize: number + content: T[] +} diff --git a/src/core/models/paginator/page-request.ts b/src/core/models/paginator/page-request.ts new file mode 100644 index 0000000..a2b8f70 --- /dev/null +++ b/src/core/models/paginator/page-request.ts @@ -0,0 +1,11 @@ +import {BaseModel} from "../shared/base-model"; +import {number} from "joiful"; + +export default class PageRequest extends BaseModel { + + @number().optional().min(0) + pageIndex?: number + + @number().optional().min(1) + pageSize?: number +} diff --git a/src/core/models/paginator/paginator.ts b/src/core/models/paginator/paginator.ts new file mode 100644 index 0000000..c24080d --- /dev/null +++ b/src/core/models/paginator/paginator.ts @@ -0,0 +1,77 @@ +import {BaseModel} from "../shared/base-model" +import boom from '@hapi/boom' +import IPageResponse from "./interfaces/ipage-response"; +import PageRequest from "./page-request"; + +export default class Paginator { + + static DEFAULT_PAGE_SIZE = 25 + + model: typeof BaseModel + + // TODO define type for field `prismaModelDelegate` + prismaModelDelegate: any + + totalElements: number + + totalPages: number + + currentPage: number + + pageSize: number + + content: T[] + + constructor(model: typeof BaseModel, prismaModelDelegate: any, pageRequest?: PageRequest) { + this.model = model + this.prismaModelDelegate = prismaModelDelegate + this.totalElements = 0 + this.totalPages = 1 + this.currentPage = pageRequest?.pageIndex || 0 + this.pageSize = pageRequest?.pageSize || Paginator.DEFAULT_PAGE_SIZE + this.content = [] + } + + async getPage(extraQueryParams?: object): Promise> { + await this.fetchElements(extraQueryParams) + + // Out of pages exception + if (this.currentPage > this.totalPages - 1) { + throw boom.badRequest('Current page is greater than total pages') + } + + return { + content: this.content, + pageSize: this.pageSize, + currentPage: this.currentPage, + totalElements: this.totalElements, + totalPages: this.totalPages, + } as IPageResponse + } + + private async fetchElements(extraQueryParams?: object): Promise { + + // Count total elements + this.totalElements = await this.getTotalElements() + + // Get totale pages + this.totalPages = await this.getTotalPages() + + // Fetch elements for current page + this.content = (await this.prismaModelDelegate.findMany({ + take: this.pageSize, + skip: this.pageSize * this.currentPage, + ...extraQueryParams + })).map((data: object) => this.model.fromJSON(data)) + + } + + private async getTotalElements(): Promise { + return await this.prismaModelDelegate.count() + } + + private async getTotalPages(): Promise { + return Math.ceil(await this.getTotalElements() / this.pageSize) + } + +} diff --git a/src/models/core/shared/base-model.ts b/src/core/models/shared/base-model.ts similarity index 100% rename from src/models/core/shared/base-model.ts rename to src/core/models/shared/base-model.ts diff --git a/src/plugins/core/prisma.ts b/src/core/plugins/prisma/prisma.ts similarity index 80% rename from src/plugins/core/prisma.ts rename to src/core/plugins/prisma/prisma.ts index f1beb83..59601d0 100644 --- a/src/plugins/core/prisma.ts +++ b/src/core/plugins/prisma/prisma.ts @@ -1,6 +1,6 @@ import Hapi from '@hapi/hapi' -import {healthPluginName} from "./health" -import PrismaProvider from "../../repositories/core/prisma/prisma-provider" +import {healthPluginName} from "../../controllers/health" +import PrismaProvider from "../../providers/prisma-provider" const prismaPluginName = 'core/prisma' diff --git a/src/swagger/swagger-service.ts b/src/core/plugins/swagger/swagger.ts similarity index 93% rename from src/swagger/swagger-service.ts rename to src/core/plugins/swagger/swagger.ts index 05f6d3d..447e053 100644 --- a/src/swagger/swagger-service.ts +++ b/src/core/plugins/swagger/swagger.ts @@ -1,5 +1,5 @@ const config = require('config') -const Pack = require('../../package') +const Pack = require('../../../../package') const Inert = require('@hapi/inert') const Vision = require('@hapi/vision') const HapiSwagger = require('hapi-swagger') @@ -17,7 +17,7 @@ const swaggerOptions = { }, auth: 'basicAuth', documentationPath: '/api-docs', - schemes: ['https', 'http'], + schemes: ['http', 'https'], securityDefinitions: { 'jwt': { 'type': 'apiKey', diff --git a/src/shared/validator/error.ts b/src/core/plugins/validator/error.ts similarity index 100% rename from src/shared/validator/error.ts rename to src/core/plugins/validator/error.ts diff --git a/src/shared/validator/id-validator.ts b/src/core/plugins/validator/validators/id-validator.ts similarity index 100% rename from src/shared/validator/id-validator.ts rename to src/core/plugins/validator/validators/id-validator.ts diff --git a/src/repositories/core/oauth2/oauth2-provider.ts b/src/core/providers/oauth2-provider.ts similarity index 96% rename from src/repositories/core/oauth2/oauth2-provider.ts rename to src/core/providers/oauth2-provider.ts index b27116a..fc29416 100644 --- a/src/repositories/core/oauth2/oauth2-provider.ts +++ b/src/core/providers/oauth2-provider.ts @@ -1,6 +1,6 @@ import ClientOAuth2 from "client-oauth2" import fetch from "node-fetch"; -import IOpenidConfig from "../../../models/core/oauth2/iopenid-config"; +import IOpenidConfig from "../models/oauth2/interfaces/iopenid-config"; const config = require('config') let instance: Oauth2Provider diff --git a/src/repositories/core/prisma/prisma-provider.ts b/src/core/providers/prisma-provider.ts similarity index 93% rename from src/repositories/core/prisma/prisma-provider.ts rename to src/core/providers/prisma-provider.ts index 9a5a97d..2fa1d2f 100644 --- a/src/repositories/core/prisma/prisma-provider.ts +++ b/src/core/providers/prisma-provider.ts @@ -1,5 +1,5 @@ import {PrismaClient} from "@prisma/client" -import Utils from "../../../helpers/utils" +import Utils from "../../helpers/utils" let instance: PrismaProvider diff --git a/src/repositories/core/oauth2/state-code-repository.ts b/src/core/repositories/oauth2/state-code-repository.ts similarity index 89% rename from src/repositories/core/oauth2/state-code-repository.ts rename to src/core/repositories/oauth2/state-code-repository.ts index c6f5912..d3a8f97 100644 --- a/src/repositories/core/oauth2/state-code-repository.ts +++ b/src/core/repositories/oauth2/state-code-repository.ts @@ -1,5 +1,5 @@ -import PrismaProvider from "../prisma/prisma-provider"; -import StateIpBind from "../../../models/core/oauth2/stateIpBind"; +import PrismaProvider from "../../providers/prisma-provider"; +import StateIpBind from "../../models/oauth2/stateIpBind"; export default class StateCodeRepository { diff --git a/src/repositories/core/oauth2/token-repository.ts b/src/core/repositories/oauth2/token-repository.ts similarity index 94% rename from src/repositories/core/oauth2/token-repository.ts rename to src/core/repositories/oauth2/token-repository.ts index deb42a2..59719b3 100644 --- a/src/repositories/core/oauth2/token-repository.ts +++ b/src/core/repositories/oauth2/token-repository.ts @@ -1,5 +1,5 @@ -import PrismaProvider from "../prisma/prisma-provider"; -import TokenUserBind from "../../../models/core/oauth2/token-user-bind"; +import PrismaProvider from "../../providers/prisma-provider"; +import TokenUserBind from "../../models/oauth2/token-user-bind"; import ClientOAuth2 from "client-oauth2"; import {add} from "date-fns"; diff --git a/src/models/social.ts b/src/models/social.ts index 4748702..6b5f3f9 100644 --- a/src/models/social.ts +++ b/src/models/social.ts @@ -1,4 +1,4 @@ -import {BaseModel} from "./core/shared/base-model" +import {BaseModel} from "../core/models/shared/base-model" import {string} from "joiful" export default class Social extends BaseModel { diff --git a/src/models/user.ts b/src/models/user.ts index 8808965..2d4bdfe 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -1,6 +1,6 @@ import Social from "./social" import {object, string} from "joiful" -import {BaseModel, model} from "./core/shared/base-model" +import {BaseModel, model} from "../core/models/shared/base-model" export default class User extends BaseModel { diff --git a/src/plugins/core/paginator.ts b/src/plugins/core/paginator.ts deleted file mode 100644 index 7bae5ef..0000000 --- a/src/plugins/core/paginator.ts +++ /dev/null @@ -1,16 +0,0 @@ -import Hapi from '@hapi/hapi' - -const paginatorPluginName = 'core/paginator' - -const paginatorPlugin: Hapi.Plugin = { - name: paginatorPluginName, - dependencies: [], - register: (server: Hapi.Server) => { - - } -} - -export { - paginatorPluginName, - paginatorPlugin -} diff --git a/src/repositories/user-repository.ts b/src/repositories/user-repository.ts index 281b594..571fcf4 100644 --- a/src/repositories/user-repository.ts +++ b/src/repositories/user-repository.ts @@ -1,10 +1,13 @@ import User from "../models/user" -import PrismaProvider from "./core/prisma/prisma-provider" +import PrismaProvider from "../core/providers/prisma-provider" +import PageRequest from "../core/models/paginator/page-request"; +import Paginator from "../core/models/paginator/paginator"; +import IPageResponse from "../core/models/paginator/interfaces/ipage-response"; export default class UserRepository { - public static async getUsers(): Promise { - return (await PrismaProvider.getClient().user.findMany()).map((user: object) => User.fromJSON(user)) + public static async getUsers(pageRequest?: PageRequest): Promise> { + return new Paginator(User, PrismaProvider.getClient().user, pageRequest).getPage() } public static async getUser(id: string): Promise { diff --git a/src/seed.ts b/src/seed.ts index 064b6ed..53e6aaf 100644 --- a/src/seed.ts +++ b/src/seed.ts @@ -1,4 +1,4 @@ -import PrismaProvider from "./repositories/core/prisma/prisma-provider"; +import PrismaProvider from "./core/providers/prisma-provider"; async function createFirstUser() { return PrismaProvider.getClient().user.create({ diff --git a/src/server.ts b/src/server.ts index ffe5dec..bf0c729 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,14 +1,13 @@ import Hapi from '@hapi/hapi' import 'reflect-metadata' -import {swaggerPlugins} from "./swagger/swagger-service" -import registerBasicAuthStrategy from "./auth/basic-auth" -import {healthController} from "./plugins/core/health" -import {prisma} from "./plugins/core/prisma" -import {usersController} from "./plugins/users" -import {oath2plugin} from "./plugins/core/oauth2" +import {swaggerPlugins} from "./core/plugins/swagger/swagger" +import registerBasicAuthStrategy from "./core/auth/basic-auth" +import {healthController} from "./core/controllers/health" +import {prisma} from "./core/plugins/prisma/prisma" +import {usersController} from "./controllers/users" +import {oath2plugin} from "./core/controllers/oauth2" import Utils from "./helpers/utils" -import {paginatorPlugin} from "./plugins/core/paginator" -import registerBearerTokenStrategy from "./auth/jwt"; +import registerBearerTokenStrategy from "./core/auth/jwt-oauth"; require('dotenv').config() /** @@ -40,7 +39,6 @@ export async function start(): Promise { // Register application plugins await server.register([ prisma, - paginatorPlugin, healthController, usersController, oath2plugin