Initial commit

This commit is contained in:
david.hon 2023-04-17 10:20:39 +08:00
parent 164373aa05
commit 8c78c2ad98
54 changed files with 23165 additions and 2 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
.gitlab-ci*
# .gitignore
Dockerfile*
.env*
data

25
.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# application
/.env*

221
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,221 @@
# -----------------------------------------------------------------------------
# Original/Ref: https://git.price-hk.com/boilerplate-samples-projects/basic-docker-ci
# -----------------------------------------------------------------------------
# Generic Gitlab CI for docker project for building and uploading img to Nexus and QCloud
#
# This file requires the following variables:
# See Also: https://git.price-hk.com/admin/application_settings/ci_cd
# (Requried:)
# - NAMESPACE: [price|myprice] # It will be prepended to the DOCKER_IMAGE_NAME
# - PRICE_DOCKER_REPO_HOST # Nexus Repo Host
# - PRICE_DOCKER_REPO_USER # Nexus Repo User
# - PRICE_DOCKER_REPO_PWD # Nexus Repo Password
# - QCLOUD_TKE_TCR_HOST # QCloud TKE TCR (Registry) Host
# - QCLOUD_TKE_TCR_USER # QCloud TKE TCR (Registry) User
# - QCLOUD_TKE_TCR_PWD # QCloud TKE TCR (Registry) Password
#
# (Optional:)
# - DOCKER_IMAGE_NAME # It can be overrided if specified
#
# Triggering the Pipeline:
# The pipeline can be triggered by git tagging or running the pipeline manually with CI_COMMIT_TAG var.
#
# The pattern of Git tag or CI_COMMIT_TAG should be either:
# - Image for release purpose => release-{Docker Image Tag}
# - Docker Image Tag pattern: {semantic version style: x.y.z}[-{Optional text}]
# - Image for Non-release/Development purpose => dev-{Docker Image Tag}
# - Docker Image Tag pattern: any text follow docker tag requirements
#
stages:
- trigger
- docker-build-push-nexus
- push-qcloud
default:
interruptible: false
tags:
# Note: There may be a performance issue while using dind
# so that we switch to docker executor.
- docker-executor
artifacts:
reports:
dotenv: build.env
# after_script:
# # Only for debug propose, comment for production
# - env
variables:
NAMESPACE: price
DOCKER_IMAGE_NAME: "${NAMESPACE}/${CI_PROJECT_PATH_SLUG}"
DOCKERFILE_NAME: "Dockerfile"
.release_tag_rules:
rules:
# Force git-tag trigger only, run-pipeline not support (recommended)
- if: '$CI_COMMIT_TAG =~ /^release-\d+(\.\d+){0,2}\.\d+(-([a-zA-Z0-9\.\-\_]+)*[a-zA-Z0-9])?$/ && $CI_PIPELINE_SOURCE == "push"'
# Can use git-tag trigger and run-pipeline (for ci-development only)
# - if: '$CI_COMMIT_TAG =~ /^release-\d+(\.\d+){0,2}\.\d+(-([a-zA-Z0-9\.\-\_]+)*[a-zA-Z0-9])?$/'
when: on_success
.dev_tag_rules:
rules:
# Force git-tag trigger only, run-pipeline not support (recommended)
- if: '$CI_COMMIT_TAG =~ /^dev-[a-zA-Z0-9\.\-\_]+[a-zA-Z0-9]$/ && $CI_PIPELINE_SOURCE == "push"'
# Can use git-tag trigger and run-pipeline (for ci-development only)
# - if: '$CI_COMMIT_TAG =~ /^dev-[a-zA-Z0-9\.\-\_]+[a-zA-Z0-9]$/'
when: on_success
.tag_trigger_save_build_env:
script:
# Send to next stages
- |
echo "IS_RELEASE_IMAGE=${IS_RELEASE_IMAGE}" >> build.env
echo "DOCKERFILE_NAME=${DOCKERFILE_NAME}" >> build.env
echo "DOCKER_IMAGE_TAG=${DOCKER_IMAGE_TAG}" >> build.env
.check_get_docker_image_tag:
script:
- |
# Extract the docker image tag from commit tag
if [ -z $CI_COMMIT_TAG ]; then
echo "[!] ERROR, missing CI_COMMIT_TAG"
exit 1
else
DOCKER_IMAGE_TAG=$(echo "$CI_COMMIT_TAG" | sed -re 's/^[^-]+-(.*)$/\1/g')
fi
# ---------- JOB ---------
Tag Trigger for Release:
stage: trigger
rules:
- !reference [.release_tag_rules, rules]
script:
- IS_RELEASE_IMAGE=true
- !reference [.check_get_docker_image_tag, script]
- !reference [.tag_trigger_save_build_env, script]
# ---------- JOB ---------
Tag or Manual Trigger for Dev Build:
stage: trigger
rules:
- !reference [.dev_tag_rules, rules]
script:
- IS_RELEASE_IMAGE=false
- !reference [.check_get_docker_image_tag, script]
# Append 'dev-' to the docekr tag for the non-release image
- DOCKER_IMAGE_TAG="dev-${DOCKER_IMAGE_TAG}"
# Check if Dockerfile.dev exist, then use it for docker build
# TODO: consider to use filename suffix for other image tag like architecture suffix
- |
if [ -f "${DOCKERFILE_NAME}.dev" ]; then
DOCKERFILE_NAME=${DOCKERFILE_NAME}.dev
fi
- !reference [.tag_trigger_save_build_env, script]
# ---------- JOB ---------
Docker Build and Push to Nexus:
stage: docker-build-push-nexus
rules:
- !reference [.release_tag_rules, rules]
- !reference [.dev_tag_rules, rules]
# Not using 'needs' properly. The following settings cannot restrict only
# running from below jobs. It can be triggered if the rule passes anyway.
# Therefore, we still need to govern the trigger by rules.
# The use of 'needs' is for better flow declaration, for DAG.
needs:
- job: Tag or Manual Trigger for Dev Build
optional: true
- job: Tag Trigger for Release
optional: true
before_script:
# Login to Price Registry - Nexus
- echo "${PRICE_DOCKER_REPO_PWD}" | docker login -u "${PRICE_DOCKER_REPO_USER}" --password-stdin ${PRICE_DOCKER_REPO_HOST}
# pull latest image for cache-from, if any
- docker pull ${PRICE_DOCKER_REPO_HOST}/${DOCKER_IMAGE_NAME}:latest || true
script:
# ---------- PRE-BUILD ----------
# Modify the source code version string so that the VERSION_STRING will be added to the application
# - |
# sed -i -e "s/const BuildVersion string = .*$/const BuildVersion string = \"${VERSION_STRING}\"/g" cmd/priceuid2server/main.go
- DOCKER_IMAGE_NAME_WITH_TAG=${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}
# ---------- BUILD DOCKER IMAGE ----------
- |
echo "Using Dockerfile: ${DOCKERFILE_NAME}"
- >
docker build
-f "${DOCKERFILE_NAME}"
--pull
--tag ${PRICE_DOCKER_REPO_HOST}/${DOCKER_IMAGE_NAME_WITH_TAG}
--cache-from ${PRICE_DOCKER_REPO_HOST}/${DOCKER_IMAGE_NAME}:latest
--build-arg org_label_schema_name="${DOCKER_IMAGE_NAME}"
--build-arg org_label_schema_version="${DOCKER_IMAGE_TAG}"
--build-arg org_label_schema_description="${CI_PROJECT_TITLE}"
--build-arg org_label_schema_build_date="${CI_PIPELINE_CREATED_AT}"
--build-arg org_label_schema_vcs_ref="${CI_COMMIT_SHA}"
--build-arg org_label_schema_vcs_url="${CI_PROJECT_URL}"
--build-arg maintainer="${GITLAB_USER_LOGIN}<${GITLAB_USER_EMAIL}>"
--build-arg APP_VERSION="${DOCKER_IMAGE_TAG}"
.
# ---------- PUBLISH DOCKER IMAGE TO NEXUS REGISTRY ----------
# Push local image to Price's registry
- docker push ${PRICE_DOCKER_REPO_HOST}/${DOCKER_IMAGE_NAME_WITH_TAG}
# Tag latest image if it is an image for release
- |
if [ "$IS_RELEASE_IMAGE" = true ]; then
docker tag \
${PRICE_DOCKER_REPO_HOST}/${DOCKER_IMAGE_NAME_WITH_TAG} \
${PRICE_DOCKER_REPO_HOST}/${DOCKER_IMAGE_NAME}:latest
docker push ${PRICE_DOCKER_REPO_HOST}/${DOCKER_IMAGE_NAME}:latest
fi
# Artifacts to next stage
- !reference [.tag_trigger_save_build_env, script]
# ---------- JOB ---------
Push Image to QCloud:
stage: push-qcloud
rules:
- !reference [.release_tag_rules, rules]
- !reference [.dev_tag_rules, rules]
needs:
- Docker Build and Push to Nexus
before_script:
# Login to Price Registry - Nexus
- echo "${PRICE_DOCKER_REPO_PWD}" | docker login -u "${PRICE_DOCKER_REPO_USER}" --password-stdin ${PRICE_DOCKER_REPO_HOST}
# Login to QCLOUD TCR (Registry)
- echo "${QCLOUD_TKE_TCR_PWD}" | docker login -u "${QCLOUD_TKE_TCR_USER}" --password-stdin ${QCLOUD_TKE_TCR_HOST}
# Because we have no guarantee that this job will be picked up by the same runner
# that built the image in the previous step, we pull it again locally
- docker pull ${PRICE_DOCKER_REPO_HOST}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}
script:
# ---------- PRE-PUBLISH ----------
- DOCKER_IMAGE_NAME_WITH_TAG=${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}
# ---------- PUBLISH DOCKER IMAGE TO QCLOUD REGISTRY ----------
- |
docker tag \
${PRICE_DOCKER_REPO_HOST}/${DOCKER_IMAGE_NAME_WITH_TAG} \
${QCLOUD_TKE_TCR_HOST}/${DOCKER_IMAGE_NAME_WITH_TAG}
docker push ${QCLOUD_TKE_TCR_HOST}/${DOCKER_IMAGE_NAME_WITH_TAG}
# Tag latest image if it is an image for release
- |
if [ "$IS_RELEASE_IMAGE" = true ]; then
docker tag \
${QCLOUD_TKE_TCR_HOST}/${DOCKER_IMAGE_NAME_WITH_TAG} \
${QCLOUD_TKE_TCR_HOST}/${DOCKER_IMAGE_NAME}:latest
docker push ${QCLOUD_TKE_TCR_HOST}/${DOCKER_IMAGE_NAME}:latest
docker rmi ${QCLOUD_TKE_TCR_HOST}/${DOCKER_IMAGE_NAME}:latest
fi
# Clean up
- docker rmi ${QCLOUD_TKE_TCR_HOST}/${DOCKER_IMAGE_NAME_WITH_TAG}
# Artifacts to next stage
- !reference [.tag_trigger_save_build_env, script]

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

