Skip to content

Commit

Permalink
Implemented OAuth2 and pagination. General refactoring
Browse files Browse the repository at this point in the history
  • Loading branch information
potitoaghilar committed May 16, 2021
1 parent c643f37 commit e2014b3
Show file tree
Hide file tree
Showing 32 changed files with 221 additions and 86 deletions.
85 changes: 69 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 \
Expand All @@ -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 \
Expand All @@ -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
```
Expand All @@ -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 `[email protected]` 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
14 changes: 7 additions & 7 deletions config/default.json
Original file line number Diff line number Diff line change
@@ -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/",
Expand All @@ -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
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 10 additions & 5 deletions src/plugins/users.ts → src/controllers/users.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -23,6 +24,9 @@ const usersController: Hapi.Plugin<undefined> = {
description: 'Get all users',
notes: 'Get all users in the system.',
tags: ['api', controllerName],
validate: {
query: PageRequest.getValidator()
},
auth: 'jwt'
}
},
Expand Down Expand Up @@ -92,7 +96,8 @@ const usersController: Hapi.Plugin<undefined> = {
}

async function getUsersHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const users = await UserRepository.getUsers()
const pageRequest = PageRequest.fromJSON<PageRequest>(request.query) as PageRequest
const users = await UserRepository.getUsers(pageRequest)
return h.response(users).code(200)
}

Expand Down
File renamed without changes.
8 changes: 3 additions & 5 deletions src/auth/jwt.ts → src/core/auth/jwt-oauth.ts
Original file line number Diff line number Diff line change
@@ -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) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const healthController: Hapi.Plugin<undefined> = {
method: 'GET',
path: '/health',
handler: (_, h: ResponseToolkit) => {
return h.response({ status: 'HEALTHY' }).code(200)
return h.response({ status: 'healthy' }).code(200)
},
options: {
auth: false
Expand Down
18 changes: 8 additions & 10 deletions src/plugins/core/oauth2.ts → src/core/controllers/oauth2.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
8 changes: 8 additions & 0 deletions src/core/models/paginator/interfaces/ipage-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

export default interface IPageResponse<T> {
totalElements: number
totalPages: number
currentPage: number
pageSize: number
content: T[]
}
11 changes: 11 additions & 0 deletions src/core/models/paginator/page-request.ts
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 77 additions & 0 deletions src/core/models/paginator/paginator.ts
Original file line number Diff line number Diff line change
@@ -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<T> {

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<IPageResponse<T>> {
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<T>
}

private async fetchElements(extraQueryParams?: object): Promise<void> {

// 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<T>(data))

}

private async getTotalElements(): Promise<number> {
return await this.prismaModelDelegate.count()
}

private async getTotalPages(): Promise<number> {
return Math.ceil(await this.getTotalElements() / this.pageSize)
}

}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -17,7 +17,7 @@ const swaggerOptions = {
},
auth: 'basicAuth',
documentationPath: '/api-docs',
schemes: ['https', 'http'],
schemes: ['http', 'https'],
securityDefinitions: {
'jwt': {
'type': 'apiKey',
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading

0 comments on commit e2014b3

Please sign in to comment.