Initial commit
This commit is contained in:
parent
164373aa05
commit
8c78c2ad98
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
.git
|
||||
.gitlab-ci*
|
||||
# .gitignore
|
||||
Dockerfile*
|
||||
.env*
|
||||
data
|
25
.eslintrc.js
Normal file
25
.eslintrc.js
Normal 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
38
.gitignore
vendored
Normal 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
221
.gitlab-ci.yml
Normal 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
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
26
.vscode/launch.json
vendored
Normal file
26
.vscode/launch.json
vendored
Normal 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
5
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"typescript.format.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
183
Dockerfile
Normal file
183
Dockerfile
Normal 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
174
Dockerfile.dev
Normal 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
128
README.md
@ -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
6
data/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.gitkeep
|
||||
!README
|
||||
!README.md
|
||||
!mysql/
|
0
data/README.md
Normal file
0
data/README.md
Normal file
5
data/mysql/.gitignore
vendored
Normal file
5
data/mysql/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
*
|
||||
!.gitignore
|
||||
!.gitkeep
|
||||
!README
|
||||
!README.md
|
0
data/mysql/.gitkeep
Normal file
0
data/mysql/.gitkeep
Normal file
48
docker-compose.db.yml
Normal file
48
docker-compose.db.yml
Normal 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
64
docker-compose.yml
Normal 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:
|
33
image_files/fsroot/docker-entrypoint.d/30-prepare-env.sh
Normal file
33
image_files/fsroot/docker-entrypoint.d/30-prepare-env.sh
Normal 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
|
@ -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
|
17
image_files/fsroot/docker-entrypoint.d/90-start-app.sh
Normal file
17
image_files/fsroot/docker-entrypoint.d/90-start-app.sh
Normal 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
|
@ -0,0 +1,6 @@
|
||||
# customisation for bash
|
||||
|
||||
# aliases
|
||||
alias ll='ls -l'
|
||||
alias la='ls -A'
|
||||
alias l='ls -CF'
|
36
image_files/fsroot/docker-entrypoint.sh
Normal file
36
image_files/fsroot/docker-entrypoint.sh
Normal 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
|
49
image_files/fsroot/usr/local/bin/script_utils.sh
Normal file
49
image_files/fsroot/usr/local/bin/script_utils.sh
Normal 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
8
nest-cli.json
Normal 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
15749
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
78
package.json
Normal file
78
package.json
Normal 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
8
sample.env-app
Normal 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
10
sample.env-docker-compose
Normal 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
|
28
src/app.controller.spec.ts
Normal file
28
src/app.controller.spec.ts
Normal 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
17
src/app.controller.ts
Normal 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
57
src/app.module.ts
Normal 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
19
src/app.service.ts
Normal 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';
|
||||
}
|
||||
}
|
77
src/logger/any-exception.filter.ts
Normal file
77
src/logger/any-exception.filter.ts
Normal 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
88
src/logger/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
71
src/logger/logger.middleware.ts
Normal file
71
src/logger/logger.middleware.ts
Normal 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
44
src/main.ts
Normal 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
10
src/staffs/README.md
Normal 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
|
36
src/staffs/dto/staff-create.dto.ts
Normal file
36
src/staffs/dto/staff-create.dto.ts
Normal 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;
|
||||
}
|
4
src/staffs/dto/staff-update.dto.ts
Normal file
4
src/staffs/dto/staff-update.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { StaffCreateDto } from './staff-create.dto';
|
||||
|
||||
export class StaffUpdateDto extends PartialType(StaffCreateDto) {}
|
57
src/staffs/entities/staff.entity.ts
Normal file
57
src/staffs/entities/staff.entity.ts
Normal 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
|
||||
}
|
76
src/staffs/staffs.controller.ts
Normal file
76
src/staffs/staffs.controller.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
15
src/staffs/staffs.module.ts
Normal file
15
src/staffs/staffs.module.ts
Normal 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 {}
|
50
src/staffs/staffs.service.ts
Normal file
50
src/staffs/staffs.service.ts
Normal 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 } }));
|
||||
}
|
||||
}
|
||||
}
|
39
src/users/dto/create-user.dto.ts
Normal file
39
src/users/dto/create-user.dto.ts
Normal 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;
|
||||
}
|
4
src/users/dto/update-user.dto.ts
Normal file
4
src/users/dto/update-user.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
54
src/users/entities/user.entity.ts
Normal file
54
src/users/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
49
src/users/users.controller.ts
Normal file
49
src/users/users.controller.ts
Normal 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
10
src/users/users.module.ts
Normal 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 {}
|
72
src/users/users.repository.ts
Normal file
72
src/users/users.repository.ts
Normal 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;
|
||||
}
|
||||
}
|
36
src/users/users.service.ts
Normal file
36
src/users/users.service.ts
Normal 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
24
test/app.e2e-spec.ts
Normal 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
9
test/jest-e2e.json
Normal 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
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "data"]
|
||||
}
|
21
tsconfig.json
Normal file
21
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user