26
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Nest Framework",
"args": ["${workspaceFolder}/src/main.ts"],
"runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register",
"-r",
"tsconfig-paths/register"
],
"sourceMaps": true,
"envFile": "${workspaceFolder}/.env",
"cwd": "${workspaceRoot}",
"console": "integratedTerminal",
"protocol": "inspector"
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"typescript.format.enable": false,
"javascript.format.enable": false,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}

183
Dockerfile Normal file
View File

@ -0,0 +1,183 @@
# ------------------------------------------------------------------------------
# Dockerfile based on Price's best practice
# - using multi-stage builds
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Standard Args (with default values) for Price's docker image
# ------------------------------------------------------------------------------
ARG org_label_schema_vendor="Price.com.hk"
ARG org_label_schema_schema_version=1.0
ARG org_label_schema_name=unknown
ARG org_label_schema_version=unknown
ARG org_label_schema_vcs_url=unknown
ARG org_label_schema_vcs_ref=unknown
ARG org_label_schema_build_date=unknown
ARG org_label_schema_description
ARG maintainer="it.web@price.com.hk"
# -----------------------------------------------------------------------------
# Application Args/Envs
# -----------------------------------------------------------------------------
ARG NODEJS_VERSION="18.12"
ARG SUITE_VERSION="bullseye-slim"
ARG APP_ROOT="/home/node/app"
ARG APP_VERSION="not-yet-set"
ARG APP_PORT=3000
# ------------------------------------------------------------------------------
# Build the base image layer with Docker multi-stage builds
# - Install necessary packages as to add cache layer
# ------------------------------------------------------------------------------
FROM node:${NODEJS_VERSION}-${SUITE_VERSION} AS base
# -----------------------------------------------------------------------------
# Install required software package
# -----------------------------------------------------------------------------
RUN apt update -y \
# - health check & timezone
&& apt install -y curl tzdata dumb-init
# -----------------------------------------------------------------------------
# Purge / Clean up
# -----------------------------------------------------------------------------
RUN apt-get clean autoclean \
&& apt-get autoremove --yes \
&& rm -rf \
/var/lib/{apt,dpkg,cache,log}/ \
/tmp/*
# ------------------------------------------------------------------------------
# Build the NodeJS Application with Docker multi-stage builds
# - Making node_modules, separate from source as to add cache layer
# ------------------------------------------------------------------------------
FROM base AS nodejs_build
ARG APP_ROOT
RUN mkdir -p ${APP_ROOT} && chown node:node ${APP_ROOT}
WORKDIR ${APP_ROOT}
# Set the user for building operation
USER node
# ----- Install dependencies packages
# NOTE: Should not use `--ignore-optional --production` options
# because of TS or Bable is used.
COPY package.json yarn.lock ${APP_ROOT}/
RUN yarn install
# ---- Copy application source codes and build
COPY src ${APP_ROOT}/src
COPY tsconfig*.json .eslint*.js .prettierrc ${APP_ROOT}/
RUN yarn build
# NOTE: run install with `--ignore-optional --production` options again
# will remove non-production packages
RUN yarn install --ignore-optional --production
# ------------------------------------------------------------------------------
# Make the Final image
# ------------------------------------------------------------------------------
FROM base
# ------------------------------------------------------------------------------
# Arguments / Environment variables
# ------------------------------------------------------------------------------
# - Standard args and Envs
ARG org_label_schema_vendor
ARG org_label_schema_schema_version
ARG org_label_schema_name
ARG org_label_schema_version
ARG org_label_schema_vcs_url
ARG org_label_schema_vcs_ref
ARG org_label_schema_build_date
ARG org_label_schema_description
ARG maintainer
# - Application specific
ARG APP_ROOT
ENV APP_ROOT=${APP_ROOT}
ARG APP_PORT
ENV APP_PORT=${APP_PORT}
# -----------------------------------------------------------------------------
# Copy the 'image_files' directory to overrwrite root directory.
# Note: It is good to do after the main section
# -----------------------------------------------------------------------------
COPY ./image_files/fsroot /
# -----------------------------------------------------------------------------
# Provisioning (as root)
# For docker-entrypoint, it needed to change the owner to node
# -----------------------------------------------------------------------------
RUN chmod g+w -R /docker-entrypoint.d /docker-entrypoint.sh \
&& chown -R node:node /docker-entrypoint.d /docker-entrypoint.sh \
&& chmod ug+x /docker-entrypoint.sh
# ------------------------------------------------------------------------------
# Setup APP_ROOT for Node.js
# ------------------------------------------------------------------------------
RUN mkdir -p ${APP_ROOT} && chown node:node ${APP_ROOT}
WORKDIR ${APP_ROOT}
USER node
# ------------------------------------------------------------------------------
# Copy necessary files
# ------------------------------------------------------------------------------
COPY --chown=node:node --from=nodejs_build ${APP_ROOT}/dist ${APP_ROOT}/dist
COPY --chown=node:node --from=nodejs_build ${APP_ROOT}/node_modules ${APP_ROOT}/node_modules
COPY --chown=node:node --from=nodejs_build ${APP_ROOT}/package.json ${APP_ROOT}/yarn.lock ${APP_ROOT}/
# ------------------------------------------------------------------------------
# TODO: Remove TS or any other source code if the image is 'release'
# ------------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Expose ports
# -----------------------------------------------------------------------------
EXPOSE ${APP_PORT}
# -----------------------------------------------------------------------------
# Healthcheck (Optional for service)
# -----------------------------------------------------------------------------
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${APP_PORT}/healthcheck || exit 1
# -----------------------------------------------------------------------------
# Setting the Label and Args
# Shoukd be placed at the end (just before CMD) to avoid
# regenerating the cache layer above
# -----------------------------------------------------------------------------
LABEL org.label-schema.vendor=$org_label_schema_vendor \
org.label-schema.schema-version=$org_label_schema_schema_version \
org.label-schema.name=$org_label_schema_name \
org.label-schema.version=$org_label_schema_version \
org.label-schema.vcs-url=$org_label_schema_vcs_url \
org.label-schema.vcs-ref=$org_label_schema_vcs_ref \
org.label-schema.build-date=$org_label_schema_build_date \
org.label-schema.description=$org_label_schema_description \
maintainer=$maintainer
ENV ENABLE_DUMMY_DAEMON=false
ENV APP_ENV="production"
# Set the LOG_LEVEL will override application logger level.
# Default "" mean unset
# See 30-prepare-env.sh for details
ENV LOG_LEVEL=""
ENV LOG_IN_JSON_FMT=false
ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION}
# -----------------------------------------------------------------------------
# Command or Entrypoint
# -----------------------------------------------------------------------------
CMD [ "dumb-init", "sh", "-c", "/docker-entrypoint.sh" ]

174
Dockerfile.dev Normal file
View File

@ -0,0 +1,174 @@
# ------------------------------------------------------------------------------
# Dockerfile based on Price's best practice
# - using multi-stage builds
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Standard Args (with default values) for Price's docker image
# ------------------------------------------------------------------------------
ARG org_label_schema_vendor="Price.com.hk"
ARG org_label_schema_schema_version=1.0
ARG org_label_schema_name=unknown
ARG org_label_schema_version=unknown
ARG org_label_schema_vcs_url=unknown
ARG org_label_schema_vcs_ref=unknown
ARG org_label_schema_build_date=unknown
ARG org_label_schema_description
ARG maintainer="it.web@price.com.hk"
# -----------------------------------------------------------------------------
# Application Args/Envs
# -----------------------------------------------------------------------------
ARG NODEJS_VERSION="18.12"
ARG SUITE_VERSION="bullseye-slim"
ARG APP_ROOT="/home/node/app"
ARG APP_VERSION="not-yet-set"
ARG APP_PORT=3000
# ------------------------------------------------------------------------------
# Build the NodeJS Application with Docker multi-stage builds
# - Making node_modules, separate from source as to add cache layer
# ------------------------------------------------------------------------------
FROM node:${NODEJS_VERSION}-${SUITE_VERSION} AS nodejs_build
ARG APP_ROOT
RUN mkdir -p ${APP_ROOT} && chown node:node ${APP_ROOT}
WORKDIR ${APP_ROOT}
# Set the user for building operation
USER node
# ----- Install dependencies packages
# NOTE: Should not use `--ignore-optional --production` options
# because of TS or Bable is used.
COPY package.json yarn.lock ${APP_ROOT}/
RUN yarn install
# ---- Copy application source codes and build
COPY src ${APP_ROOT}/src
COPY tsconfig*.json .eslint*.js .prettierrc ${APP_ROOT}/
RUN yarn build
# ------------------------------------------------------------------------------
# Make the Final image
# ------------------------------------------------------------------------------
FROM node:${NODEJS_VERSION}-${SUITE_VERSION}
# ------------------------------------------------------------------------------
# Arguments / Environment variables
# ------------------------------------------------------------------------------
# - Standard args and Envs
ARG org_label_schema_vendor
ARG org_label_schema_schema_version
ARG org_label_schema_name
ARG org_label_schema_version
ARG org_label_schema_vcs_url
ARG org_label_schema_vcs_ref
ARG org_label_schema_build_date
ARG org_label_schema_description
ARG maintainer
# - Application specific
ARG APP_ROOT
ENV APP_ROOT=${APP_ROOT}
ARG APP_PORT
ENV APP_PORT=${APP_PORT}
# -----------------------------------------------------------------------------
# Install required software package
# -----------------------------------------------------------------------------
RUN apt update -y \
# - health check & timezone
&& apt install -y curl tzdata dumb-init git jq
# -----------------------------------------------------------------------------
# Purge / Clean up
# -----------------------------------------------------------------------------
RUN apt-get clean autoclean \
&& apt-get autoremove --yes \
&& rm -rf \
/var/lib/{apt,dpkg,cache,log}/ \
/tmp/*
# -----------------------------------------------------------------------------
# Copy the 'image_files' directory to overrwrite root directory.
# Note: It is good to do after the main section
# -----------------------------------------------------------------------------
COPY ./image_files/fsroot /
# -----------------------------------------------------------------------------
# Provisioning (as root)
# For docker-entrypoint, it needed to change the owner to node
# -----------------------------------------------------------------------------
RUN chmod g+w -R /docker-entrypoint.d /docker-entrypoint.sh \
&& chown -R node:node /docker-entrypoint.d /docker-entrypoint.sh \
&& chmod ug+x /docker-entrypoint.sh
# ------------------------------------------------------------------------------
# Setup APP_ROOT for Node.js
# ------------------------------------------------------------------------------
RUN mkdir -p ${APP_ROOT} && chown node:node ${APP_ROOT}
WORKDIR ${APP_ROOT}
USER node
# ------------------------------------------------------------------------------
# Copy necessary files, including source file (See also .dockerignore)
# ------------------------------------------------------------------------------
COPY --chown=node:node --from=nodejs_build ${APP_ROOT}/dist ${APP_ROOT}/dist
COPY --chown=node:node --from=nodejs_build ${APP_ROOT}/node_modules ${APP_ROOT}/node_modules
COPY --chown=node:node --from=nodejs_build ${APP_ROOT}/src ${APP_ROOT}/src
COPY --chown=node:node *.* .*.* ${APP_ROOT}/
# ------------------------------------------------------------------------------
# TODO: Remove TS or any other source code if the image is 'release'
# ------------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Expose ports
# -----------------------------------------------------------------------------
EXPOSE ${APP_PORT}
# -----------------------------------------------------------------------------
# Healthcheck (Optional for service)
# -----------------------------------------------------------------------------
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${APP_PORT}/healthcheck || exit 1
# -----------------------------------------------------------------------------
# Setting the Label and Args
# Shoukd be placed at the end (just before CMD) to avoid
# regenerating the cache layer above
# -----------------------------------------------------------------------------
LABEL org.label-schema.vendor=$org_label_schema_vendor \
org.label-schema.schema-version=$org_label_schema_schema_version \
org.label-schema.name=$org_label_schema_name \
org.label-schema.version=$org_label_schema_version \
org.label-schema.vcs-url=$org_label_schema_vcs_url \
org.label-schema.vcs-ref=$org_label_schema_vcs_ref \
org.label-schema.build-date=$org_label_schema_build_date \
org.label-schema.description=$org_label_schema_description \
maintainer=$maintainer
ENV ENABLE_DUMMY_DAEMON=false
ENV APP_ENV="development"
# Set the LOG_LEVEL will override application logger level.
# Default "" mean unset
# See 30-prepare-env.sh for details
ENV LOG_LEVEL=""
ENV LOG_IN_JSON_FMT=false
ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION}
# -----------------------------------------------------------------------------
# Command or Entrypoint
# -----------------------------------------------------------------------------
CMD [ "dumb-init", "sh", "-c", "/docker-entrypoint.sh" ]

128
README.md
View File

@ -1,3 +1,127 @@
# boilerplate-nestjs-api-crud
# Basic Nestjs Application/API Boilerplate project with Git CI
Boilerplate/Sample project for NestJS CRUD API in Node.js and Typescript
This project is based on the [Basic Docker Project with Git CI](https://git.price-hk.com/boilerplate-samples-projects/basic-docker-ci) project and include:
1. Node.js + Nestjs framework + Typescript + Swagger + JSON Logging (Shellscript + Winston)
1. This project is based on the new project generated by `nest new <project>` command (as of 2023-03-24)
1. Two dockerfile for production and development (which will be supported by `.gitlab-ci.yml`)
1. Dockerfile using multiple-stage builds
1. docker-compose setup for local development
1. Minimal API implementation (version, healthcheck)
## Local development using docker-compose and attach with VSCode
### Preparation
*Assume you are using Linux (Mac or WSL2) and your login user id is 1000*
1. Clone the project
1. Prepare the `.env` (for docker-compose) and `.env-app` (for Node.js app) files
1. Refer to `sample.env-*` for the details
1. Create data folder (for vscode-server which will be running inside the container for VSCode)
`mkdir -p ./data/vscode-server`
1. The VSCode extension and some required data will be saved in this persistence folder.
1. If you have started the service before making the directory (under your user account), you may get error when you attach VSCode. To resolve it, change the `data` and `vscode-server` folder ownership to you.
1. Build the image
`docker-compose build`
### Start the environment as a dummy-daemon service
*Assume you have installed docker extension in VSCode*
`docker-compose up -d`
### Attach container with VSCode
1. Start the service (if not yet)
1. Start VSCode and locate Docker in the left tab panel, click the docker icon
1. Locate the running container of your application (e.g. `basic-nestjs-app`)
1. Right-click the container instance, select `Attach Visual Studio Code`
1. From the dropdown menu, select the instance again
1. A new VSCode Window will be shown if success
1. If it is the first time, please pick the working folder to `/home/node/app`
### Run the server application
1. In the VSCode terminal (within the container), type `yarn start` or `yarn start:dev`
1. To use the VSCode debugger, click the debug and launch the `Debug Nest Framework`
(See `launch.json`) for details
## Swagger
The Nest Swagger framework is added into this project. For details, please see [Nestjs OpenAPI](https://docs.nestjs.com/openapi/introduction) documentation.
When the is started and either `NODE_ENV` is not set to `production` OR `ENABLE_SWAGGER` is `true`, the swagger will be enabled. It can be access via URL: `/api`. For example: [http://localhost:3000/api](http://localhost:3000/api)
## About the Winston Logger and Price Service Core
To use the Node.js Logger with Winston and output as a json format (required by Price's K8s best practice), please consider to use [Price Service Core](https://git.price-hk.com/price-core/service-core)'s Utility Service for logger.
The Price Service Core has benn installed in this project. This is the command used:
`yarn add --registry https://repo-manager.price-hk.com/repository/npm-group/ @price/service-core@0.4.0`
And a wrapper class `WinstonLoggerService` (file `src/logger/winston.service.ts`) is created as well. Logger configration has also implemented in this project too.
To use this, in your TS source code:
1. import the class. e.g.: `import { WinstonLoggerService } from './logger/winston.service';`
2. Set the context in constructor:
```
constructor(private logger: WinstonLoggerService) {
this.logger.setContext(this.constructor.name);
}
```
3. Log as needed: `this.logger.debug("the message to log");`
---
## Working with the sample user-API (CURD)
API-Doc: `/api`
- Example: [http://localhost:3000/api](http://localhost:3000/api)
Note:
1. The following use Linux cURL command to operate the API.
1. Header is provided. Especially for `POST` and `PATCH`
### Create User
```shell
curl -X POST -H 'Content-Type: application/json' -d '{ "name": "Tester 1" }' 'http://localhost:3000/users'
```
### List Users
List users (with header outpu):
```shell
curl -i 'http://localhost:3000/users'
```
Formatted result with JQ:
```shell
curl -s 'http://localhost:3000/users' | jq
```
### Get specific user
```shell
curl -s 'http://localhost:3000/users/0' | jq
```
### Update specific user
```shell
curl -X PATCH -H 'Content-Type: application/json' -d '{ "name": "Tester 2" }' 'http://localhost:3000/users/1'
```
Note: Header must be provided.
### Delete specific user
```shell
curl -X DELETE 'http://localhost:3000/users/1'
```

6
data/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
*
!.gitignore
!.gitkeep
!README
!README.md
!mysql/

0
data/README.md Normal file
View File

5
data/mysql/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
*
!.gitignore
!.gitkeep
!README
!README.md

0
data/mysql/.gitkeep Normal file
View File

48
docker-compose.db.yml Normal file
View File

@ -0,0 +1,48 @@
version: '3.7'
services:
adminer:
image: adminer
restart: always
links:
- "mysql"
depends_on:
- "mysql"
ports:
- 33306:8080
# network_mode: bridge
mysql:
image: mariadb:10.11
restart: always
environment:
MARIADB_ROOT_PASSWORD: example
ports:
- "3306:3306"
volumes:
- ./data/mysql:/var/lib/mysql
# Without this, will get error when start up in WSL2
# network_mode: bridge
mongo:
image: mongo:6
restart: always
ports:
- 27017:27017
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
# network_mode: bridge
mongo-express:
image: mongo-express
restart: always
links:
- "mongo"
depends_on:
- "mongo"
ports:
- 37017:8081
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: root
ME_CONFIG_MONGODB_ADMINPASSWORD: example
ME_CONFIG_MONGODB_URL: mongodb://root:example@mongo:27017/
# network_mode: bridge

64
docker-compose.yml Normal file
View File

@ -0,0 +1,64 @@
version: '3.7'
services:
basic-nestjs-app:
image: "${DOCKER_IMAGE_NAME}"
container_name: "basic-nestjs-app"
privileged: false
# NOTE: Docker image user is 'node:node (1000:1000)'
user: "1000:1000"
build:
context: .
dockerfile: ./Dockerfile.dev
args:
org_label_schema_name: "${DOCKER_IMAGE_NAME}"
environment:
ENABLE_DUMMY_DAEMON: "${ENABLE_DUMMY_DAEMON}"
ports:
- "${PUB_APP_PORT}:3000"
# - "${PUB_HTTP_PORT}:80"
# - "${PUB_HTTPS_PORT}:443"
volumes:
# For application development:
- "./.env-app:/home/node/app/.env"
- "./src:/home/node/app/src"
- "./.vscode:/home/node/app/.vscode"
- "./.git:/home/node/app/.git"
- "./.eslintrc.js:/home/node/app/.eslintrc.js"
- "./.gitignore:/home/node/app/.gitignore"
- "./.prettierrc:/home/node/app/.prettierrc"
- "./nest-cli.json:/home/node/app/nest-cli.json"
- "./package.json:/home/node/app/package.json"
- "./tsconfig.build.json:/home/node/app/tsconfig.build.json"
- "./tsconfig.json:/home/node/app/tsconfig.json"
- "./yarn.lock:/home/node/app/yarn.lock"
# In order syncing in git, mount need files as read-only
- "./.gitlab-ci.yml:/home/node/app/.gitlab-ci.yml:ro"
- "./Dockerfile:/home/node/app/Dockerfile:ro"
- "./Dockerfile.dev:/home/node/app/Dockerfile.dev:ro"
- "./docker-compose.yml:/home/node/app/docker-compose.yml:ro"
- "./image_files:/home/node/app/image_files:ro"
# --- For Attach VSCode in container
# - "basic-nestjs-app-data-vscode-server:/home/node/.vscode-server"
- "./data/vscode-server:/home/node/.vscode-server"
# Disable Healthcheck
healthcheck:
test: ["NONE"]
# Without this, will get error when start up in WSL2
network_mode: bridge
links:
- "db"
db:
image: mariadb:10.11
restart: always
environment:
MARIADB_ROOT_PASSWORD: example
# Note: Don't use named volume since the container is running as non-root
# volumes:
# basic-nestjs-app-data-vscode-server:

View File

@ -0,0 +1,33 @@
# #############################################################################
# Docker Entrypoint scipt - Prepare Application Enviroment
# #############################################################################
export PORT=${APP_PORT:-3000}
# ----- Setup the Environment
if [ "${ENABLE_DUMMY_DAEMON}" = "true" ]; then
# Assume it is a development environment if ENABLE_DUMMY_DAEMON=true
export APP_ENV="development"
export NODE_ENV="development"
export LOG_LEVEL="debug"
else
# Starting as normal, so it should not be a development environment
# It should be a service for Production, UAT, or even demo
export APP_ENV=${APP_ENV:-production}
# Because of the Price Logging framework does not support setting
# the JSON format explicitly, we have to set NODE_ENV="production"
# in order to log in JSON format. Even it should be set to "development"
export NODE_ENV="production"
if [ \( -z "${LOG_LEVEL}" \) -o \( "${LOG_LEVEL}" = "" \) ]; then
if [ "${APP_ENV}" = "production" ]; then
export LOG_LEVEL="info"
else
export LOG_LEVEL="debug"
fi
fi
fi

View File

@ -0,0 +1,16 @@
# #############################################################################
# Docker Entrypoint scipt - Prepare the shell enviroment
# #############################################################################
# For Bash
cat "/docker-entrypoint.d/files/dot-bashrc.append" >> /home/node/.bashrc
if [ "${ENABLE_DUMMY_DAEMON}" = "true" ]; then
# Assume it is a development environment if ENABLE_DUMMY_DAEMON=true
# Append the following lines for shell operations
echo "export APP_ENV=${APP_ENV}" >> /home/node/.bashrc
echo "export NODE_ENV=${NODE_ENV}" >> /home/node/.bashrc
echo "export LOG_LEVEL=${LOG_LEVEL}" >> /home/node/.bashrc
echo "export APP_PORT=${APP_PORT}" >> /home/node/.bashrc
echo "unset LOG_IN_JSON_FMT" >> /home/node/.bashrc
fi

View File

@ -0,0 +1,17 @@
# Start application
# By-pass starting the node.js if ENABLE_DUMMY_DAEMON=true
if [ ! "${ENABLE_DUMMY_DAEMON}" = "true" ]; then
# Change to APP Root Folder to run app
cd ${APP_ROOT}
# Start NodeJS App - 'start:prod'
# Note: Don't run 'start:dev' for development environment
# instead, set environment variable: APP_ENV=development
# Optionally, you can override the LOG_LEVEL as needed.
# TODO: Handle env properly
# dumb-init yarn start:prod
dumb-init yarn -s start:prod
fi

View File

@ -0,0 +1,6 @@
# customisation for bash
# aliases
alias ll='ls -l'
alias la='ls -A'
alias l='ls -CF'

View File

@ -0,0 +1,36 @@
#!/bin/sh
# This script use sh. Since the sh may be different function in distributions.
# In order to make the maximum compatibility, please use classic technique.
# Example, if-condition, 'source' command.
# Exit immediately if a command exits with a non-zero status.
set -e
# (same as set -o errexit)
# The condition use classic technique
if [ \( "${LOG_IN_JSON_FMT}" = "false" \) -o \( "${LOG_IN_JSON_FMT}" = "0" \) ]; then
unset LOG_IN_JSON_FMT
# Otherwise, other value will enable Log in JSON
fi
. /usr/local/bin/script_utils.sh
# Run (startup) scripts for the entrypoint if any:
if [ $# -eq 0 ]; then
if [ -d '/docker-entrypoint.d' ]; then
for f in /docker-entrypoint.d/*.sh; do
. "$f" "$@"
done
fi
# If daemon script does not work as daemon,
# Use the follow command to keep the container running as daemon
if [ "${ENABLE_DUMMY_DAEMON}" = "true" ]; then
logging_warning "[!] Running as dummy daemon"
tail -f /dev/null
else
# Run the shell
/bin/sh
fi
else
exec "$@"
fi

View File

@ -0,0 +1,49 @@
#!/bin/sh
# Utility functions for shell script
# Very simple 'logger' for shell script support JSON output
# To turn on JSON output, set env LOG_IN_JSON_FMT=1
# ---------------------
logging_info () {
msg="${1}"
logging_msg "INFO" "\${msg}"
}
logging_warning () {
msg="${1}"
logging_msg "WARNING" "\${msg}"
}
logging_error () {
msg="${1}"
logging_msg "ERROR" "\${msg}"
}
logging_msg () {
eval level="${1}"
eval msg="${2}"
#TODO, handle 4 env or detect if it is k8s
if [ ! -z "$LOG_IN_JSON_FMT" ]; then
echo_json_log "INFO" "\${msg}"
else
# echo "$ENV - $msg"
echo "$msg"
fi
}
echo_json_log () {
eval level="$1"
eval msg="${2}"
# Handle double quote
msg="${msg//[\"]/\\\"}"
# Handle mutliline
msg=$(echo "$msg" | sed -e '1h;2,$H;$!d;g' -re 's/([^\n]*)\n([^\n]*)/\1\\n\2/g' )
#Replace %N with %3N for milliseconds, %6N for micro-seconds...
# now=`date '+%FT%T.%3N%:z'`
now=`date '+%FT%T%z'`
echo "{\"time\": \"${now}\", \"level\": \"${level}\", \"message\": \"${msg}\"}"
}

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

15749
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

78
package.json Normal file
View File

@ -0,0 +1,78 @@
{
"name": "basic-nestjs-app",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.1",
"@nestjs/core": "^9.0.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/swagger": "^6.2.1",
"@nestjs/typeorm": "^9.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"mysql2": "^3.2.1",
"nest-winston": "^1.9.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0",
"typeorm": "^0.3.15",
"winston": "^3.8.2"
},
"devDependencies": {
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/express": "^4.17.13",
"@types/jest": "29.2.4",
"@types/node": "^18.15.11",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"jest": "29.3.1",
"prettier": "^2.3.2",
"source-map-support": "^0.5.20",
"supertest": "^6.1.3",
"ts-jest": "29.0.3",
"ts-loader": "^9.2.3",
"ts-node": "^10.0.0",
"tsconfig-paths": "4.1.1",
"typescript": "^4.7.4"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

8
sample.env-app Normal file
View File

@ -0,0 +1,8 @@
# This is the sample of .env for Node.js App (Federation API)
# Copy this file to .env and change the following content as needed
# For local docker-compose dev env, use .env-app instead of .env
LOG_LEVEL=debug
APP_PORT=3000
SWAGGER_SERVER_PATH=/
ENABLE_SWAGGER=true

10
sample.env-docker-compose Normal file
View File

@ -0,0 +1,10 @@
# This is the sample of .env-docker-compose
# Copy this file to .env and change the following content as needed.
# Optionally, you can copy file to .env-docker-compose
# and create symlink, e.g. ln -s .env-docker-compose .env
ENABLE_DUMMY_DAEMON=true
DOCKER_IMAGE_NAME=price/basic-nestjs-app:dev-local
PUB_APP_PORT=3000
PUB_HTTP_PORT=3080
PUB_HTTPS_PORT=3443

View File

@ -0,0 +1,28 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('healthcheck', () => {
it('should return "OK"', () => {
expect(appController.healthcheck()).toBe('OK');
});
});
describe('version', () => {
it('should return "OK"', () => {
expect(appController.healthcheck()).toBe(process.env.APP_VERSION);
});
});
});

17
src/app.controller.ts Normal file
View File

@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('/healthcheck')
healthcheck(): any {
return this.appService.healthcheck();
}
@Get('/version')
version(): any {
return this.appService.version();
}
}

57
src/app.module.ts Normal file
View File

@ -0,0 +1,57 @@
import {
Logger,
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AllExceptionsFilter } from './logger/any-exception.filter';
import loggerMiddleware from './logger/logger.middleware';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StaffsModule } from './staffs/staffs.module';
@Module({
imports: [
// For Configuration
ConfigModule.forRoot(),
// For ORM-MySQL
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: 'example',
database: 'example_nodejs_nest_crud',
// entities: ['dist/modules/**/*.mysql.entity{.ts,.js}'],
autoLoadEntities: true,
// IMPORTANT: disable sync
// synchronize: false,
synchronize: true,
}),
UsersModule,
StaffsModule,
],
controllers: [AppController],
providers: [
Logger,
AppService,
{
provide: APP_FILTER,
// useClass: HttpExceptionFilter,
useClass: AllExceptionsFilter,
},
],
})
export class AppModule implements NestModule {
// Apply logger middleware for all routes
configure(consumer: MiddlewareConsumer) {
consumer
.apply(loggerMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

19
src/app.service.ts Normal file
View File

@ -0,0 +1,19 @@
import { Logger, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AppService {
constructor(
private readonly logger: Logger,
private configService: ConfigService,
) {}
healthcheck() {
return 'OK';
}
version() {
this.logger.debug(
this.configService.get<string>('APP_VERSION') || 'Not APP_VERSION',
);
return process.env.APP_VERSION || '0.0.0';
}
}

View File

@ -0,0 +1,77 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { error } from 'console';
// Catch any exception
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const { body, url, method, originalUrl, rawHeaders } = request;
let message = exception as string;
let stack = undefined;
if (exception instanceof Error) {
message = exception.message;
// trim the stack lines
if (exception.stack) {
stack = exception.stack.split('\n').slice(1, 3).join('\n');
}
}
// For HTTP Exception, don't log the stack
// if (exception instanceof HttpException) {
// stack = null;
// }
let logMsg: any;
if (process.env.NODE_ENV === 'production') {
logMsg = {
message,
request: {
method,
url,
body,
// originalUrl,
rawHeaders,
},
};
this.logger.error(logMsg, stack, AllExceptionsFilter.name);
} else {
const requstBody =
Object.keys(body).length > 0 ? ` Body=${JSON.stringify(body)}` : '';
const rawHeadersStr =
Object.keys(rawHeaders).length > 0
? ` RawHeaders=${JSON.stringify(rawHeaders)}`
: '';
logMsg = `${message}\n${method.toLocaleUpperCase()} ${url}${requstBody}${rawHeadersStr}`;
this.logger.error(logMsg, stack, AllExceptionsFilter.name);
}
response.header('Content-Type', 'application/json; charset=utf-8');
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
error: message,
});
}
}

