├── .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 |
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 | [](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 | }
--------------------------------------------------------------------------------