├── .env ├── wercker.env ├── docker.env ├── dev.env ├── test.env └── prod.env ├── nginx ├── Dockerfile └── nginx.conf ├── __test__ ├── entity │ ├── index.ts │ ├── users.ts │ └── mock.ts ├── func │ ├── auth-sign-up.spec.ts │ └── auth-authenticate.spec.ts └── rest │ └── init.admin.rest ├── doc ├── README.md ├── swagger.ts ├── util.ts ├── index.ts ├── function.ts ├── dto.ts └── versions │ └── 1.0.0.json ├── src ├── host.json ├── util │ ├── index.ts │ ├── error.ts │ ├── db.ts │ ├── authorized.ts │ ├── output.ts │ └── func.ts ├── entity │ ├── index.ts │ ├── entities.ts │ ├── device-history.ts │ ├── general-device.ts │ ├── device.ts │ └── user.ts ├── local.settings.json ├── package.json ├── func │ ├── hello-world │ │ ├── index.ts │ │ └── function.json │ ├── init │ │ ├── function.json │ │ └── index.ts │ ├── index.ts │ ├── auth-sign-up │ │ ├── function.json │ │ └── index.ts │ ├── swagger-doc │ │ ├── function.json │ │ └── index.ts │ ├── role-get-roles │ │ ├── function.json │ │ └── index.ts │ ├── user-list-users │ │ ├── function.json │ │ └── index.ts │ ├── auth-authenticate │ │ ├── function.json │ │ └── index.ts │ ├── device-add-device │ │ ├── function.json │ │ └── index.ts │ ├── user-get-my-user │ │ ├── function.json │ │ └── index.ts │ └── device-get-devices │ │ ├── function.json │ │ └── index.ts ├── extensions.csproj ├── index.ts └── migration │ ├── 1563603114464-Init.ts │ └── 1559877528912-Init.ts ├── .editorconfig ├── .vscode ├── extensions.json ├── settings.json ├── launch.json └── tasks.json ├── wercker.yml ├── tslint.json ├── jest.setup.js ├── .gitignore ├── Dockerfile ├── docker-compose.yaml ├── tsconfig.json ├── mssql-tools └── Dockerfile ├── LICENSE ├── ormconfig.json ├── package.json ├── README.md └── gulpfile.js /.env/wercker.env: -------------------------------------------------------------------------------- 1 | DB_PROFILE='default-test' 2 | AUTH_SECRET='secret' -------------------------------------------------------------------------------- /.env/docker.env: -------------------------------------------------------------------------------- 1 | DB_PROFILE='default-dist' 2 | AUTH_SECRET='secret' 3 | -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | 3 | COPY nginx.conf /etc/nginx/nginx.conf -------------------------------------------------------------------------------- /__test__/entity/index.ts: -------------------------------------------------------------------------------- 1 | export * from './users'; 2 | 3 | export * from './mock'; 4 | -------------------------------------------------------------------------------- /.env/dev.env: -------------------------------------------------------------------------------- 1 | AZ_STORAGE_CONN_STRING='' 2 | 3 | DB_PROFILE='dev' 4 | AUTH_SECRET='secret' 5 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | ```sh 2 | tsc --lib es2017 doc/index.ts && node doc/index.js > doc.yml 3 | ``` -------------------------------------------------------------------------------- /.env/test.env: -------------------------------------------------------------------------------- 1 | AZ_STORAGE_CONN_STRING='' 2 | 3 | DB_PROFILE='default-test' 4 | AUTH_SECRET='secret' -------------------------------------------------------------------------------- /src/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging":{ 4 | "logLevel": { 5 | "default": "Warning" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authorized'; 2 | export * from './db'; 3 | export * from './func'; 4 | export * from './output'; 5 | export * from './error'; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = false 9 | insert_final_newline = false -------------------------------------------------------------------------------- /src/entity/index.ts: -------------------------------------------------------------------------------- 1 | export * from './device'; 2 | export * from './device-history'; 3 | export * from './general-device'; 4 | export * from './user'; 5 | 6 | export * from './entities'; 7 | -------------------------------------------------------------------------------- /src/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "", 5 | "FUNCTIONS_WORKER_RUNTIME": "node", 6 | "languageWorkers:node:arguments": "--inspect=5858" 7 | } 8 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-azuretools.vscode-docker", 5 | "ms-vscode.vscode-typescript-tslint-plugin", 6 | "mike-co.import-sorter", 7 | "editorconfig.editorconfig" 8 | ] 9 | } -------------------------------------------------------------------------------- /src/entity/entities.ts: -------------------------------------------------------------------------------- 1 | import { Device } from './device'; 2 | import { DeviceHistory } from './device-history'; 3 | import { GeneralDevice } from './general-device'; 4 | import { User } from './user'; 5 | 6 | export const ENTITIES = [Device, DeviceHistory, GeneralDevice, User]; 7 | -------------------------------------------------------------------------------- /wercker.yml: -------------------------------------------------------------------------------- 1 | box: node:10.14.2 2 | 3 | build: 4 | steps: 5 | - script: 6 | name: install 7 | code: npm install 8 | - script: 9 | name: build 10 | code: npm run build:prod 11 | - script: 12 | name: test 13 | code: npm run test -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "azure-functions-typescript-boilerplate", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "author": "", 7 | "devDependencies": {}, 8 | "dependencies": { 9 | "app-root-path": "^2.1.0", 10 | "glob": "^7.1.3" 11 | } 12 | } -------------------------------------------------------------------------------- /src/util/error.ts: -------------------------------------------------------------------------------- 1 | export class UnauthorizedError extends Error { 2 | } 3 | 4 | export class UserFriendlyError extends Error { 5 | 6 | constructor(message: string) { 7 | super(); 8 | 9 | this.message = message; 10 | } 11 | 12 | } 13 | 14 | export class InternalServerError extends Error { 15 | } 16 | -------------------------------------------------------------------------------- /src/func/hello-world/index.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '@azure/functions'; 2 | import { Func } from '@boilerplate/util'; 3 | 4 | export async function helloWorldFunc(context: Context) { 5 | context.res = await Func.run0( 6 | context, 7 | async () => { 8 | return 'Hello World.'; 9 | }, 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-airbnb", 3 | "linterOptions": { 4 | "exclude": [ 5 | "src/migration/*.ts", 6 | "node_modules/**" 7 | ] 8 | }, 9 | "rules": { 10 | "max-line-length": [ 11 | true, 12 | 200 13 | ], 14 | "no-boolean-literal-compare": false 15 | } 16 | } -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const path = require('path'); 3 | 4 | if (process.env.NODE_ENV === 'WERCKER') { 5 | dotenv.config({ 6 | path: path.resolve('.env/wercker.env'), 7 | }); 8 | } else { 9 | dotenv.config({ 10 | path: path.resolve('.env/test.env'), 11 | }); 12 | } 13 | 14 | jest.setTimeout(30000); 15 | -------------------------------------------------------------------------------- /.env/prod.env: -------------------------------------------------------------------------------- 1 | NODE_ENV='Production' 2 | WEBSITE_NODE_DEFAULT_VERSION='10.14.1' 3 | APP_ROOT_PATH='D:\home\site\wwwroot' 4 | 5 | AZ_STORAGE_CONN_STRING='' 6 | 7 | DB_PROFILE='default-dist' 8 | AUTH_SECRET='NztGa2dCPX5ULCJUclJRZ1dGcEchO2orXjRlNGMuRXBrIU11TERbaCxmVDZwJUNCRWRCU3ZuQDkzIjkyS2dRPnVlUEdhJkItczxwRS19ZydQXHgoI1lVJSR0QDlGUyR6QT8mKXNNRFBQbjw0PCs2VWttNmBNNUZIaCxkYnAqX0A=' 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | 3 | bin 4 | obj 5 | csx 6 | .vs 7 | edge 8 | Publish 9 | 10 | *.user 11 | *.suo 12 | *.cscfg 13 | *.Cache 14 | project.lock.json 15 | 16 | /packages 17 | /TestResults 18 | 19 | /tools/NuGet.exe 20 | /App_Data 21 | /secrets 22 | /data 23 | .secrets 24 | appsettings.json 25 | 26 | /node_modules 27 | /dist 28 | 29 | /doc/versions/staging.json 30 | /docker_volumes -------------------------------------------------------------------------------- /src/extensions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netstandard2.0 4 | 5 | ** 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/func/init/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "Init", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "res" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/func/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth-authenticate'; 2 | export * from './auth-sign-up'; 3 | 4 | export * from './device-add-device'; 5 | export * from './device-get-devices'; 6 | 7 | export * from './hello-world'; 8 | 9 | export * from './init'; 10 | 11 | export * from './role-get-roles'; 12 | 13 | export * from './swagger-doc'; 14 | 15 | export * from './user-get-my-user'; 16 | export * from './user-list-users'; 17 | -------------------------------------------------------------------------------- /src/func/auth-sign-up/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "Auth/SignUp", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "res" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/func/swagger-doc/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "SwaggerDoc", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "get" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "res" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/func/role-get-roles/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "Role/GetRoles", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "res" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/func/user-list-users/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "User/ListUsers", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "res" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/func/auth-authenticate/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "Auth/Authenticate", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "res" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/func/device-add-device/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "Device/AddDevice", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "res" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/func/user-get-my-user/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "User/GetMyUser", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "res" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/func/device-get-devices/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "Device/GetDevices", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "res" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/func/hello-world/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "disabled": false, 3 | "bindings": [ 4 | { 5 | "authLevel": "anonymous", 6 | "route": "HelloWorld", 7 | "type": "httpTrigger", 8 | "direction": "in", 9 | "name": "req", 10 | "methods": [ 11 | "get", 12 | "post" 13 | ] 14 | }, 15 | { 16 | "type": "http", 17 | "direction": "out", 18 | "name": "res" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | import * as path from 'path'; 3 | 4 | if (process.env.NODE_ENV === 'Development') { 5 | dotenv.config({ 6 | path: path.resolve('../.env/dev.env'), 7 | }); 8 | } 9 | 10 | // In this project, 11 | // the dependency tree is like this 12 | // func -> entity + util -> node_modules 13 | // func is the highest level, we can cite func here to reference everything 14 | 15 | export * from '@boilerplate/func'; 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/azure-functions/node:2.0 2 | 3 | ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ 4 | APP_ROOT_PATH=/home/site/wwwroot \ 5 | AzureFunctionsJobHost__Logging__Console__IsEnabled=true \ 6 | NODE_ENV=Docker 7 | 8 | COPY ./dist/package.json /home/site/wwwroot/package.json 9 | 10 | RUN mkdir /home/site/wwwroot/tmp 11 | 12 | RUN cd /home/site/wwwroot && \ 13 | npm install 14 | 15 | COPY ./dist /home/site/wwwroot 16 | COPY ./.env/docker.env /home/site/ -------------------------------------------------------------------------------- /__test__/entity/users.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | import { UserRole } from '@boilerplate/entity'; 4 | 5 | export const users = { 6 | admin_1: { 7 | id: v4(), 8 | emailAddress: '80000000@hktv.com.hk', 9 | password: '123456', 10 | roles: [UserRole.Admins, UserRole.Users], 11 | enabled: true, 12 | }, 13 | user_1: { 14 | id: v4(), 15 | emailAddress: '80000001@hktv.com.hk', 16 | password: '123456', 17 | roles: [UserRole.Users], 18 | enabled: true, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/migration/1563603114464-Init.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class Init1563603114464 implements MigrationInterface { 4 | 5 | public async up(queryRunner: QueryRunner): Promise { 6 | await queryRunner.query(`EXEC sp_rename "user.mobilePhone", "emailAddress"`); 7 | } 8 | 9 | public async down(queryRunner: QueryRunner): Promise { 10 | await queryRunner.query(`EXEC sp_rename "user.emailAddress", "mobilePhone"`); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | mssql: 4 | image: 'mcr.microsoft.com/mssql/server:2017-latest-ubuntu' 5 | ports: 6 | - 1433:1433 7 | environment: 8 | ACCEPT_EULA: 'Y' 9 | SA_PASSWORD: 'yourStrong(!)Password' 10 | mssql-tools: 11 | build: 12 | context: ./mssql-tools 13 | dockerfile: Dockerfile 14 | nginx: 15 | build: 16 | context: ./nginx 17 | dockerfile: Dockerfile 18 | ports: 19 | - 8080:8080 20 | api: 21 | build: 22 | context: . 23 | dockerfile: Dockerfile -------------------------------------------------------------------------------- /src/entity/device-history.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | import { Device } from './device'; 4 | import { User } from './user'; 5 | 6 | @Entity('device_history') 7 | export class DeviceHistory { 8 | 9 | @PrimaryGeneratedColumn() 10 | id: number; 11 | 12 | @ManyToOne(type => Device, d => d.deviceHistories) 13 | device: Device; 14 | 15 | @Column() 16 | userId: string; 17 | 18 | @ManyToOne(type => User, d => d.deviceHistories) 19 | user: User; 20 | 21 | @Column() 22 | start: Date; 23 | 24 | @Column() 25 | end: Date; 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/util/db.ts: -------------------------------------------------------------------------------- 1 | import { Connection, createConnection, getConnectionOptions } from 'typeorm'; 2 | 3 | import { ENTITIES } from '@boilerplate/entity'; 4 | 5 | export namespace DB { 6 | 7 | let connection: Connection; 8 | 9 | export async function getConnection(): Promise { 10 | if (connection) { 11 | return connection; 12 | } 13 | 14 | const connectionOptions = await getConnectionOptions(process.env.DB_PROFILE || 'default-dist'); 15 | 16 | Object.assign(connectionOptions, { 17 | entities: ENTITIES, 18 | }); 19 | 20 | connection = await createConnection(connectionOptions); 21 | 22 | return connection; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/func/role-get-roles/index.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '@azure/functions'; 2 | import { UserRole } from '@boilerplate/entity'; 3 | import { Authorized, Func, InternalServerError } from '@boilerplate/util'; 4 | 5 | export class GetRolesOutput { 6 | constructor(public roles: UserRole[]) { 7 | } 8 | } 9 | 10 | export async function getRoles(_: any, roles?: UserRole[]): Promise { 11 | if (roles) return new GetRolesOutput(roles); 12 | 13 | throw new InternalServerError(); 14 | } 15 | 16 | export async function getRolesFunc(context: Context) { 17 | context.res = await Func.run0( 18 | context, 19 | getRoles, 20 | Authorized.permit({ 21 | })); 22 | } 23 | -------------------------------------------------------------------------------- /__test__/func/auth-sign-up.spec.ts: -------------------------------------------------------------------------------- 1 | import { signUp, SignUpInput } from '@boilerplate/func'; 2 | import { UserFriendlyError } from '@boilerplate/util'; 3 | 4 | import { createMockUsers, init, users } from '../entity'; 5 | 6 | beforeAll(async (done) => { 7 | await init(); 8 | await createMockUsers(); 9 | 10 | done(); 11 | }); 12 | 13 | describe('auth-sign-up', () => { 14 | 15 | it('should not work', async () => { 16 | const user = users.admin_1; 17 | 18 | const input = new SignUpInput(); 19 | input.emailAddress = user.emailAddress; 20 | input.password = 'incorrect'; 21 | 22 | await expect(signUp(input)).rejects.toThrowError(UserFriendlyError); 23 | }); 24 | 25 | }); 26 | -------------------------------------------------------------------------------- /src/entity/general-device.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | import { Device } from './device'; 4 | 5 | @Entity('general_device') 6 | export class GeneralDevice { 7 | 8 | @PrimaryGeneratedColumn('uuid') 9 | id: string; 10 | 11 | @Column() 12 | type: string; 13 | 14 | @Column() 15 | unit: string; 16 | 17 | @OneToMany(type => Device, d => d.generalDevice) 18 | devices: Device[]; 19 | 20 | } 21 | 22 | export class GeneralDeviceDto { 23 | 24 | constructor( 25 | public type: string, 26 | public unit: string) { 27 | } 28 | 29 | static from(generalDevice: GeneralDevice): GeneralDeviceDto { 30 | return new GeneralDeviceDto(generalDevice.type, generalDevice.unit); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "cSpell.words": [ 4 | "typeorm" 5 | ], 6 | "csharp.suppressDotnetRestoreNotification": true, 7 | "omnisharp.autoStart": false, 8 | "azureFunctions.projectRuntime": "~2", 9 | "azureFunctions.projectLanguage": "JavaScript", 10 | "azureFunctions.templateFilter": "Verified", 11 | "azureFunctions.deploySubpath": "dist", 12 | "azureFunctions.preDeployTask": "restoreExtensionsProd", 13 | "azureFunctions.projectSubpath": "src", 14 | "importSorter.generalConfiguration.sortOnBeforeSave": true, 15 | "importSorter.importStringConfiguration.trailingComma": "multiLine", 16 | "gulp.autoDetect": "off", 17 | "rest-client.environmentVariables": { 18 | "$shared": { 19 | "host": "" 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to JavaScript Functions", 6 | "type": "node", 7 | "request": "attach", 8 | "port": 5858, 9 | "preLaunchTask": "runFunctionsHost" 10 | }, 11 | { 12 | "name": "Jest All", 13 | "type": "node", 14 | "request": "launch", 15 | "program": "${workspaceFolder}/node_modules/.bin/jest", 16 | "args": [ 17 | "--runInBand" 18 | ], 19 | "env": { 20 | "DB_PROFILE": "default-test", 21 | "AUTH_SECRET": "secret" 22 | }, 23 | "console": "integratedTerminal", 24 | "internalConsoleOptions": "neverOpen", 25 | "windows": { 26 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 27 | } 28 | } 29 | ] 30 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "noImplicitAny": true, 6 | "strict": true, 7 | "noImplicitThis": true, 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "strictPropertyInitialization": false, 13 | "lib": [ 14 | "es2017" 15 | ], 16 | "baseUrl": "src", 17 | "paths": { 18 | "@boilerplate/entity": [ 19 | "entity/index" 20 | ], 21 | "@boilerplate/migration": [ 22 | "migration/index" 23 | ], 24 | "@boilerplate/util": [ 25 | "util/index" 26 | ], 27 | "@boilerplate/func": [ 28 | "func/index" 29 | ] 30 | }, 31 | "skipLibCheck": true, 32 | } 33 | } -------------------------------------------------------------------------------- /__test__/entity/mock.ts: -------------------------------------------------------------------------------- 1 | import { hash } from 'bcryptjs'; 2 | 3 | import { User } from '@boilerplate/entity'; 4 | import { DB } from '@boilerplate/util'; 5 | 6 | import { users } from './users'; 7 | 8 | export const init = async () => { 9 | const connection = await DB.getConnection(); 10 | await connection.synchronize(); 11 | }; 12 | 13 | export const createMockUsers = async () => { 14 | const connection = await DB.getConnection(); 15 | const userRepository = connection.getRepository(User); 16 | 17 | for (const user of Object.values(users)) { 18 | const u = new User(); 19 | u.id = user.id; 20 | u.emailAddress = user.emailAddress; 21 | u.password = await hash(user.password, 12); 22 | u.roles = user.roles; 23 | u.tokenVersion = ''; 24 | u.enabled = user.enabled; 25 | await userRepository.insert(u); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/entity/device.ts: -------------------------------------------------------------------------------- 1 | import { Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | import { DeviceHistory } from './device-history'; 4 | import { GeneralDevice } from './general-device'; 5 | 6 | @Entity('device') 7 | export class Device { 8 | 9 | @PrimaryGeneratedColumn('uuid') 10 | id: string; 11 | 12 | @ManyToOne(type => GeneralDevice, gd => gd.devices) 13 | generalDevice: GeneralDevice; 14 | 15 | @OneToMany(type => DeviceHistory, dh => dh.device) 16 | deviceHistories: DeviceHistory[]; 17 | 18 | } 19 | 20 | export class DeviceDto { 21 | 22 | constructor( 23 | public id: string, 24 | public type: string, 25 | public unit: string) { 26 | } 27 | 28 | static from(device: Device): DeviceDto { 29 | return new DeviceDto(device.id, device.generalDevice.type, device.generalDevice.unit); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/func/device-get-devices/index.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '@azure/functions'; 2 | import { Device, DeviceDto, UserRole } from '@boilerplate/entity'; 3 | import { Authorized, DB, Func } from '@boilerplate/util'; 4 | 5 | export class GetDevicesOutput { 6 | constructor(public devices: DeviceDto[]) { 7 | } 8 | } 9 | 10 | export async function getDevice(): Promise { 11 | const connection = await DB.getConnection(); 12 | const deviceRepository = connection.getRepository(Device); 13 | 14 | const devices = await deviceRepository.find({ relations: ['generalDevice'] }); 15 | 16 | return new GetDevicesOutput(devices.map(DeviceDto.from)); 17 | } 18 | 19 | export async function deviceGetDevicesFunc(context: Context) { 20 | context.res = await Func.run0( 21 | context, 22 | getDevice, 23 | Authorized.permit({ 24 | }), 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/func/user-get-my-user/index.ts: -------------------------------------------------------------------------------- 1 | import { MyUserDto, User, UserRole } from '@boilerplate/entity'; 2 | import { Authorized, DB, Func, UnauthorizedError } from '@boilerplate/util'; 3 | 4 | export class GetMyUserOutput { 5 | constructor(public myUser: MyUserDto) { 6 | } 7 | } 8 | 9 | export async function getMyUser(userId?: string, roles?: UserRole[]): Promise { 10 | if (userId === undefined) throw new UnauthorizedError(); 11 | 12 | const connection = await DB.getConnection(); 13 | const userRepository = connection.getRepository(User); 14 | 15 | const myUser = await userRepository.findOneOrFail(userId); 16 | 17 | return new GetMyUserOutput(MyUserDto.from(myUser)); 18 | } 19 | 20 | export async function userGetMyUserFunc(context: any) { 21 | context.res = await Func.run0( 22 | context, 23 | getMyUser, 24 | Authorized.permit({ 25 | })); 26 | } 27 | -------------------------------------------------------------------------------- /mssql-tools/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | 3 | LABEL maintainer="SQL Server Engineering Team" 4 | 5 | # apt-get and system utilities 6 | RUN apt-get update && apt-get install -y curl apt-transport-https debconf-utils && \ 7 | rm -rf /var/lib/apt/lists/* 8 | 9 | # adding custom MS repository 10 | RUN curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - 11 | RUN curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list > /etc/apt/sources.list.d/mssql-release.list 12 | 13 | # install SQL Server drivers and tools 14 | RUN apt-get update && ACCEPT_EULA=Y apt-get install -y msodbcsql mssql-tools 15 | 16 | RUN apt-get -y install locales 17 | RUN locale-gen en_US.UTF-8 18 | RUN update-locale LANG=en_US.UTF-8 19 | 20 | CMD /opt/mssql-tools/bin/sqlcmd -S mssql -U sa -P "yourStrong(!)Password" -q "IF DB_ID(N'boilerplate-database') IS NULL BEGIN CREATE DATABASE [boilerplate-database]; END;" -l 30 -------------------------------------------------------------------------------- /src/util/authorized.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@boilerplate/entity'; 2 | 3 | export interface AuthorizedConfig { 4 | allRoles?: UserRole[]; 5 | anyRoles?: UserRole[]; 6 | } 7 | 8 | export class Authorized { 9 | 10 | private config: AuthorizedConfig; 11 | 12 | private isInRoles(roles: UserRole[]) { 13 | if (this.config.allRoles == null) return true; 14 | if (this.config.allRoles.length === 0) return true; 15 | 16 | return this.config.allRoles.every(r => roles.includes(r)); 17 | } 18 | 19 | private isInAnyRoles(roles: UserRole[]) { 20 | if (this.config.anyRoles == null) return true; 21 | if (this.config.anyRoles.length === 0) return true; 22 | 23 | return this.config.anyRoles.some(r => roles.includes(r)); 24 | } 25 | 26 | public permitted(roles: UserRole[]) { 27 | return this.isInRoles(roles) && this.isInAnyRoles(roles); 28 | } 29 | 30 | static permit(config: AuthorizedConfig): Authorized { 31 | const authorized = new Authorized(); 32 | 33 | authorized.config = config; 34 | 35 | return authorized; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/func/init/index.ts: -------------------------------------------------------------------------------- 1 | import { hash } from 'bcryptjs'; 2 | import { IsDefined, IsEmail } from 'class-validator'; 3 | 4 | import { User, UserRole } from '@boilerplate/entity'; 5 | import { DB, Func, UnauthorizedError } from '@boilerplate/util'; 6 | 7 | export class InitInput { 8 | @IsDefined() 9 | @IsEmail() 10 | emailAddress: string; 11 | 12 | @IsDefined() 13 | password: string; 14 | } 15 | 16 | export async function init(input: InitInput) { 17 | const connection = await DB.getConnection(); 18 | const userRepository = connection.getRepository(User); 19 | 20 | if (await userRepository.count() > 0) { 21 | throw new UnauthorizedError(); 22 | } 23 | 24 | const newUser = new User(); 25 | newUser.emailAddress = input.emailAddress; 26 | newUser.password = await hash(input.password, 12); 27 | newUser.roles = [ 28 | UserRole.Admins, 29 | UserRole.Users, 30 | ]; 31 | await userRepository.save(newUser); 32 | } 33 | 34 | export async function initFunc(context: any) { 35 | context.res = await Func.run1( 36 | context, 37 | init, 38 | InitInput, 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Leo Choi 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. 22 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes 1; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | log_format upstream_time '$remote_addr - $remote_user [$time_local] ' 9 | '"$request" $status $body_bytes_sent ' 10 | '"$http_referer" "$http_user_agent" ' 11 | 'rt=$request_time uct="$upstream_connect_time" uht="$upstream_header_time" urt="$upstream_response_time"'; 12 | 13 | error_log /var/log/nginx/error.log debug; 14 | access_log /var/log/nginx/access.log upstream_time; 15 | 16 | server { 17 | listen 8080; 18 | server_name localhost; 19 | 20 | add_header Access-Control-Allow-Origin 'http://localhost:4200' always; 21 | add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always; 22 | add_header Access-Control-Allow-Headers 'Content-Type, X-Authorization' always; 23 | 24 | location / { 25 | if ($request_method = 'OPTIONS') { 26 | return 204; 27 | } 28 | 29 | proxy_pass http://api/; 30 | } 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /src/func/auth-sign-up/index.ts: -------------------------------------------------------------------------------- 1 | import { hash } from 'bcryptjs'; 2 | import { IsDefined, IsEmail, MinLength } from 'class-validator'; 3 | 4 | import { Context } from '@azure/functions'; 5 | import { User, UserRole } from '@boilerplate/entity'; 6 | import { DB, Func, UserFriendlyError } from '@boilerplate/util'; 7 | 8 | export class SignUpInput { 9 | @IsDefined() 10 | @IsEmail() 11 | emailAddress: string; 12 | 13 | @IsDefined() 14 | @MinLength(6) 15 | password: string; 16 | } 17 | 18 | export async function signUp(input: SignUpInput) { 19 | const connection = await DB.getConnection(); 20 | const userRepository = connection.getRepository(User); 21 | 22 | let user = await userRepository.findOne({ 23 | where: { 24 | emailAddress: input.emailAddress, 25 | }, 26 | }); 27 | if (user) throw new UserFriendlyError('The emailAddress is occupied.'); 28 | 29 | user = new User(); 30 | user.emailAddress = input.emailAddress; 31 | user.password = await hash(input.password, 12); 32 | user.roles = [ 33 | UserRole.Users, 34 | ]; 35 | 36 | await userRepository.save(user); 37 | } 38 | 39 | export async function authSignUpFunc(context: Context) { 40 | context.res = await Func.run1( 41 | context, 42 | signUp, 43 | SignUpInput, 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/func/device-add-device/index.ts: -------------------------------------------------------------------------------- 1 | import { IsDefined } from 'class-validator'; 2 | 3 | import { Context } from '@azure/functions'; 4 | import { Device, DeviceDto, GeneralDevice, UserRole } from '@boilerplate/entity'; 5 | import { Authorized, DB, Func, UserFriendlyError } from '@boilerplate/util'; 6 | 7 | export class AddDeviceInput { 8 | @IsDefined() 9 | generalDeviceId: string; 10 | } 11 | 12 | export class AddDeviceOutput { 13 | constructor(public device: DeviceDto) { 14 | } 15 | } 16 | 17 | export async function addDevice(input: AddDeviceInput): Promise { 18 | const connection = await DB.getConnection(); 19 | const generalDeviceRepository = connection.getRepository(GeneralDevice); 20 | 21 | const generalDevice = await generalDeviceRepository.findOne(input.generalDeviceId); 22 | if (!generalDevice) throw new UserFriendlyError('The GeneralDevice does not exist'); 23 | 24 | let device = new Device(); 25 | device.generalDevice = generalDevice; 26 | 27 | device = await generalDeviceRepository.save(device); 28 | 29 | return new AddDeviceOutput(DeviceDto.from(device)); 30 | } 31 | 32 | export async function deviceAddDeviceFunc(context: Context) { 33 | context.res = await Func.run1( 34 | context, 35 | addDevice, 36 | AddDeviceInput, 37 | Authorized.permit({ 38 | anyRoles: [UserRole.Admins], 39 | }), 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /doc/swagger.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface SwaggerFile { 3 | swagger: string; 4 | info: { 5 | version: string; 6 | title: string; 7 | }; 8 | host: string; 9 | basePath: string; 10 | schemes: string[]; 11 | paths: { 12 | [key: string]: SwaggerPath, 13 | }; 14 | definitions: SwaggerDefinition; 15 | } 16 | 17 | export interface SwaggerPath { 18 | post: SwaggerAction; 19 | } 20 | 21 | export interface SwaggerAction { 22 | tags: string[]; 23 | operationId: string; 24 | consumes: string[]; 25 | produces: string[]; 26 | parameters: { 27 | name: string; 28 | in: string; 29 | required: boolean; 30 | schema?: { 31 | $ref: string; 32 | }; 33 | type?: string; 34 | }[]; 35 | responses: { 36 | '200': { 37 | description: string; 38 | schema?: { 39 | $ref: string; 40 | } 41 | }, 42 | }; 43 | } 44 | 45 | export interface SwaggerDefinition { 46 | [key: string]: { 47 | type: string; 48 | properties: SwaggerDefinitionProperties 49 | }; 50 | } 51 | 52 | export interface SwaggerDefinitionProperties { 53 | [key: string]: { 54 | // type => array, boolean, integer, null, number, object, string 55 | type?: string; 56 | 57 | // type => definition 58 | $ref?: string; 59 | 60 | // type => array 61 | items?: { 62 | type?: string; 63 | $ref?: string; 64 | } 65 | 66 | description?: string; 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/func/user-list-users/index.ts: -------------------------------------------------------------------------------- 1 | import { IsDefined, IsDivisibleBy, IsInt } from 'class-validator'; 2 | 3 | import { User, UserListDto, UserRole } from '@boilerplate/entity'; 4 | import { Authorized, DB, Func, UnauthorizedError } from '@boilerplate/util'; 5 | 6 | export class ListUsersInput { 7 | @IsDefined() 8 | @IsInt() 9 | @IsDivisibleBy(20) 10 | skip: number; 11 | } 12 | 13 | export class ListUsersOutput { 14 | constructor(public users: UserListDto[], public totalNumOfUsers: number) { 15 | } 16 | } 17 | 18 | export async function listUsers(input: ListUsersInput, userId?: string, roles?: UserRole[]): Promise { 19 | if (userId === undefined) throw new UnauthorizedError(); 20 | if (roles === undefined) throw new UnauthorizedError(); 21 | 22 | const connection = await DB.getConnection(); 23 | const userRepository = connection.getRepository(User); 24 | 25 | const [ 26 | users, 27 | totalNumOfUsers, 28 | ] = await userRepository.findAndCount({ 29 | order: { 30 | createDate: 'ASC', 31 | }, 32 | take: 20, 33 | skip: input.skip, 34 | }); 35 | 36 | return new ListUsersOutput(users.map(u => UserListDto.from(u)), totalNumOfUsers); 37 | } 38 | 39 | export async function userListUsersFunc(context: any) { 40 | context.res = await Func.run1( 41 | context, 42 | listUsers, 43 | ListUsersInput, 44 | Authorized.permit({ 45 | anyRoles: [UserRole.Admins], 46 | })); 47 | } 48 | -------------------------------------------------------------------------------- /__test__/rest/init.admin.rest: -------------------------------------------------------------------------------- 1 | ### 2 | # @name HelloWorld 3 | POST http://{{host}}/api/HelloWorld HTTP/1.1 4 | 5 | ### 6 | # @name Init 7 | 8 | POST http://{{host}}/api/Init HTTP/1.1 9 | 10 | { 11 | "emailAddress": "mkchoi@hktv.com.hk", 12 | "password": "123456" 13 | } 14 | 15 | ### 16 | # @name Auth_Authenticate 17 | 18 | POST http://{{host}}/api/Auth/Authenticate HTTP/1.1 19 | 20 | { 21 | "emailAddress": "mkchoi@hktv.com.hk", 22 | "password": "123456" 23 | } 24 | 25 | @accessToken = {{Auth_Authenticate.response.body.$.data.accessToken}} 26 | 27 | ### 28 | # @name User_GetMyUser 29 | 30 | POST http://{{host}}/api/User/GetMyUser HTTP/1.1 31 | X-Authorization: Bearer {{accessToken}} 32 | 33 | @userId = {{User_GetMyUser.response.body.$.data.myUser.id}} 34 | 35 | ### 36 | # @name User_ListUsers 37 | 38 | POST http://{{host}}/api/User/ListUsers HTTP/1.1 39 | X-Authorization: Bearer {{accessToken}} 40 | 41 | { 42 | "skip": 0 43 | } 44 | 45 | ### 46 | # @name Project_Create 47 | 48 | POST http://{{host}}/api/Project/Create HTTP/1.1 49 | X-Authorization: Bearer {{accessToken}} 50 | 51 | { 52 | "name": "FDX" 53 | } 54 | 55 | ### 56 | # @name Project_GetProjects 57 | 58 | POST http://{{host}}/api/Project/GetProjects HTTP/1.1 59 | X-Authorization: Bearer {{accessToken}} 60 | 61 | ### 62 | # @name Doc_GetDocs 63 | 64 | POST http://{{host}}/api/Doc/GetDocs HTTP/1.1 65 | X-Authorization: Bearer {{accessToken}} 66 | 67 | { 68 | "projectId": 1 69 | } 70 | -------------------------------------------------------------------------------- /ormconfig.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "default", 4 | "type": "mssql", 5 | "host": "", 6 | "username": "", 7 | "password": "", 8 | "database": "", 9 | "logging": true, 10 | "options": { 11 | "encrypt": true 12 | }, 13 | "entities": [ 14 | "src/entity/**/*.ts" 15 | ], 16 | "migrations": [ 17 | "src/migration/**/*.ts" 18 | ], 19 | "subscribers": [ 20 | "src/subscriber/**/*.ts" 21 | ], 22 | "cli": { 23 | "entitiesDir": "src/entity", 24 | "migrationsDir": "src/migration", 25 | "subscribersDir": "src/subscriber" 26 | } 27 | }, 28 | { 29 | "name": "default-dist", 30 | "type": "mssql", 31 | "host": "", 32 | "username": "", 33 | "password": "", 34 | "database": "", 35 | "logging": false, 36 | "options": { 37 | "encrypt": true 38 | } 39 | }, 40 | { 41 | "name": "dev", 42 | "type": "mssql", 43 | "host": "localhost", 44 | "username": "sa", 45 | "password": "yourStrong(!)Password", 46 | "database": "boilerplate-database", 47 | "logging": true, 48 | "options": { 49 | "encrypt": true 50 | }, 51 | "entities": [ 52 | "src/entity/**/*.ts" 53 | ], 54 | "migrations": [ 55 | "src/migration/**/*.ts" 56 | ], 57 | "subscribers": [ 58 | "src/subscriber/**/*.ts" 59 | ], 60 | "cli": { 61 | "entitiesDir": "src/entity", 62 | "migrationsDir": "src/migration", 63 | "subscribersDir": "src/subscriber" 64 | } 65 | }, 66 | { 67 | "name": "default-test", 68 | "type": "sqlite", 69 | "database": ":memory:", 70 | "logging": true 71 | } 72 | ] -------------------------------------------------------------------------------- /src/util/output.ts: -------------------------------------------------------------------------------- 1 | export class Output { 2 | 3 | success: boolean = true; 4 | 5 | data: T; 6 | 7 | message: string; 8 | 9 | dateTime = new Date().toISOString(); 10 | 11 | static out(status: number, body: any) { 12 | return { 13 | status, 14 | body, 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | }, 18 | }; 19 | } 20 | 21 | static ok(data?: any) { 22 | const output = new Output(); 23 | 24 | if (typeof data === 'string') { 25 | output.message = data; 26 | } else if (typeof data === 'object') { 27 | if (data != null) output.data = data; 28 | } 29 | 30 | return this.out(200, output); 31 | } 32 | 33 | static internalError(message: string = 'There are some errors.', data?: any) { 34 | const output = new Output(); 35 | 36 | output.success = false; 37 | 38 | if (message) output.message = message; 39 | if (data) output.data = data; 40 | 41 | return this.out(500, output); 42 | } 43 | 44 | static error(message: string, data?: any) { 45 | const output = new Output(); 46 | 47 | output.success = false; 48 | output.message = message; 49 | 50 | if (data) output.data = data; 51 | 52 | return this.out(400, output); 53 | } 54 | 55 | static badRequest(constraints: { [type: string]: string }[]) { 56 | const output = new Output(); 57 | 58 | output.success = false; 59 | output.message = 'The input is invalid.'; 60 | output.data = constraints; 61 | 62 | return this.out(400, output); 63 | } 64 | 65 | static unauthorized() { 66 | const output = new Output(); 67 | 68 | output.success = false; 69 | 70 | return this.out(401, output); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/entity/user.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterLoad, Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, 3 | UpdateDateColumn, 4 | } from 'typeorm'; 5 | 6 | import { DeviceHistory } from './device-history'; 7 | 8 | export enum UserRole { 9 | Users = 1000, 10 | Admins = 9999, 11 | } 12 | 13 | @Entity('user') 14 | export class User { 15 | 16 | @PrimaryGeneratedColumn('uuid') 17 | id: string; 18 | 19 | @Column() 20 | emailAddress: string; 21 | 22 | @Column() 23 | password: string; 24 | 25 | @Column('simple-array') 26 | roles: UserRole[]; 27 | 28 | @CreateDateColumn() 29 | createDate: Date; 30 | 31 | @UpdateDateColumn() 32 | updateDate: Date; 33 | 34 | @Column({ nullable: true }) 35 | tokenVersion: string; 36 | 37 | @Column({ default: true }) 38 | enabled: boolean = true; 39 | 40 | @OneToMany(() => DeviceHistory, d => d.userId) 41 | deviceHistories: DeviceHistory[]; 42 | 43 | @AfterLoad() 44 | onLoad() { 45 | if (this.roles) { 46 | this.roles = this.roles.map((r: any) => parseInt(r, undefined) as UserRole); 47 | } 48 | } 49 | 50 | } 51 | 52 | export class UserListDto { 53 | constructor( 54 | public id: string, 55 | public emailAddress: string, 56 | ) { 57 | } 58 | 59 | static from(user: User): UserListDto { 60 | return new UserListDto( 61 | user.id, 62 | user.emailAddress, 63 | ); 64 | } 65 | } 66 | 67 | export class MyUserDto { 68 | 69 | constructor( 70 | public id: string, 71 | public emailAddress: string, 72 | public roles: UserRole[], 73 | public createDate: Date, 74 | public updateDate: Date, 75 | ) { 76 | } 77 | 78 | static from(user: User): MyUserDto { 79 | return new MyUserDto( 80 | user.id, 81 | user.emailAddress, 82 | user.roles, 83 | user.createDate, 84 | user.updateDate, 85 | ); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/func/swagger-doc/index.ts: -------------------------------------------------------------------------------- 1 | const templateHtml = ` 2 | 3 | 4 | 5 | 6 | 7 | Swagger UI 8 | 10 | 11 | 29 | 30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 | 38 | 57 | 58 | 59 | 60 | `; 61 | 62 | export async function swaggerDocFunc(context: any) { 63 | context.res = { 64 | body: templateHtml, 65 | headers: { 66 | 'Content-Type': 'text/html; charset=utf-8', 67 | }, 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /doc/util.ts: -------------------------------------------------------------------------------- 1 | import { ClassDeclaration, SourceFile } from 'ts-morph'; 2 | 3 | export function flat(arrays: any[]) { 4 | return [].concat.apply([], arrays); 5 | } 6 | 7 | // IN classNameWithImportPath 8 | // e.g. import("/Users/leochoi/Projects/eim-functions/src/entity/course").CourseDto 9 | // OUT className 10 | // e.g. CourseDto 11 | // OUT absoluteImportPath 12 | // e.g. /Users/leochoi/Projects/eim-functions/src/entity/course.ts 13 | export function extractClassAndImportPath(classNameWithImportPath: string) { 14 | const regex = /import\(\"(.*)\"\)\.(.*)/; 15 | 16 | const matches = regex.exec(classNameWithImportPath); 17 | if (matches === null) { 18 | throw new Error(); 19 | } 20 | 21 | const className = matches[2]; 22 | const importPath = `${matches[1]}.ts`; 23 | 24 | return { 25 | className, 26 | absoluteImportPath: importPath, 27 | }; 28 | } 29 | 30 | // classNameWithImportPath 31 | // e.g. import("/Users/leochoi/Projects/eim-functions/src/entity/course").CourseDto 32 | export function getClassDeclaration(classNameWithImportPath: string, sourceFiles: SourceFile[]): ClassDeclaration { 33 | const { className, absoluteImportPath } = extractClassAndImportPath(classNameWithImportPath); 34 | 35 | const classDeclarations = flat(sourceFiles.filter(sf => sf.getFilePath() === absoluteImportPath).map(sf => sf.getClasses())) as ClassDeclaration[]; 36 | const classDeclaration = classDeclarations.find(c => c.getNameOrThrow() === className); 37 | 38 | if (classDeclaration === undefined) { 39 | throw new Error(); 40 | } 41 | 42 | return classDeclaration; 43 | } 44 | 45 | export function normalizeToSwaggerType(inType: string): string { 46 | const type = inType 47 | .replace(/\| null/g, '') 48 | .replace(/\| undefined/g, '') 49 | .replace(/\ /, ''); 50 | 51 | switch (type) { 52 | case 'int': 53 | return 'integer'; 54 | case 'any': 55 | return 'object'; 56 | case 'Date': 57 | return 'string'; 58 | default: 59 | return type; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/func/auth-authenticate/index.ts: -------------------------------------------------------------------------------- 1 | import { compare } from 'bcryptjs'; 2 | import { IsDefined, IsEmail } from 'class-validator'; 3 | import { sign } from 'jsonwebtoken'; 4 | import { v4 } from 'uuid'; 5 | 6 | import { Context } from '@azure/functions'; 7 | import { User } from '@boilerplate/entity'; 8 | import { DB, Func, UnauthorizedError, UserFriendlyError } from '@boilerplate/util'; 9 | 10 | export class AuthenticateInput { 11 | @IsDefined() 12 | @IsEmail() 13 | emailAddress: string; 14 | 15 | @IsDefined() 16 | password: string; 17 | } 18 | 19 | export class AuthenticateOutput { 20 | constructor(public accessToken: string) { 21 | } 22 | } 23 | 24 | export async function authenticate(input: AuthenticateInput): Promise { 25 | const secret = process.env.AUTH_SECRET; 26 | if (secret === undefined || secret === null) throw new Error('Invalid AUTH_SECRET.'); 27 | 28 | const connection = await DB.getConnection(); 29 | const userRepository = connection.getRepository(User); 30 | 31 | const user = await userRepository.findOne({ 32 | where: { 33 | emailAddress: input.emailAddress, 34 | }, 35 | select: ['id', 'password', 'tokenVersion'], 36 | }); 37 | if (user === undefined) throw new UserFriendlyError('Please sign up first.'); 38 | 39 | const isValid = await compare(input.password, user.password); 40 | if (isValid === false) throw new UnauthorizedError(); 41 | 42 | if (user.tokenVersion === undefined || user.tokenVersion === null) { 43 | user.tokenVersion = v4(); 44 | await userRepository.save(user); 45 | } 46 | 47 | const options = { 48 | expiresIn: 60 * 60 * 24 * 31, 49 | audience: ['http://localhost:7071'], 50 | issuer: 'http://localhost:7071', 51 | subject: user.id, 52 | }; 53 | 54 | const payload = { 55 | gty: 'Auth/Authenticate', 56 | version: user.tokenVersion, 57 | }; 58 | 59 | const token = await sign(payload, secret, options); 60 | 61 | return new AuthenticateOutput(token); 62 | } 63 | 64 | export async function authAuthenticateFunc(context: Context) { 65 | context.res = await Func.run1( 66 | context, 67 | authenticate, 68 | AuthenticateInput, 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "runFunctionsHost", 6 | "type": "shell", 7 | "command": "func host start", 8 | "isBackground": true, 9 | "presentation": { 10 | "reveal": "always", 11 | "clear": true, 12 | "panel": "dedicated" 13 | }, 14 | "problemMatcher": "$func-watch", 15 | "options": { 16 | "env": { 17 | "languageWorkers:node:arguments": "--inspect=5858", 18 | "NODE_ENV": "Development" 19 | }, 20 | "cwd": "./dist" 21 | }, 22 | "dependsOn": [ 23 | "restoreExtensionsDev" 24 | ] 25 | }, 26 | { 27 | "label": "restoreExtensionsDev", 28 | "command": "dotnet restore", 29 | "type": "shell", 30 | "options": { 31 | "cwd": "./dist" 32 | }, 33 | "dependsOn": [ 34 | "runFunExtensionInstallDev" 35 | ] 36 | }, 37 | { 38 | "label": "runFunExtensionInstallDev", 39 | "command": "func extensions install", 40 | "type": "shell", 41 | "options": { 42 | "cwd": "./dist" 43 | }, 44 | "dependsOn": [ 45 | "buildDev" 46 | ] 47 | }, 48 | { 49 | "label": "buildDev", 50 | "command": "npm run build:dev", 51 | "type": "shell" 52 | }, 53 | // Production 54 | { 55 | "label": "restoreExtensionsProd", 56 | "command": "dotnet restore", 57 | "type": "shell", 58 | "presentation": { 59 | "reveal": "silent" 60 | }, 61 | "options": { 62 | "cwd": "./dist" 63 | }, 64 | "dependsOn": [ 65 | "runFunExtensionInstallProd" 66 | ] 67 | }, 68 | { 69 | "label": "runFunExtensionInstallProd", 70 | "command": "func extensions install", 71 | "type": "shell", 72 | "presentation": { 73 | "reveal": "silent" 74 | }, 75 | "options": { 76 | "cwd": "./dist" 77 | }, 78 | "dependsOn": [ 79 | "buildProd" 80 | ] 81 | }, 82 | { 83 | "label": "buildProd", 84 | "command": "npm run build:prod", 85 | "type": "shell", 86 | "presentation": { 87 | "reveal": "always" 88 | } 89 | } 90 | ] 91 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "azure-functions-typescript-boilerplate", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "scripts": { 7 | "typeorm:cli": "ts-node ./node_modules/typeorm/cli.js", 8 | "build:prod": "cross-env NODE_ENV=Production ts-node ./node_modules/gulp/bin/gulp clean build --series", 9 | "build:dev": "cross-env NODE_ENV=Development ts-node ./node_modules/gulp/bin/gulp build", 10 | "watch:dev": "cross-env NODE_ENV=Development ts-node ./node_modules/gulp/bin/gulp watch", 11 | "clean": "ts-node ./node_modules/gulp/bin/gulp clean", 12 | "test": "cross-env NODE_ENV=Development ts-node ./node_modules/jest/bin/jest" 13 | }, 14 | "author": "", 15 | "devDependencies": { 16 | "@azure/functions": "^1.0.3", 17 | "@types/bcryptjs": "^2.4.2", 18 | "@types/dotenv": "^6.1.1", 19 | "@types/jest": "^24.0.15", 20 | "@types/js-yaml": "^3.12.1", 21 | "@types/jsonwebtoken": "^8.3.2", 22 | "@types/node": "^10.14.13", 23 | "@types/uuid": "^3.4.5", 24 | "cross-env": "^5.2.0", 25 | "del": "^5.0.0", 26 | "gulp": "^4.0.0", 27 | "gulp-change": "^1.0.2", 28 | "gulp-changed": "^4.0.0", 29 | "gulp-filter": "^6.0.0", 30 | "gulp-rename": "^1.4.0", 31 | "gulp-sourcemaps": "^2.6.5", 32 | "gulp-typescript": "^5.0.1", 33 | "jest": "^24.7.1", 34 | "js-yaml": "^3.13.1", 35 | "parcel-bundler": "^1.12.3", 36 | "sqlite3": "^4.0.9", 37 | "ts-jest": "^24.0.2", 38 | "ts-morph": "^1.3.3", 39 | "ts-node": "^8.3.0", 40 | "tslint": "^5.18.0", 41 | "tslint-config-airbnb": "^5.11.1", 42 | "typescript": "^3.5.3" 43 | }, 44 | "dependencies": { 45 | "bcryptjs": "^2.4.3", 46 | "class-transformer": "^0.2.0", 47 | "class-transformer-validator": "^0.7.1", 48 | "class-validator": "^0.9.1", 49 | "dotenv": "^8.0.0", 50 | "jsonwebtoken": "^8.5.1", 51 | "moment": "^2.24.0", 52 | "mssql": "^5.1.0", 53 | "reflect-metadata": "^0.1.12", 54 | "typeorm": "^0.2.16" 55 | }, 56 | "jest": { 57 | "moduleFileExtensions": [ 58 | "ts", 59 | "tsx", 60 | "js" 61 | ], 62 | "transform": { 63 | "^.+\\.(ts|tsx)$": "ts-jest" 64 | }, 65 | "globals": { 66 | "ts-jest": { 67 | "tsConfig": "tsconfig.json" 68 | } 69 | }, 70 | "testMatch": [ 71 | "**/__test__/func/*.spec.+(ts|tsx|js)" 72 | ], 73 | "setupFilesAfterEnv": [ 74 | "./jest.setup.js" 75 | ], 76 | "testEnvironment": "node" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /__test__/func/auth-authenticate.spec.ts: -------------------------------------------------------------------------------- 1 | import { verify } from 'jsonwebtoken'; 2 | 3 | import { User } from '@boilerplate/entity'; 4 | import { authenticate, AuthenticateInput } from '@boilerplate/func'; 5 | import { DB, UnauthorizedError, UserFriendlyError } from '@boilerplate/util'; 6 | 7 | import { createMockUsers, init } from '../entity/mock'; 8 | import { users } from '../entity/users'; 9 | 10 | beforeAll(async (done) => { 11 | await init(); 12 | await createMockUsers(); 13 | 14 | done(); 15 | }); 16 | 17 | describe('auth-authenticate', () => { 18 | 19 | it('should work when correct password with emailAddress', async () => { 20 | const input = new AuthenticateInput(); 21 | input.emailAddress = users.admin_1.emailAddress; 22 | input.password = users.admin_1.password; 23 | 24 | const output = await authenticate(input); 25 | expect(output).toBeDefined(); 26 | 27 | const connection = await DB.getConnection(); 28 | const userRepository = connection.getRepository(User); 29 | 30 | const count = await userRepository.count(); 31 | expect(count).toBe(Object.keys(users).length); 32 | 33 | const user = await userRepository.findOneOrFail(); 34 | const decoded = await verify(output.accessToken, process.env.AUTH_SECRET as string) as any; 35 | expect(decoded.sub).toBe(user.id.toString()); 36 | }); 37 | 38 | it('should error when incorrect password', async () => { 39 | const input = new AuthenticateInput(); 40 | input.emailAddress = users.admin_1.emailAddress; 41 | input.password = 'incorrect'; 42 | 43 | await expect(authenticate(input)).rejects.toThrowError(UnauthorizedError); 44 | }); 45 | 46 | it('should error when incorrect password', async () => { 47 | const input = new AuthenticateInput(); 48 | input.emailAddress = users.admin_1.emailAddress; 49 | 50 | // tslint:disable-next-line:max-line-length 51 | input.password = 'incorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrectincorrect'; 52 | 53 | await expect(authenticate(input)).rejects.toThrowError(UnauthorizedError); 54 | }); 55 | 56 | it('should error when no sign up', async () => { 57 | const input = new AuthenticateInput(); 58 | input.emailAddress = 'A000000'; 59 | input.password = 'incorrect'; 60 | 61 | await expect(authenticate(input)).rejects.toThrowError(UserFriendlyError); 62 | }); 63 | 64 | it('should not work when empty input', async () => { 65 | const input = new AuthenticateInput(); 66 | 67 | await expect(authenticate(input)).rejects.toThrowError(UserFriendlyError); 68 | }); 69 | 70 | }); 71 | -------------------------------------------------------------------------------- /src/migration/1559877528912-Init.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class Init1559877528912 implements MigrationInterface { 4 | 5 | public async up(queryRunner: QueryRunner): Promise { 6 | await queryRunner.query(`CREATE TABLE "general_device" ("id" uniqueidentifier NOT NULL CONSTRAINT "DF_057bfe39ef2ad01b9f6e733b662" DEFAULT NEWSEQUENTIALID(), "type" nvarchar(255) NOT NULL, "unit" nvarchar(255) NOT NULL, CONSTRAINT "PK_057bfe39ef2ad01b9f6e733b662" PRIMARY KEY ("id"))`); 7 | await queryRunner.query(`CREATE TABLE "device" ("id" uniqueidentifier NOT NULL CONSTRAINT "DF_2dc10972aa4e27c01378dad2c72" DEFAULT NEWSEQUENTIALID(), "generalDeviceId" uniqueidentifier, CONSTRAINT "PK_2dc10972aa4e27c01378dad2c72" PRIMARY KEY ("id"))`); 8 | await queryRunner.query(`CREATE TABLE "user" ("id" uniqueidentifier NOT NULL CONSTRAINT "DF_cace4a159ff9f2512dd42373760" DEFAULT NEWSEQUENTIALID(), "mobilePhone" nvarchar(255) NOT NULL, "password" nvarchar(255) NOT NULL, "roles" ntext NOT NULL, "createDate" datetime2 NOT NULL CONSTRAINT "DF_456a6c03f0ac80b3a4ae72ffed8" DEFAULT getdate(), "updateDate" datetime2 NOT NULL CONSTRAINT "DF_b802fcb424617b0cef57d37901f" DEFAULT getdate(), "tokenVersion" nvarchar(255), "enabled" bit NOT NULL CONSTRAINT "DF_654df1c6ddbd210e467577b6ecc" DEFAULT 1, CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`); 9 | await queryRunner.query(`CREATE TABLE "device_history" ("id" int NOT NULL IDENTITY(1,1), "userId" uniqueidentifier NOT NULL, "start" datetime NOT NULL, "end" datetime NOT NULL, "deviceId" uniqueidentifier, CONSTRAINT "PK_e7b12f40c596560b264d9cd68f5" PRIMARY KEY ("id"))`); 10 | await queryRunner.query(`ALTER TABLE "device" ADD CONSTRAINT "FK_29e8021cc405d9002561846b1ba" FOREIGN KEY ("generalDeviceId") REFERENCES "general_device"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); 11 | await queryRunner.query(`ALTER TABLE "device_history" ADD CONSTRAINT "FK_a7eda83dd4b12447ffe7ccc6b6b" FOREIGN KEY ("deviceId") REFERENCES "device"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); 12 | await queryRunner.query(`ALTER TABLE "device_history" ADD CONSTRAINT "FK_5d54592a06dded1a8629423df26" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); 13 | } 14 | 15 | public async down(queryRunner: QueryRunner): Promise { 16 | await queryRunner.query(`ALTER TABLE "device_history" DROP CONSTRAINT "FK_5d54592a06dded1a8629423df26"`); 17 | await queryRunner.query(`ALTER TABLE "device_history" DROP CONSTRAINT "FK_a7eda83dd4b12447ffe7ccc6b6b"`); 18 | await queryRunner.query(`ALTER TABLE "device" DROP CONSTRAINT "FK_29e8021cc405d9002561846b1ba"`); 19 | await queryRunner.query(`DROP TABLE "device_history"`); 20 | await queryRunner.query(`DROP TABLE "user"`); 21 | await queryRunner.query(`DROP TABLE "device"`); 22 | await queryRunner.query(`DROP TABLE "general_device"`); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /doc/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { dump } from 'js-yaml'; 3 | 4 | import { dtoEntries } from './dto'; 5 | import { functionEntries } from './function'; 6 | import { SwaggerDefinitionProperties, SwaggerFile, SwaggerPath } from './swagger'; 7 | import { extractClassAndImportPath, normalizeToSwaggerType } from './util'; 8 | 9 | const pkg: any = JSON.parse(fs.readFileSync('package.json').toString()); 10 | 11 | const swaggerFile: SwaggerFile = { 12 | swagger: '2.0', 13 | info: { 14 | version: pkg.version, 15 | title: 'Azure Functions TypeScript Boilerplate', 16 | }, 17 | host: 'localhost:7071', 18 | basePath: '/', 19 | schemes: [ 20 | 'http', 21 | 'https', 22 | ], 23 | paths: {}, 24 | definitions: {}, 25 | }; 26 | 27 | for (const { filePath, inputClassName, outputClassName } of functionEntries) { 28 | const functionJsonPath = filePath.replace('index.ts', 'function.json'); 29 | 30 | const functionJsonString = fs.readFileSync(functionJsonPath).toString(); 31 | const functionJson = JSON.parse(functionJsonString); 32 | 33 | if (functionJsonString.includes('httpTrigger') === false) { 34 | continue; 35 | } 36 | 37 | const route = `/api/${functionJson.bindings[0].route}`; 38 | 39 | const regex = /\/api\/([a-zA-Z]+)(\/[a-zA-Z]+)?/; 40 | const matches = regex.exec(route); 41 | if (matches === null) { 42 | throw new Error(); 43 | } 44 | 45 | const tag = matches[1]; 46 | 47 | // /api/Auth/Authenticate -> api_Auth_Authenticate 48 | const operationId = route.replace(/\//g, '_').slice(1); 49 | 50 | const path: SwaggerPath = { 51 | post: { 52 | operationId, 53 | tags: [ 54 | tag, 55 | ], 56 | consumes: [ 57 | 'application/json', 58 | ], 59 | produces: [ 60 | 'application/json', 61 | ], 62 | parameters: [ 63 | { 64 | in: 'header', 65 | name: 'X-Authorization', 66 | type: 'string', 67 | required: false, 68 | }, 69 | ], 70 | responses: { 71 | 200: { 72 | description: 'Success', 73 | }, 74 | }, 75 | }, 76 | }; 77 | 78 | if (inputClassName !== undefined) { 79 | const parameter = { 80 | name: 'input', 81 | in: 'body', 82 | required: true, 83 | schema: { 84 | $ref: `#/definitions/${inputClassName}`, 85 | }, 86 | }; 87 | 88 | path.post.parameters.push(parameter); 89 | } 90 | 91 | if (outputClassName !== undefined) { 92 | path.post.responses[200].schema = { 93 | $ref: `#/definitions/${outputClassName}`, 94 | }; 95 | } 96 | 97 | swaggerFile.paths[route] = path; 98 | } 99 | 100 | for (const dtoEntry of dtoEntries) { 101 | const definition = { 102 | type: 'object', 103 | properties: {} as SwaggerDefinitionProperties, 104 | }; 105 | 106 | for (const member of dtoEntry.members) { 107 | let type = member.type; 108 | if (type.includes('import')) { 109 | const { className } = extractClassAndImportPath(member.type); 110 | 111 | type = `#/definitions/${className}`; 112 | 113 | definition.properties[member.name] = type.endsWith('[]') ? 114 | { 115 | type: 'array', 116 | items: { 117 | $ref: normalizeToSwaggerType(type.replace('[]', '')), 118 | }, 119 | } : 120 | { 121 | $ref: normalizeToSwaggerType(type), 122 | }; 123 | 124 | if (member.decorators.length > 0) { 125 | definition.properties[member.name].description = member.decorators.join(' '); 126 | } 127 | 128 | continue; 129 | } 130 | 131 | definition.properties[member.name] = type.endsWith('[]') ? 132 | { 133 | type: 'array', 134 | items: { 135 | type: normalizeToSwaggerType(type.replace('[]', '')), 136 | }, 137 | } : 138 | { 139 | type: normalizeToSwaggerType(type), 140 | }; 141 | 142 | if (member.decorators.length > 0) { 143 | definition.properties[member.name].description = member.decorators.join(' '); 144 | } 145 | } 146 | 147 | swaggerFile.definitions[dtoEntry.className] = definition; 148 | } 149 | 150 | export const swaggerFileObj = swaggerFile; 151 | export const swaggerYaml = dump(swaggerFile); 152 | -------------------------------------------------------------------------------- /doc/function.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FunctionDeclaration, Identifier, Node, Project, Statement, ts, TypeGuards, 3 | } from 'ts-morph'; 4 | 5 | import { ClassEntry, dtoEntries } from './dto'; 6 | import { extractClassAndImportPath } from './util'; 7 | 8 | export interface FunctionEntry { 9 | functionStatement: string; 10 | filePath: string; 11 | inputClassName: string | undefined; 12 | outputClassName: string | undefined; 13 | } 14 | 15 | const funcEntries: FunctionEntry[] = []; 16 | 17 | const project = new Project({ 18 | tsConfigFilePath: 'tsconfig.json', 19 | }); 20 | 21 | const sourceFiles = project.getSourceFiles('src/func/**/*.ts'); 22 | 23 | const functions = sourceFiles.reduce( 24 | (accumulator, currentValue) => accumulator.concat(currentValue.getFunctions().filter(f => f.getNameOrThrow().endsWith('Func'))), 25 | [] as FunctionDeclaration[], 26 | ); 27 | 28 | functions.map((f) => { 29 | for (const statement of f.getStatements()) { 30 | 31 | // context.res = await Func.run1(context, encodeVideo, EncodeVideoInput, Authorized.permit({ anyRoles: [Role.Admins] })); 32 | parseStatement(statement, f); 33 | } 34 | }); 35 | 36 | function parseStatement(statement: Statement, functionDeclaration: FunctionDeclaration) { 37 | if (TypeGuards.isExpressionStatement(statement)) { 38 | const filePath = functionDeclaration.getSourceFile().getFilePath(); 39 | 40 | let functionStatement: string = ''; 41 | let inputClassName: string | undefined; 42 | let outputClassName: string | undefined; 43 | 44 | for (const expression of statement.getExpression().getChildren()) { 45 | functionStatement = expression.getText(); 46 | 47 | // await Func.run1(context, encodeVideo, EncodeVideoInput, Authorized.permit({ anyRoles: [Role.Admins] })) 48 | if (TypeGuards.isAwaitExpression(expression)) { 49 | for (const syntaxList of expression.getExpression().getChildren()) { 50 | 51 | // Identifier, Identifier, Identifier, CallExpression 52 | // context, encodeVideo, EncodeVideoInput, Authorized.permit({ anyRoles: [Role.Admins] }) 53 | const { inputEntry: inEntry, outputEntry: outEntry } = parseParameters(syntaxList, functionDeclaration); 54 | 55 | inputClassName = inputClassName === undefined && inEntry ? inEntry.className : inputClassName; 56 | outputClassName = outputClassName === undefined && outEntry ? outEntry.className : outputClassName; 57 | } 58 | } 59 | } 60 | 61 | const functionEntry = { 62 | functionStatement, 63 | filePath, 64 | inputClassName, 65 | outputClassName, 66 | }; 67 | 68 | funcEntries.push(functionEntry); 69 | } 70 | } 71 | 72 | function parseParameters(syntaxList: Node, functionDeclaration: FunctionDeclaration) { 73 | let inputEntry: ClassEntry | undefined; 74 | let outputEntry: ClassEntry | undefined; 75 | 76 | if (TypeGuards.isSyntaxList(syntaxList)) { 77 | for (const child of syntaxList.getChildren()) { 78 | 79 | // context 80 | // encodeVideo 81 | // EncodeVideoInput 82 | // Authorized.permit({ anyRoles: [Role.Admins] }) 83 | if (TypeGuards.isIdentifier(child)) { 84 | 85 | // context 86 | if (child.getText() === 'context') { 87 | continue; 88 | } 89 | 90 | // encodeVideo 91 | outputEntry = outputEntry === undefined ? parseOutput(child, functionDeclaration) : outputEntry; 92 | 93 | // EncodeVideoInput 94 | inputEntry = inputEntry === undefined ? parseInput(child) : inputEntry; 95 | } 96 | } 97 | } 98 | 99 | return { 100 | inputEntry, 101 | outputEntry, 102 | }; 103 | } 104 | 105 | function parseInput(identifier: Identifier) { 106 | const dtoEntry = dtoEntries.find(de => de.className === identifier.getText()); 107 | if (dtoEntry) { 108 | return dtoEntry; 109 | } 110 | 111 | return undefined; 112 | } 113 | 114 | function parseOutput(child: Identifier, functionDeclaration: FunctionDeclaration) { 115 | const funcDeclaration = functionDeclaration.getSourceFile().getFunction(child.getText()); 116 | if (funcDeclaration) { 117 | // Skip void 118 | const returnType = funcDeclaration.getReturnType().getText(); 119 | if (returnType === 'Promise') { 120 | return undefined; 121 | } 122 | 123 | // Promise 124 | const outputClassNameWithImportPath = funcDeclaration.getReturnType().getTypeArguments().map(ta => ta.getText())[0]; 125 | if (outputClassNameWithImportPath === undefined) { 126 | throw new Error(); 127 | } 128 | 129 | const { className, absoluteImportPath } = extractClassAndImportPath(outputClassNameWithImportPath); 130 | 131 | const dtoEntry = dtoEntries.find(de => de.className === className && de.filePath === absoluteImportPath); 132 | if (dtoEntry) { 133 | return dtoEntry; 134 | } 135 | } 136 | 137 | return undefined; 138 | } 139 | 140 | export const functionEntries = funcEntries; 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Azure Functions Typescript Boilerplate 3 | [![wercker status](https://app.wercker.com/status/dd35967169a2305d61e7f6f5560fd0db/s/master "wercker status")](https://app.wercker.com/project/byKey/dd35967169a2305d61e7f6f5560fd0db)
4 | This project helps set up so many things that I encountered when developing projects with Azure Functions + TypeScript. 5 | 6 | ## Packages 7 | [typeorm](https://www.npmjs.com/package/typeorm), typescript ORM
8 | [bcrypt](https://www.npmjs.com/package/bcrypt), password encryption
9 | [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken), JWT token authentication, `X-Authorization: Bearer `
10 | [tslint](https://www.npmjs.com/package/tslint)
11 | [parcel-bundler](https://www.npmjs.com/package/parcel-bundler), bundle your functions to decrease the network overheads for faster [cold start](https://blogs.msdn.microsoft.com/appserviceteam/2018/02/07/understanding-serverless-cold-start/)
12 | [class-transformer-validator](https://www.npmjs.com/package/parcel-bundler), transform JSON into TS objects and validate them
13 | [jest](https://www.npmjs.com/package/jest), testing
14 | [swagger](https://swagger.io) + [ts-morph](https://www.npmjs.com/package/ts-morph), automatically generate the swagger document `doc/versions/staging.json`
15 | [dotenv](https://www.npmjs.com/package/dotenv), `.env`
16 | 17 | ## Configs 18 | `src/local.settings.json` for `AUTH_SECRET`
19 | `ormconfig.json` for your DB 20 | 21 | ## Default functions 22 | `src/func/init`, a great start to initialize the admin account
23 | `src/func/hello-world`, a great start
24 | `src/func/auth-sign-up`, a sign up demos
25 | `src/func/auth-authenticate`, a jwt authenticate demo
26 | `src/func/role-get-roles`, a jwt authorization demo
27 | `src/func/device-get-devices`
28 | `src/func/device-add-device`
29 | `src/func/swagger-doc`, a HTML endpoint to see the generated swagger doc
30 | 31 | ```bash 32 | npm run typeorm:cli -- migration:generate -c dev -n Init 33 | npm run typeorm:cli -- migration:run -c dev 34 | npm run typeorm:cli -- migration:revert -c dev 35 | ``` 36 | 37 | ## Hello World 38 | `src/func/role-get-roles` 39 | ```javascript 40 | import { Context } from '@azure/functions'; 41 | import { Role } from '@boilerplate/entity'; 42 | import { Authorized, Func, InternalServerError } from '@boilerplate/util'; 43 | 44 | export class GetRolesOutput { 45 | constructor(public roles: Role[]) { 46 | } 47 | } 48 | 49 | export async function getRoles(_: any, roles?: Role[]): Promise { 50 | // 3. Your jwt token will be automatically parsed, you can get userId and roles here 51 | // 4. return your output 52 | if (roles) return new GetRolesOutput(roles); 53 | 54 | throw new InternalServerError(); 55 | } 56 | 57 | // 5. The entry function must end with `Func` or the swagger doc generator cannot find the entry point 58 | export async function getRolesFunc(context: Context) { 59 | // 1. Wrap your logic in Function.run 60 | context.res = await Func.run0( 61 | context, 62 | getRoles, 63 | // 2. You can define the permitted roles to this function 64 | Authorized.permit({ 65 | anyRoles: [Role.Patients, Role.Nurses, Role.Doctors, Role.Instructors], 66 | })); 67 | } 68 | ``` 69 | 70 | `src/func/device-add-device` 71 | ```javascript 72 | import { IsDefined } from 'class-validator'; 73 | 74 | import { Context } from '@azure/functions'; 75 | import { Device, DeviceDto, GeneralDevice, Role } from '@boilerplate/entity'; 76 | import { Authorized, DB, Func, UserFriendlyError } from '@boilerplate/util'; 77 | 78 | // 6. Input 79 | export class AddDeviceInput { 80 | @IsDefined() 81 | generalDeviceId: string; 82 | } 83 | 84 | // 7. Output 85 | export class AddDeviceOutput { 86 | constructor(public device: DeviceDto) { 87 | } 88 | } 89 | 90 | // 8. The input JSON is automatically parsed 91 | export async function addDevice(input: AddDeviceInput): Promise { 92 | // 9. Get a DB connection with TypeORM 93 | const connection = await DB.getConnection(); 94 | const generalDeviceRepository = connection.getRepository(GeneralDevice); 95 | 96 | // 10. API clients will see your exception message defined here 97 | const generalDevice = await generalDeviceRepository.findOne(input.generalDeviceId); 98 | if (!generalDevice) throw new UserFriendlyError('The GeneralDevice does not exist'); 99 | 100 | // 11. Create a new instance 101 | let device = new Device(); 102 | device.generalDevice = generalDevice; 103 | 104 | device = await generalDeviceRepository.save(device); 105 | 106 | // 12. Return deviceDto with AddDeviceOutput 107 | return new AddDeviceOutput(DeviceDto.from(device)); 108 | } 109 | 110 | export async function deviceAddDeviceFunc(context: Context) { 111 | context.res = await Func.run1( 112 | context, 113 | addDevice, 114 | // The input type 115 | AddDeviceInput, 116 | Authorized.permit({ 117 | anyRoles: [Role.Nurses], 118 | }), 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /doc/dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClassDeclaration, ParameterDeclaration, Project, PropertyDeclaration, SyntaxKind, Type, 3 | } from 'ts-morph'; 4 | 5 | import { flat, getClassDeclaration } from './util'; 6 | 7 | export interface MemberEntry { 8 | name: string; 9 | type: string; 10 | decorators: string[]; 11 | } 12 | 13 | export interface ClassEntry { 14 | className: string; 15 | filePath: string; 16 | members: MemberEntry[]; 17 | } 18 | 19 | const project = new Project({ 20 | tsConfigFilePath: 'tsconfig.json', 21 | }); 22 | 23 | const sourceFiles = project.getSourceFiles('src/**/*.ts'); 24 | 25 | const entries: ClassEntry[] = []; 26 | 27 | const inputs = sourceFiles.reduce( 28 | (accumulator, currentValue) => accumulator.concat(currentValue.getClasses().filter(c => (c.getName() || '').endsWith('Input'))), 29 | [] as ClassDeclaration[], 30 | ); 31 | const outputs = sourceFiles.reduce( 32 | (accumulator, currentValue) => accumulator.concat(currentValue.getClasses().filter(c => (c.getName() || '').endsWith('Output'))), 33 | [] as ClassDeclaration[], 34 | ); 35 | 36 | inputs.forEach((o) => { 37 | addClassDeclaration(o); 38 | }); 39 | outputs.forEach((o) => { 40 | addClassDeclaration(o); 41 | }); 42 | 43 | function typeToMemberEntry(node: PropertyDeclaration | ParameterDeclaration) { 44 | // const symbol = arrayType.getSymbol(); 45 | // if (symbol !== undefined && symbol.getDeclarations().some(d => d.getKind() === SyntaxKind.EnumDeclaration)) { 46 | 47 | const type = node.getType().getNonNullableType(); 48 | 49 | // Array 50 | if (type.isArray()) { 51 | const arrayType = type.getArrayType() as Type; 52 | 53 | // Enum Array 54 | const symbol = arrayType.getSymbol(); 55 | if (symbol !== undefined && symbol.getDeclarations().some(d => d.getKind() === SyntaxKind.EnumDeclaration)) { 56 | return { 57 | name: node.getName(), 58 | type: 'int[]', 59 | decorators: node.getDecorators().map((d: any) => d.getText()).map(d => d.replace(/(?:\r\n|\r|\n)/gm, '')), 60 | } as MemberEntry; 61 | } 62 | 63 | // Indirect Array 64 | if (arrayType.getText().startsWith('import')) { 65 | const indirectClassDeclaration = getClassDeclaration(arrayType.getText(), sourceFiles); 66 | addClassDeclaration(indirectClassDeclaration); 67 | } 68 | 69 | return { 70 | name: node.getName(), 71 | type: type.getText(), 72 | decorators: node.getDecorators().map((d: any) => d.getText()).map(d => d.replace(/(?:\r\n|\r|\n)/gm, '')), 73 | } as MemberEntry; 74 | } 75 | 76 | // Enum 77 | const symbol = type.getSymbol(); 78 | if (symbol !== undefined && symbol.getDeclarations().some(d => d.getKind() === SyntaxKind.EnumDeclaration)) { 79 | return { 80 | name: node.getName(), 81 | type: 'int', 82 | decorators: node.getDecorators().map((d: any) => d.getText()).map(d => d.replace(/(?:\r\n|\r|\n)/gm, '')), 83 | } as MemberEntry; 84 | } 85 | 86 | // Union 87 | if (type.isUnion()) { 88 | type.getText().split('|') 89 | .map(d => d.replace(/( |\[|\])/gm, '')) 90 | .map((d) => { 91 | if (d.startsWith('import')) { 92 | addClassDeclaration(getClassDeclaration(d, sourceFiles)); 93 | } 94 | }); 95 | return { 96 | name: node.getName(), 97 | type: type.getText(), 98 | decorators: node.getDecorators().map((d: any) => d.getText()).map(d => d.replace(/(?:\r\n|\r|\n)/gm, '')), 99 | } as MemberEntry; 100 | } 101 | 102 | // Indirect 103 | if (type.getText().startsWith('import')) { 104 | const indirectClassDeclaration = getClassDeclaration(type.getText(), sourceFiles); 105 | addClassDeclaration(indirectClassDeclaration); 106 | } 107 | 108 | return { 109 | name: node.getName(), 110 | type: type.getText(), 111 | decorators: node.getDecorators().map((d: any) => d.getText()).map(d => d.replace(/(?:\r\n|\r|\n)/gm, '')), 112 | } as MemberEntry; 113 | } 114 | 115 | // Recursively retrieve all class declarations 116 | function addClassDeclaration(cd: ClassDeclaration) { 117 | const className = cd.getNameOrThrow(); 118 | const filePath = cd.getSourceFile().getFilePath(); 119 | 120 | // Skip duplicate 121 | if (entries.some(e => e.className === className && e.filePath === filePath)) { 122 | return; 123 | } 124 | 125 | const propertyMembers = cd 126 | .getMembers() 127 | .map((member) => { 128 | if (member instanceof PropertyDeclaration) { 129 | return typeToMemberEntry(member); 130 | } 131 | 132 | return undefined as unknown as MemberEntry; 133 | }) 134 | .filter(m => m); 135 | 136 | const constructorMembers = cd 137 | .getConstructors() 138 | .map((c) => { 139 | return c.getParameters().map((param) => { 140 | return typeToMemberEntry(param); 141 | }); 142 | }); 143 | 144 | entries.push({ 145 | className, 146 | filePath, 147 | members: [...propertyMembers, ...flat(constructorMembers)], 148 | }); 149 | } 150 | 151 | export const dtoEntries = entries; 152 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const del = require('del'); 2 | const fs = require('fs'); 3 | const parcelBundler = require('parcel-bundler'); 4 | 5 | const gulp = require('gulp'); 6 | const ts = require('gulp-typescript'); 7 | const sourcemaps = require('gulp-sourcemaps'); 8 | const filter = require('gulp-filter'); 9 | const change = require('gulp-change'); 10 | const rename = require('gulp-rename'); 11 | const changed = require('gulp-changed'); 12 | 13 | const swagger = require('./doc/index.ts'); fs.writeFileSync('doc/versions/staging.json', JSON.stringify(swagger.swaggerFileObj, null, 2), 'utf8'); 14 | 15 | const isProd = process.env.NODE_ENV === 'Development' ? false : true; 16 | 17 | function clean() { 18 | return del(['dist', 'node_modules/@boilerplate', '.cache']); 19 | } 20 | 21 | function modules() { 22 | const tsProject = ts.createProject('tsconfig.json'); 23 | const scope = ['src/entity/**/*.ts', 'src/util/**/*.ts', 'src/func/**/*.ts', 'src/svc/**/*.ts']; 24 | const outDir = 'node_modules/@boilerplate'; 25 | const swaggerFileObj = swagger.swaggerFileObj; 26 | 27 | if (isProd) { 28 | return gulp 29 | .src('src/**/*.ts') 30 | .pipe(filter(scope)) 31 | .pipe(change((content) => { 32 | if (content.includes('$___specJson___')) { 33 | return content.replace('$___specJson___', JSON.stringify(swaggerFileObj)); 34 | } 35 | 36 | return content; 37 | })) 38 | .pipe(tsProject()) 39 | .pipe(gulp.dest(outDir)); 40 | } 41 | 42 | return gulp 43 | .src('src/**/*.ts') 44 | .pipe(changed(outDir, { transformPath: newPath => newPath.replace('.ts', '.js') })) 45 | 46 | .pipe(filter(scope)) 47 | .pipe(sourcemaps.init()) 48 | .pipe(tsProject()) 49 | .pipe(change((content) => { 50 | if (content.includes('\\${specJson}')) { 51 | return content.replace('\\${specJson}', JSON.stringify(swaggerFileObj)); 52 | } 53 | 54 | return content; 55 | })) 56 | .pipe(sourcemaps.mapSources((sourcePath, file) => { 57 | return sourcePath.includes('func') ? '../../../../src/' + sourcePath : '../../../src/' + sourcePath; 58 | })) 59 | .pipe(sourcemaps.write('.')) 60 | .pipe(gulp.dest(outDir)); 61 | } 62 | 63 | function libs() { 64 | const tsProject = ts.createProject('tsconfig.json'); 65 | 66 | if (isProd) { 67 | return gulp 68 | .src('src/index.ts') 69 | .pipe(tsProject()) 70 | .pipe(gulp.dest('dist')); 71 | } 72 | 73 | return gulp 74 | .src('src/index.ts') 75 | .pipe(changed('dist', { transformPath: newPath => newPath.replace('.ts', '.js') })) 76 | 77 | .pipe(sourcemaps.init()) 78 | .pipe(tsProject()) 79 | .pipe(sourcemaps.mapSources(function (sourcePath, file) { 80 | return '../src/' + sourcePath; 81 | })) 82 | .pipe(sourcemaps.write('.')) 83 | .pipe(gulp.dest('dist')); 84 | } 85 | 86 | function funcBootstraps() { 87 | const tsProject = ts.createProject('tsconfig.json'); 88 | const template = ` 89 | import { \${funcName} } from '../index'; 90 | 91 | export async function run(context: any) { 92 | return \${funcName}(context); 93 | } 94 | `; 95 | 96 | return gulp 97 | .src('src/func/*/index.ts') 98 | .pipe(changed('dist', { transformPath: newPath => newPath.replace('.ts', '.js') })) 99 | 100 | // 1. Rename to bootstrap.ts 101 | // 2. Modify content with the template 102 | // 3. Transpile 103 | // 4. Rename to index.js 104 | .pipe(rename({ basename: 'bootstrap' })) 105 | .pipe(change((content) => { 106 | const regex = /function\ (\S+)\(context/; 107 | const matches = content.match(regex); 108 | 109 | return template.split('${funcName}').join(matches[1]); 110 | })) 111 | .pipe(tsProject()) 112 | .pipe(rename({ basename: 'index' })) 113 | 114 | .pipe(gulp.dest('dist')); 115 | } 116 | 117 | function funcDefs() { 118 | return gulp 119 | .src('src/func/**/function.json') 120 | .pipe(gulp.dest('dist')); 121 | } 122 | 123 | function hostDefs() { 124 | let src = ['src/host.json', 'src/extensions.csproj', 'ormconfig.json']; 125 | 126 | if (isProd) { 127 | src = src.concat(['src/package.json']); 128 | } else { 129 | src = src.concat(['src/local.settings.json']); 130 | } 131 | 132 | return gulp 133 | .src(src) 134 | .pipe(gulp.dest('dist')); 135 | } 136 | 137 | async function libsPacks() { 138 | if (isProd) { 139 | const outDir = 'dist'; 140 | const options = { 141 | outDir: outDir, 142 | outFile: 'index.js', 143 | watch: false, 144 | cache: true, 145 | cacheDir: '.cache', 146 | contentHash: false, 147 | minify: true, 148 | scopeHoist: false, 149 | target: 'node', 150 | bundleNodeModules: true, 151 | sourceMaps: false, 152 | detailedReport: false, 153 | logLevel: 3 154 | }; 155 | 156 | const bundler = new parcelBundler('dist/index.js', options); 157 | 158 | const bundle = await bundler.bundle(); 159 | 160 | fs.writeFileSync(outDir + '/index.js.bundle', Array.from(bundle.assets).map(a => `${a.name},${a.bundledSize}`).join('\n')); 161 | } 162 | } 163 | 164 | function watch() { 165 | gulp.watch(['src/entity/**/*.ts', 'src/util/**/*.ts', 'src/func/**/*.ts', 'src/svc/**/*.ts'], gulp.series(modules)); 166 | } 167 | 168 | exports.clean = clean; 169 | exports.modules = modules; 170 | exports.libs = libs; 171 | exports.funcBootstraps = funcBootstraps; 172 | exports.funcDefs = funcDefs; 173 | exports.hostDefs = hostDefs; 174 | 175 | const build = gulp.series( 176 | gulp.parallel( 177 | funcDefs, 178 | hostDefs 179 | ), 180 | gulp.parallel( 181 | modules, 182 | libs, 183 | funcBootstraps, 184 | ), 185 | libsPacks 186 | ); 187 | 188 | gulp.task('watch', watch); 189 | gulp.task('build', build); 190 | gulp.task('default', build); -------------------------------------------------------------------------------- /src/util/func.ts: -------------------------------------------------------------------------------- 1 | import { ClassType, transformAndValidate } from 'class-transformer-validator'; 2 | import { ValidationError } from 'class-validator'; 3 | import { JsonWebTokenError, verify } from 'jsonwebtoken'; 4 | import moment from 'moment'; 5 | 6 | import { Context } from '@azure/functions'; 7 | import { User, UserRole } from '@boilerplate/entity'; 8 | 9 | import { Authorized } from './authorized'; 10 | import { DB } from './db'; 11 | import { InternalServerError, UnauthorizedError, UserFriendlyError } from './error'; 12 | import { Output } from './output'; 13 | 14 | export interface Func0 { 15 | (userId?: string, roles?: UserRole[]): any; 16 | } 17 | 18 | export interface Func1 { 19 | (input: TInput): any; 20 | } 21 | 22 | export interface Func3 { 23 | (input: TInput, userId?: string, roles?: UserRole[]): any; 24 | } 25 | 26 | export class AuditLog { 27 | url: string; 28 | input?: string; 29 | output?: string; 30 | executionTime: any; 31 | executionDuration: number; 32 | userId?: string; 33 | exception?: string; 34 | } 35 | 36 | export namespace Func { 37 | 38 | async function verifyAccessToken(req: any, authorized?: Authorized): Promise<{ userId?: string; roles?: UserRole[]; }> { 39 | // Check header 40 | if (!('x-authorization' in req.headers)) { 41 | if (authorized) { 42 | throw new UnauthorizedError(); 43 | } 44 | 45 | return {}; 46 | } 47 | 48 | const accessToken: string = req.headers['x-authorization'].replace('Bearer ', ''); 49 | 50 | // Get auth secret 51 | const secret = process.env.AUTH_SECRET; 52 | if (!secret) { 53 | throw new InternalServerError('process.env.AUTH_SECRET is empty.'); 54 | } 55 | 56 | const decoded = await verify(accessToken, secret) as any; 57 | 58 | const userId = decoded.sub as string; 59 | 60 | const connection = await DB.getConnection(); 61 | const userRepository = connection.getRepository(User); 62 | const user = await userRepository.findOne({ 63 | where: { 64 | id: userId, 65 | }, 66 | select: ['tokenVersion', 'roles', 'enabled'], 67 | }); 68 | if (user === undefined || user.enabled === false) { 69 | throw new UnauthorizedError(); 70 | } 71 | if (decoded.version !== user.tokenVersion) { 72 | throw new UnauthorizedError(); 73 | } 74 | 75 | // Check roles 76 | if (authorized && !authorized.permitted(user.roles)) { 77 | throw new UnauthorizedError(); 78 | } 79 | 80 | return { 81 | userId, 82 | roles: user.roles, 83 | }; 84 | } 85 | 86 | async function parseInput(req: any, classType: ClassType) { 87 | if (classType) { 88 | if (req.body) { 89 | return await transformAndValidate(classType, req.body); 90 | } 91 | 92 | throw new UserFriendlyError(''); 93 | } 94 | 95 | return undefined; 96 | } 97 | 98 | async function insertAuditLog(auditLog: AuditLog) { 99 | // This function allows some logics to log all requests 100 | console.log(auditLog); 101 | } 102 | 103 | async function run( 104 | context: Context, 105 | func: (verifyResult: { userId?: string; roles?: UserRole[] }) => Promise<{ status: number; body: Output; headers: {} }>, 106 | authorized?: Authorized, 107 | ): Promise<{ status: number; body: Output; headers: {} }> { 108 | const auditLog: AuditLog = { 109 | executionTime: moment(), 110 | executionDuration: 0, 111 | url: '', 112 | }; 113 | 114 | let res: { status: number; body: Output; headers: {} }; 115 | 116 | try { 117 | const req = context.req; 118 | 119 | const verifyResult = await verifyAccessToken(req, authorized); 120 | 121 | if (verifyResult) { 122 | auditLog.userId = verifyResult.userId; 123 | } 124 | 125 | res = await func(verifyResult); 126 | } catch (ex) { 127 | console.log(ex); 128 | 129 | auditLog.exception = ex.toString(); 130 | 131 | if (ex instanceof UnauthorizedError || ex instanceof JsonWebTokenError) { 132 | res = Output.unauthorized(); 133 | } else if (Array.isArray(ex)) { 134 | const constraints = ex 135 | .filter(ex => ex instanceof ValidationError) 136 | .reduce((acc, ex) => acc.concat(ex.constraints), []); 137 | 138 | res = Output.badRequest(constraints); 139 | } else if (ex instanceof UserFriendlyError) { 140 | res = Output.error(ex.message); 141 | } else { 142 | res = Output.internalError(); 143 | } 144 | 145 | if (process.env.NODE_ENV === 'Development') { 146 | console.error(ex); 147 | } 148 | } 149 | 150 | if (context.req) { 151 | if (context.req.url) auditLog.url = context.req.url; 152 | 153 | auditLog.input = JSON.stringify(context.req); 154 | } 155 | if (res) { 156 | auditLog.output = JSON.stringify(res); 157 | } 158 | auditLog.executionDuration = new Date().valueOf() - auditLog.executionTime.valueOf(); 159 | 160 | if (process.env.NODE_ENV === 'Production' && auditLog.url.includes('AuditLog/List') === false) { 161 | await insertAuditLog(auditLog); 162 | } 163 | 164 | return res; 165 | } 166 | 167 | export async function run1( 168 | context: any, 169 | func: Func1 | Func3, 170 | inputClass: ClassType, 171 | authorized?: Authorized, 172 | ): Promise<{ status: number; body: Output; headers: {} }> { 173 | return await run( 174 | context, 175 | async (verifyResult) => { 176 | const input = await parseInput(context.req, inputClass); 177 | 178 | const anyFunc: any = func as any; 179 | 180 | const output = verifyResult ? await anyFunc(input, verifyResult.userId, verifyResult.roles) : await anyFunc(input); 181 | 182 | return Output.ok(output); 183 | }, 184 | authorized); 185 | } 186 | 187 | export async function run0( 188 | context: any, 189 | func: Func0, 190 | authorized?: Authorized, 191 | ): Promise<{ status: number; body: Output; headers: {} }> { 192 | return await run( 193 | context, 194 | async (verifyResult) => { 195 | const anyFunc: any = func as any; 196 | 197 | const output = verifyResult ? await anyFunc(verifyResult.userId, verifyResult.roles) : await anyFunc(); 198 | 199 | return Output.ok(output); 200 | }, 201 | authorized); 202 | } 203 | 204 | } 205 | -------------------------------------------------------------------------------- /doc/versions/1.0.0.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "version": "1.0.0", 5 | "title": "Azure Functions TypeScript Boilerplate" 6 | }, 7 | "host": "localhost:7071", 8 | "basePath": "/", 9 | "schemes": [ 10 | "https" 11 | ], 12 | "paths": { 13 | "/api/Auth/Authenticate": { 14 | "post": { 15 | "operationId": "api_Auth_Authenticate", 16 | "tags": [ 17 | "Auth" 18 | ], 19 | "consumes": [ 20 | "application/json" 21 | ], 22 | "produces": [ 23 | "application/json" 24 | ], 25 | "parameters": [ 26 | { 27 | "name": "input", 28 | "in": "body", 29 | "required": true, 30 | "schema": { 31 | "$ref": "#/definitions/AuthenticateInput" 32 | } 33 | } 34 | ], 35 | "responses": { 36 | "200": { 37 | "description": "Success", 38 | "schema": { 39 | "$ref": "#/definitions/AuthenticateOutput" 40 | } 41 | } 42 | } 43 | } 44 | }, 45 | "/api/Auth/SignUp": { 46 | "post": { 47 | "operationId": "api_Auth_SignUp", 48 | "tags": [ 49 | "Auth" 50 | ], 51 | "consumes": [ 52 | "application/json" 53 | ], 54 | "produces": [ 55 | "application/json" 56 | ], 57 | "parameters": [ 58 | { 59 | "name": "input", 60 | "in": "body", 61 | "required": true, 62 | "schema": { 63 | "$ref": "#/definitions/SignUpInput" 64 | } 65 | } 66 | ], 67 | "responses": { 68 | "200": { 69 | "description": "Success" 70 | } 71 | } 72 | } 73 | }, 74 | "/api/Device/AddDevice": { 75 | "post": { 76 | "operationId": "api_Device_AddDevice", 77 | "tags": [ 78 | "Device" 79 | ], 80 | "consumes": [ 81 | "application/json" 82 | ], 83 | "produces": [ 84 | "application/json" 85 | ], 86 | "parameters": [ 87 | { 88 | "name": "input", 89 | "in": "body", 90 | "required": true, 91 | "schema": { 92 | "$ref": "#/definitions/AddDeviceInput" 93 | } 94 | } 95 | ], 96 | "responses": { 97 | "200": { 98 | "description": "Success", 99 | "schema": { 100 | "$ref": "#/definitions/AddDeviceOutput" 101 | } 102 | } 103 | } 104 | } 105 | }, 106 | "/api/Device/GetDevices": { 107 | "post": { 108 | "operationId": "api_Device_GetDevices", 109 | "tags": [ 110 | "Device" 111 | ], 112 | "consumes": [ 113 | "application/json" 114 | ], 115 | "produces": [ 116 | "application/json" 117 | ], 118 | "parameters": [], 119 | "responses": { 120 | "200": { 121 | "description": "Success", 122 | "schema": { 123 | "$ref": "#/definitions/GetDevicesOutput" 124 | } 125 | } 126 | } 127 | } 128 | }, 129 | "/api/HelloWorld": { 130 | "post": { 131 | "operationId": "api_HelloWorld", 132 | "tags": [ 133 | "HelloWorld" 134 | ], 135 | "consumes": [ 136 | "application/json" 137 | ], 138 | "produces": [ 139 | "application/json" 140 | ], 141 | "parameters": [], 142 | "responses": { 143 | "200": { 144 | "description": "Success" 145 | } 146 | } 147 | } 148 | }, 149 | "/api/Role/GetRoles": { 150 | "post": { 151 | "operationId": "api_Role_GetRoles", 152 | "tags": [ 153 | "Role" 154 | ], 155 | "consumes": [ 156 | "application/json" 157 | ], 158 | "produces": [ 159 | "application/json" 160 | ], 161 | "parameters": [], 162 | "responses": { 163 | "200": { 164 | "description": "Success", 165 | "schema": { 166 | "$ref": "#/definitions/GetRolesOutput" 167 | } 168 | } 169 | } 170 | } 171 | }, 172 | "/api/SwaggerDoc": { 173 | "post": { 174 | "operationId": "api_SwaggerDoc", 175 | "tags": [ 176 | "SwaggerDoc" 177 | ], 178 | "consumes": [ 179 | "application/json" 180 | ], 181 | "produces": [ 182 | "application/json" 183 | ], 184 | "parameters": [], 185 | "responses": { 186 | "200": { 187 | "description": "Success" 188 | } 189 | } 190 | } 191 | } 192 | }, 193 | "definitions": { 194 | "AuthenticateInput": { 195 | "type": "object", 196 | "properties": { 197 | "mobilePhone": { 198 | "type": "string", 199 | "description": "@IsDefined() @IsMobilePhone('en-HK')" 200 | }, 201 | "password": { 202 | "type": "string", 203 | "description": "@IsDefined()" 204 | } 205 | } 206 | }, 207 | "SignUpInput": { 208 | "type": "object", 209 | "properties": { 210 | "mobilePhone": { 211 | "type": "string", 212 | "description": "@IsDefined() @IsMobilePhone('en-HK')" 213 | }, 214 | "password": { 215 | "type": "string", 216 | "description": "@IsDefined()" 217 | } 218 | } 219 | }, 220 | "AddDeviceInput": { 221 | "type": "object", 222 | "properties": { 223 | "generalDeviceId": { 224 | "type": "string", 225 | "description": "@IsDefined()" 226 | } 227 | } 228 | }, 229 | "Output": { 230 | "type": "object", 231 | "properties": { 232 | "success": { 233 | "type": "boolean" 234 | }, 235 | "data": { 236 | "type": "NonNullable" 237 | }, 238 | "message": { 239 | "type": "string" 240 | }, 241 | "dateTime": { 242 | "type": "string" 243 | } 244 | } 245 | }, 246 | "AuthenticateOutput": { 247 | "type": "object", 248 | "properties": { 249 | "accessToken": { 250 | "type": "string" 251 | } 252 | } 253 | }, 254 | "DeviceDto": { 255 | "type": "object", 256 | "properties": { 257 | "id": { 258 | "type": "string" 259 | }, 260 | "type": { 261 | "type": "string" 262 | }, 263 | "unit": { 264 | "type": "string" 265 | } 266 | } 267 | }, 268 | "AddDeviceOutput": { 269 | "type": "object", 270 | "properties": { 271 | "device": { 272 | "$ref": "#/definitions/DeviceDto" 273 | } 274 | } 275 | }, 276 | "GetDevicesOutput": { 277 | "type": "object", 278 | "properties": { 279 | "devices": { 280 | "type": "array", 281 | "items": { 282 | "$ref": "#/definitions/DeviceDto" 283 | } 284 | } 285 | } 286 | }, 287 | "GetRolesOutput": { 288 | "type": "object", 289 | "properties": { 290 | "roles": { 291 | "type": "array", 292 | "items": { 293 | "type": "integer" 294 | } 295 | } 296 | } 297 | } 298 | } 299 | } --------------------------------------------------------------------------------