88
src/logger/index.ts Normal file
View File

@ -0,0 +1,88 @@
/**
* index.ts: logging utilities
*
* See:
* - https://github.com/gremo/nest-winston
* - https://github.com/winstonjs/winston
*
* (C) 2023
* Price.com.hk
*/
import * as winston from 'winston';
import { utilities as nestWinstonModuleUtilities } from 'nest-winston';
const { combine, timestamp, printf, colorize } = winston.format;
// Wrapper of createLogger, with the common options used for Price's usvc
export function createLogger(options?: winston.LoggerOptions): winston.Logger {
const logLevel = process.env.LOG_LEVEL
? process.env.LOG_LEVEL
: process.env.NODE_ENV === 'production'
? 'info'
: 'debug';
if (process.env.NODE_ENV === 'production') {
return winston.createLogger({
...options,
transports: [new winston.transports.Console()],
format: combine(
nestWinstonModuleUtilities.format.nestLike(),
winston.format.json(),
winston.format((info) => {
// Remove reduntant 'value' while object is logged instead of msg string
if (info.value) {
delete info.value;
}
// Remove timestamp (for changing timestamp to 'time')
if (info.timestamp) {
delete info.timestamp;
return info;
}
return info;
})(),
timestamp({
// format: 'YYYY-MM-DDTHH:mm:ss.SSSZZ',
format: 'YYYY-MM-DDTHH:mm:ssZZ',
alias: 'time',
}),
),
level: logLevel,
});
} else {
return winston.createLogger({
...options,
transports: [new winston.transports.Console()],
format: combine(
nestWinstonModuleUtilities.format.nestLike(),
// timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
timestamp({ format: 'HH:mm:ss' }),
colorize(),
// Format for debug-console, not in JSON meta param is ensured by splat()
printf(
({
context,
namespace,
module,
timestamp,
level,
message,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
value, // No print
...meta
}) => {
const infoContext = context ? `[${context}] ` : '';
const infoString =
module || namespace ? `[${module || namespace}] ` : '';
return `${timestamp} ${level.padEnd(
15,
)} ${infoString}${infoContext}${message}${
Object.keys(meta).length > 0 ? ' - ' + JSON.stringify(meta) : ''
}`;
},
),
),
level: logLevel,
});
}
}

View File

@ -0,0 +1,71 @@
import {
Inject,
Injectable,
NestMiddleware,
Logger,
LoggerService,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export default class LoggerMiddleware implements NestMiddleware {
// TODO: Access log if env not production
constructor(@Inject(Logger) private readonly logger: LoggerService) {}
use(req: Request, res: Response, next: NextFunction) {
if (process.env.NODE_ENV === 'production') {
const logObj = {
message: 'web-request',
request: this.requestInJson(req),
};
if (process.env.LOG_LEVEL_INFO_FOR_WEB_REQUEST === 'true') {
this.logger.log(logObj, LoggerMiddleware.name);
} else {
this.logger.debug(logObj, LoggerMiddleware.name);
}
} else {
const logMsg = this.requestInLine(req);
if (process.env.LOG_LEVEL_INFO_FOR_WEB_REQUEST === 'true') {
this.logger.log(logMsg, LoggerMiddleware.name);
} else {
this.logger.debug(logMsg, LoggerMiddleware.name);
}
}
next();
}
requestInLine(req: Request): string {
const { headers, url, method, body, socket } = req;
const xRealIp = headers['X-Real-IP'];
const xForwardedFor = headers['X-Forwarded-For'];
const { ip: cIp } = req;
const { remoteAddress } = socket || {};
const ip = xRealIp || xForwardedFor || cIp || remoteAddress;
const requstBody =
Object.keys(body).length > 0 ? ` Body=${JSON.stringify(body)}` : '';
const requstIp = ip ? ` SrcIP=${ip}` : '';
return `${method.toLocaleUpperCase()} ${url}${requstBody}${requstIp}`;
}
requestInJson: (req: Request) => {
[prop: string]: any;
} = (req) => {
const { headers, url, method, body, socket } = req;
const xRealIp = headers['X-Real-IP'];
const xForwardedFor = headers['X-Forwarded-For'];
const { ip: cIp } = req;
const { remoteAddress } = socket || {};
const ip = xRealIp || xForwardedFor || cIp || remoteAddress;
return {
url,
host: headers.host,
ip,
method,
body,
};
};
}

44
src/main.ts Normal file
View File

@ -0,0 +1,44 @@
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { WinstonModule } from 'nest-winston';
import { createLogger } from './logger';
import { ValidationPipe } from '@nestjs/common';
const DEFAULT_APP_PORT = 3000;
async function bootstrap() {
const instance = createLogger({});
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger({ instance }),
});
// Use the shutdown hooks
// app.enableShutdownHooks();
// Use Validation Pipe
app.useGlobalPipes(new ValidationPipe());
// Setup the Swagger if ENABLE_SWAGGER is True OR NODE_ENV is NOT 'production'
if (
process.env.NODE_ENV !== 'production' ||
process.env.ENABLE_SWAGGER === 'true'
) {
const swaggerDoc = SwaggerModule.createDocument(
app,
new DocumentBuilder()
.setTitle('Basic Nestjs API Service')
.setDescription('Boilerplate project of the Basic Nestjs API Service')
.setVersion('1.0')
.addServer(process.env.SWAGGER_SERVER_PATH)
.build(),
);
SwaggerModule.setup('api', app, swaggerDoc);
}
// Start the web app
await app.listen(process.env.APP_PORT || DEFAULT_APP_PORT);
}
bootstrap();

10
src/staffs/README.md Normal file
View File

@ -0,0 +1,10 @@
# Staffs Module
- This module demonstrates using:
- CRUD
- TypeORM: MySQL
- TypeORM Repository
- No QueryBuilder
- TypeOrmModule.forFeature & TypeOrmModule.forRoot{ autoLoadEntities: true }
- The Entities
- DB and Web-API entities are shared

View File

@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsNumber, IsOptional } from 'class-validator';
export class StaffCreateDto {
// NOTE: Since the id is auto-inc, so no id for the creation
// id: number;
@ApiProperty({
type: String,
name: 'name',
description: 'Staff name',
required: true,
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
type: Number,
name: 'staffCode',
description: 'Staff code/ID',
required: false,
})
@IsNumber()
@IsOptional()
staffCode: number;
@ApiProperty({
type: Number,
name: 'departmentId',
description: 'Department ID',
required: false,
})
@IsNumber()
@IsOptional()
departmentId: number;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { StaffCreateDto } from './staff-create.dto';
export class StaffUpdateDto extends PartialType(StaffCreateDto) {}

View File

@ -0,0 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
// @Entity('staffs')
@Entity()
export class Staff {
@ApiProperty({
type: 'number',
name: 'id',
description: 'id of staff, auto-incremented',
required: true,
})
@PrimaryGeneratedColumn('increment')
id: number;
@ApiProperty({
type: String,
name: 'name',
description: 'Staff name',
required: true,
})
@Column({
type: 'varchar',
})
name: string;
@ApiProperty({
type: Number,
name: 'staffCode',
description: 'Staff code/ID',
required: false,
})
@Column({
name: 'staff_code',
type: 'varchar',
length: 10,
nullable: true,
})
staffCode: number;
@ApiProperty({
type: Number,
name: 'departmentId',
description: 'Department ID',
required: false,
})
@Column({
// Match existing column naming convention
name: 'department_id',
type: 'tinyint',
unsigned: true,
nullable: true,
})
departmentId: number;
// TODO: JOIN TABLE CASE
}

View File

@ -0,0 +1,76 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
NotFoundException,
HttpCode,
BadRequestException,
} from '@nestjs/common';
import { ApiResponse } from '@nestjs/swagger';
import { StaffsService } from './staffs.service';
import { Staff } from './entities/staff.entity';
import { StaffCreateDto } from './dto/staff-create.dto';
import { StaffUpdateDto } from './dto/staff-update.dto';
import { EntityNotFoundError } from 'typeorm';
@Controller('staffs')
export class StaffsController {
constructor(private readonly staffsService: StaffsService) {}
private handleEntityNotFoundError(id: number, e: Error) {
if (e instanceof EntityNotFoundError) {
throw new NotFoundException(`Not found: id=${id}`);
} else {
throw e;
}
}
@Get()
@ApiResponse({ type: [Staff] })
findAll(): Promise<Staff[]> {
return this.staffsService.findAll();
}
@Get(':id')
@ApiResponse({ type: Staff })
async findOne(@Param('id') id: number): Promise<Staff | null> {
// NOTE: the + operator returns the numeric representation of the object.
return this.staffsService.findOne(+id).catch((err) => {
this.handleEntityNotFoundError(id, err);
return null;
});
}
@Post()
@ApiResponse({ type: [Staff] })
create(@Body() staffCreateDto: StaffCreateDto) {
return this.staffsService.create(staffCreateDto);
}
@Patch(':id')
// Status 204 because no content will be returned
@HttpCode(204)
async update(
@Param('id') id: number,
@Body() staffUpdateDto: StaffUpdateDto,
): Promise<void> {
if (Object.keys(staffUpdateDto).length === 0) {
throw new BadRequestException('Request body is empty');
}
await this.staffsService.update(+id, staffUpdateDto).catch((err) => {
this.handleEntityNotFoundError(id, err);
});
}
@Delete(':id')
@HttpCode(204)
async remove(@Param('id') id: number): Promise<void> {
await this.staffsService.remove(+id).catch((err) => {
this.handleEntityNotFoundError(id, err);
});
}
}

View File

@ -0,0 +1,15 @@
import { Logger, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StaffsService } from './staffs.service';
import { StaffsController } from './staffs.controller';
import { Staff } from './entities/staff.entity';
@Module({
imports: [TypeOrmModule.forFeature([Staff])],
controllers: [StaffsController],
providers: [Logger, StaffsService],
// If you want to use the repository outside of the module
// which imports TypeOrmModule.forFeature, you'll need to re-export the providers generated by it.
// exports: [TypeOrmModule],
})
export class StaffsModule {}

View File

@ -0,0 +1,50 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityNotFoundError, Repository } from 'typeorm';
import { Staff } from './entities/staff.entity';
import { StaffCreateDto } from './dto/staff-create.dto';
import { StaffUpdateDto } from './dto/staff-update.dto';
@Injectable()
export class StaffsService {
constructor(
private readonly logger: Logger,
@InjectRepository(Staff)
private staffsRepository: Repository<Staff>,
) {}
create(staffCreateDto: StaffCreateDto): Promise<Staff> {
// Use Repository.create() will copy the values to destination object.
const staff = this.staffsRepository.create(staffCreateDto);
return this.staffsRepository.save(staff);
}
findAll(): Promise<Staff[]> {
return this.staffsRepository.find();
}
findOne(id: number): Promise<Staff> {
return this.staffsRepository.findOneOrFail({ where: { id } });
}
async update(id: number, staffUpdateDto: StaffUpdateDto): Promise<void> {
await this.isExist(id);
await this.staffsRepository.update({ id }, staffUpdateDto);
}
async remove(id: number): Promise<void> {
await this.isExist(id);
await this.staffsRepository.delete(id);
}
// Helper function: Check if the entity exist.
// If entity does not exsit, return the Promise.reject()
private async isExist(id: number): Promise<void> {
const cnt = await this.staffsRepository.countBy({ id });
if (cnt > 0) {
return;
} else {
return Promise.reject(new EntityNotFoundError(Staff, { where: { id } }));
}
}
}

View File

@ -0,0 +1,39 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsDefined,
IsString,
IsNumber,
IsOptional,
} from 'class-validator';
export class CreateUserDto {
// NOTE: Since the id is autoinc, so no id for user creation
// @ApiProperty({
// type: 'number',
// name: 'id',
// description: 'id of user',
// required: false,
// })
// id: number;
@ApiProperty({
type: String,
name: 'name',
description: 'name of user',
required: true,
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
type: Number,
name: 'age',
description: 'age of user',
required: false,
})
@IsNumber()
@IsOptional()
age: number;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@ -0,0 +1,54 @@
import { ApiProperty } from '@nestjs/swagger';
import { CreateUserDto } from '../dto/create-user.dto';
import { UpdateUserDto } from '../dto/update-user.dto';
// See: https://cimpleo.com/blog/nestjs-modules-and-swagger-practices/
// export interface IUser {
// id: number;
// name: string;
// }
// export class User implements IUser {
export class User {
@ApiProperty({
type: 'number',
name: 'id',
description: 'id of user',
required: false,
})
id: number;
@ApiProperty({
type: 'string',
name: 'name',
description: 'name of user',
required: false,
})
name: string;
protected constructor() {
this.id = -1;
this.name = '';
}
// Factory methods
public static create(id = 0, name = ''): User {
const user = new User();
user.id = id;
user.name = name;
return user;
}
public static createFromCreateUserDto(
userDto: CreateUserDto | UpdateUserDto,
id?: number,
): User {
const user = new User();
if (id) {
user.id = id;
}
user.name = userDto.name;
return user;
}
}

View File

@ -0,0 +1,49 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
} from '@nestjs/common';
import { ApiResponse } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Get()
@ApiResponse({ type: [User], status: 200 })
async findAll(): Promise<User[]> {
return this.usersService.findAll();
}
@Get(':id')
@ApiResponse({ type: User, status: 200 })
async findOne(@Param('id') id: number): Promise<User> {
return this.usersService.findOne(+id);
}
@Patch(':id')
update(
@Param('id') id: number,
@Body() updateUserDto: UpdateUserDto,
): Promise<User> {
return this.usersService.update(+id, updateUserDto);
}
@Delete(':id')
remove(@Param('id') id: number): Promise<User> {
return this.usersService.remove(+id);
}
}

10
src/users/users.module.ts Normal file
View File

@ -0,0 +1,10 @@
import { Logger, Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { UserRepository } from './users.repository';
@Module({
controllers: [UsersController],
providers: [Logger, UsersService, UserRepository],
})
export class UsersModule {}

View File

@ -0,0 +1,72 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { User } from './entities/user.entity';
@Injectable()
export class UserRepository {
constructor(private readonly logger: Logger) {}
private readonly users = new Map<number, User>();
public async findAll(): Promise<User[]> {
return [...this.users.values()];
}
public async get(id: number): Promise<User> {
// const user = this.users.find((user) => user.id === id);
if (!this.users.has(id)) {
throw new NotFoundException('User not found');
}
return this.users.get(id);
}
public async add(user: User): Promise<void> {
const largestId = Math.max(...Array.from(this.users.keys()));
// Can get size from map directly but we simulate reading from external service
// So calling the method instead
// const cnt = await this.count();
// await this.get(cnt - 1)
// .then((lastuser) => {
// id = lastuser.id + 1;
// })
// .catch(() => {
// id = 0;
// });
user.id = largestId < 0 ? 0 : largestId + 1;
this.users.set(user.id, user);
this.logger.log({ message: 'User added', user });
}
public async update(id: number, user: User): Promise<User> {
// Can get size from map directly but we simulate reading from external service
// So calling the method instead
// It will throw exception if not found.
const oldUser = await this.get(id);
this.users.set(id, user);
this.logger.log({ message: 'User updated', from: oldUser, to: user });
return oldUser;
}
public async delete(id: number): Promise<User> {
let deletedUser: User;
await this.get(id)
.then((user) => {
this.users.delete(id);
this.logger.log({ message: 'User deleted', user });
deletedUser = user;
})
.catch(() => {
throw new NotFoundException(
`User not found, No user deleted, id=${id}`,
);
});
return deletedUser;
}
public async count(): Promise<number> {
return this.users.size;
}
}

View File

@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
import { UserRepository } from './users.repository';
@Injectable()
export class UsersService {
constructor(
private readonly logger: Logger,
private readonly userRepository: UserRepository,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const user = User.createFromCreateUserDto(createUserDto, -1);
await this.userRepository.add(user);
return user;
}
async findAll(): Promise<User[]> {
return this.userRepository.findAll();
}
async findOne(id: number): Promise<User> {
return this.userRepository.get(id);
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = User.createFromCreateUserDto(updateUserDto, id);
return this.userRepository.update(id, user);
}
async remove(id: number): Promise<User> {
return this.userRepository.delete(id);
}
}

24
test/app.e2e-spec.ts Normal file
View File

@ -0,0 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

9
test/jest-e2e.json Normal file
View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "data"]
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}

5253
yarn.lock Normal file

File diff suppressed because it is too large Load Diff