Initial commit

This commit is contained in:
Jyotirmoy Bandyopadhayaya 2023-05-06 16:27:52 +05:30 committed by GitHub
commit 9e52e0391d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 8110 additions and 0 deletions

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
PORT=
NODE_ENV=
HASURA_GRAPHQL_ADMIN_SECRET=
HASURA_GRAPHQL_ENDPOINT=
AWS_S3_BUCKET=
AWS_REGION=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
MAIL_HOST=
MAIL_PORT=
MAIL_USER=
MAIL_PASS=
MAIL_LOGGER=
MAIL_FROM_EMAIL=
MAIL_FROM_NAME=
CACHE_ENV=
REDIS_URL=

124
.eslintignore Normal file
View File

@ -0,0 +1,124 @@
# Webstorm stuff
.idea
# Ignoring Build folder
build
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# lock files
yarn.lock
*-lock.json
# Custom
testBox.js
jwtRS256.key
jwtRS256.key.pub
#markdown files
**/*.md

21
.eslintrc Normal file
View File

@ -0,0 +1,21 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "off"
},
"env": {
"browser": true,
"es2021": true
}
}

135
.gitignore vendored Normal file
View File

@ -0,0 +1,135 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.husky
yarn.lock
package-lock.json

121
.prettierignore Normal file
View File

@ -0,0 +1,121 @@
# Webstorm stuff
.idea
# Ignoring Build folder
build
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# lock files
yarn.lock
*-lock.json
# Custom
testBox.js
jwtRS256.key
jwtRS256.key.pub

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"tabWidth": 2,
"useTabs": true,
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid",
"proseWrap": "always"
}

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:alpine
WORKDIR /usr/src/app
COPY package.json ./
RUN yarn
RUN yarn build
COPY . .
EXPOSE 9000
CMD [ "yarn", "start" ]

15
LICENSE Normal file
View File

@ -0,0 +1,15 @@
ISC License
Copyright (c) 2023 Jyotirmoy Bandopadhayaya (https://b68.dev)
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

47
README.md Normal file
View File

@ -0,0 +1,47 @@
🚀 **typescript-express-hasura-pgsql-template**
A template repo to quickly start developing a backend with Express JS, Typescript, Redis, Hasura GraphQL (with Postgres), Husky, Nodemailer, and AWS S3 preconfigured.
## 📝 Description
This repo provides a starting point for developing a backend with a modern tech stack.
## 🛠️ Technologies Used
- Express JS
- Typescript
- Redis
- Hasura GraphQL (with Postgres)
- Husky
- Nodemailer
- AWS S3
## 🚀 Getting Started
To get started with the project, follow these steps:
1. Clone the repo.
2. Run `npm install` to install dependencies.
3. Copy the `.env.example` file and create a `.env` file with your environment variables.
4. Run `npm run dev` to start the development server.
## 📜 Scripts
- `npm run dev`: Starts the development server for both Hasura and Express.
- `npm run dev:hasura`: Starts the Hasura development server.
- `npm run dev:express`: Starts the Express development server.
- `npm run build`: Builds the project.
- `npm start`: Starts the project.
- `npm run prettier`: Runs Prettier to format code.
- `npm run prepare`: Installs Husky.
- `npm run configure-husky`: Configures Husky.
## 📝 License
This project is licensed under the ISC License.
For more information, please see the `LICENSE` file.
## 📧 Contact
If you have any questions or would like to contribute to the project, please contact `hi@b68.dev`.

59
configs/index.ts Normal file
View File

@ -0,0 +1,59 @@
import fs from 'fs';
import { parse as parseFile } from 'envfile';
type IconfigStore = 'development' | 'production';
export interface IConfigKeys {
PORT: string | number;
NODE_ENV: string;
HASURA_GRAPHQL_ADMIN_SECRET: string;
HASURA_GRAPHQL_ENDPOINT: string;
AWS_S3_BUCKET: string;
AWS_REGION: string;
AWS_ACCESS_KEY_ID: string;
AWS_SECRET_ACCESS_KEY: string;
MAIL_HOST: string;
MAIL_PORT: number;
MAIL_USER: string;
MAIL_PASS: string;
MAIL_LOGGER: boolean;
MAIL_FROM_EMAIL: string;
MAIL_FROM_NAME: string;
CACHE_ENV: string;
REDIS_URL: string;
}
export default class ConfigStoreFactory {
public configStoreType: IconfigStore;
constructor(isProd = false) {
if (isProd) {
this.configStoreType = 'production';
} else {
this.configStoreType = 'development';
}
}
public async getConfigStore() {
if (this.configStoreType === 'development') {
const envContent = await fs.readFileSync(`./.env`, 'utf8');
const env: Partial<IConfigKeys> = await parseFile(envContent);
return env;
} else {
let reqEnvContent: any = await fs.readFileSync('./.env.example', 'utf8');
reqEnvContent = reqEnvContent.replaceAll('=', '');
reqEnvContent = reqEnvContent.split('\n');
const missingKeys: string[] = [];
const env: Partial<IConfigKeys> = {};
for (const line of reqEnvContent) {
if (!process.env[line]) {
missingKeys.push(line);
} else env[line] = process.env[line];
}
if (missingKeys.length > 0) {
throw new Error(`Missing keys: ${missingKeys}`);
}
return env;
}
}
}

View File

@ -0,0 +1,14 @@
import DevClass from '../services/dev.service';
import { Request, Response } from 'express';
import { makeResponse } from '../libs';
export default class DevController extends DevClass {
public devFunc = async (req: Request, res: Response): Promise<any> => {
try {
const data = await this.devRun();
res.send(makeResponse(data));
} catch (err: any) {
res.send(makeResponse(err.message, {}, 'Failed', true));
}
};
}

6
hasura/config.yaml Normal file
View File

@ -0,0 +1,6 @@
version: 3
endpoint: <your-endpoint>
metadata_directory: metadata
actions:
kind: synchronous
handler_webhook_baseurl: http://localhost:3000

View File

View File

@ -0,0 +1,6 @@
actions: []
custom_types:
enums: []
input_objects: []
objects: []
scalars: []

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1 @@
version: 3

31
helpers/axios_client.ts Normal file
View File

@ -0,0 +1,31 @@
import axios from 'axios';
export const axiosInstance = axios.create({});
axiosInstance.interceptors.request.use(
config => {
const newConfig: any = { ...config };
newConfig.metadata = { startTime: new Date() };
return newConfig;
},
error => {
return Promise.reject(error);
}
);
axiosInstance.interceptors.response.use(
response => {
const newRes: any = { ...response };
newRes.config.metadata.endTime = new Date();
newRes.duration =
newRes.config.metadata.endTime - newRes.config.metadata.startTime;
return newRes;
},
error => {
const newError = { ...error };
newError.config.metadata.endTime = new Date();
newError.duration =
newError.config.metadata.endTime - newError.config.metadata.startTime;
return Promise.reject(newError);
}
);

65
helpers/cache.factory.ts Normal file
View File

@ -0,0 +1,65 @@
import * as redis from 'redis';
import NodeCache from 'node-cache';
import { configKeys } from '..';
type CacheEnvironment = 'development' | 'production';
export default class CacheClient {
private static _clientMode: CacheEnvironment;
private static _redisClient: redis.RedisClientType;
private static _nodeClient: NodeCache;
static get client() {
return this._clientMode === 'production'
? this._redisClient
: this._nodeClient;
}
static get env() {
return this._clientMode;
}
static init(forceEnv?: CacheEnvironment) {
const env =
forceEnv ||
configKeys.CACHE_ENV ||
configKeys.NODE_ENV ||
'development';
if (!['development', 'production'].includes(env))
throw new Error(
"Invalid Caching Environment, expected - ['development', 'production'], received - " +
env
);
this._clientMode = env as CacheEnvironment;
const redisUrl = configKeys.REDIS_URL || '';
if (env === 'production') {
this._redisClient = redis.createClient({
url: redisUrl,
name: '<>', // TODO: add redis name
});
this._redisClient.connect();
}
this._nodeClient = new NodeCache();
console.log(`Caching Client initialized in '${env}' environment`);
}
static async set(key: string, value: any) {
if (this._clientMode === 'production') {
await this._redisClient.set(key, value);
} else {
this._nodeClient.set(key, value);
}
}
static async get(key: string): Promise<string | null> {
if (this._clientMode === 'production') {
return await this._redisClient.get(key);
} else {
return (this._nodeClient.get(key) as string) || null;
}
}
}

18
helpers/gql_clent.ts Normal file
View File

@ -0,0 +1,18 @@
import { GraphQLClient } from 'graphql-request';
import { configKeys } from '..';
export let client = new GraphQLClient('');
export const hgqlInit = () => {
console.log('\n🚀 GraphQL Client Initialized');
let HASURA_URL: string = configKeys.HASURA_GRAPHQL_ENDPOINT || '';
HASURA_URL += HASURA_URL.endsWith('/') ? 'v1/graphql' : '/v1/graphql';
const HASURA_ADMIN: string = configKeys.HASURA_GRAPHQL_ADMIN_SECRET || '';
client = new GraphQLClient(HASURA_URL, {
headers: {
'x-hasura-admin-secret': HASURA_ADMIN,
},
});
};

4
helpers/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './cache.factory';
export * from './gql_clent';
export * from './axios_client';
export * from './upload.factory'

50
helpers/mailer.config.ts Normal file
View File

@ -0,0 +1,50 @@
import { configKeys } from "..";
export interface MailConfig {
host?: string;
port?: number;
secure?: boolean;
auth?: {
user: string;
pass: string;
};
logger?: boolean;
}
type MailerConfigValues = {
[k: string]: MailConfig & Partial<ExtraMailerConfig>;
};
interface ExtraMailerConfig {
from_email: string;
from_name: string;
}
const ConfigValue: MailerConfigValues = {
development: {
host: configKeys.MAIL_HOST,
port: configKeys.MAIL_PORT,
secure: false,
auth: {
user: configKeys.MAIL_USER,
pass: configKeys.MAIL_PASS,
},
logger: configKeys.MAIL_LOGGER,
from_email: configKeys.MAIL_FROM_EMAIL,
from_name: configKeys.MAIL_FROM_NAME,
},
production: {
host: configKeys.MAIL_HOST,
port: configKeys.MAIL_PORT,
secure: false,
auth: {
user: configKeys.MAIL_USER,
pass: configKeys.MAIL_PASS,
},
logger: configKeys.MAIL_LOGGER,
from_email: configKeys.MAIL_FROM_EMAIL,
from_name: configKeys.MAIL_FROM_NAME,
},
};
export default ConfigValue;

20
helpers/mailer.ts Normal file
View File

@ -0,0 +1,20 @@
import nodemailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer';
import { configKeys } from '..';
import MailerConfig from './mailer.config';
const mailConfig =
configKeys.NODE_ENV === 'production'
? MailerConfig.production
: MailerConfig.development;
const transporter = nodemailer.createTransport(mailConfig);
export default async (mail: Mail.Options): Promise<void> => {
try {
await transporter.sendMail(mail);
} catch (err) {
console.log(err);
}
};

80
helpers/upload.factory.ts Normal file
View File

@ -0,0 +1,80 @@
import { S3Client } from '@aws-sdk/client-s3';
import multer from 'multer';
import multerS3 from 'multer-s3';
import path from 'path';
import napiNanoId from 'napi-nanoid';
import { configKeys } from '..';
interface UploadFactoryOptions {
region: string;
bucket: string;
accessKey: string;
secretKey: string;
}
interface UploaderConfig {
folder: string;
mimeFilters: string[];
}
export class UploadFactory {
private options: UploadFactoryOptions & Partial<UploaderConfig>;
private s3Client: S3Client;
constructor(options?: Partial<UploadFactoryOptions>) {
this.options = {
bucket: options?.bucket || configKeys.AWS_S3_BUCKET || '',
region: options?.region || configKeys.AWS_REGION || '',
accessKey: options?.accessKey || configKeys.AWS_ACCESS_KEY_ID || '',
secretKey: options?.secretKey || configKeys.AWS_SECRET_ACCESS_KEY || '',
};
this.s3Client = new S3Client({
region: this.options.region,
credentials: {
accessKeyId: this.options.accessKey,
secretAccessKey: this.options.secretKey,
},
});
}
public get serviceName(): string {
return 'aws:' + this.options.bucket;
}
public getUploader(config?: Partial<UploadFactoryOptions & UploaderConfig>) {
const finalOptions = {
...this.options,
...(config || {}),
};
return multer({
fileFilter(_req, file, cb) {
const res = finalOptions.mimeFilters
? finalOptions.mimeFilters.includes(file.mimetype)
: true;
cb(null, res);
},
storage: multerS3({
s3: this.s3Client,
bucket: this.options.bucket,
acl: 'public-read',
contentType: multerS3.AUTO_CONTENT_TYPE,
metadata: function (_req, file, cb) {
const meta = {
fieldName: file.fieldname,
fileName: file.originalname,
uploadOn: new Date().toISOString(),
};
cb(null, meta);
},
key: function (_req, file, cb) {
const key: string[] = [];
if (finalOptions.folder) key.push(finalOptions.folder);
const value = napiNanoId.nanoid();
const ext = path.extname(file.originalname);
key.push(value + ext);
cb(null, key.join('/'));
},
}),
});
}
}

49
index.ts Normal file
View File

@ -0,0 +1,49 @@
import 'dotenv/config';
import cors from 'cors';
import express from 'express';
import morgan from 'morgan';
import helmet from 'helmet';
import { hgqlInit } from './helpers';
import routes from './routes';
import { errorHandler, notFoundHandler } from './libs';
import pkg from './package.json' assert { type: 'json' };
import configStore, { IConfigKeys } from './configs';
export const app: express.Application = express();
console.log('🚀', '@' + pkg.author.name + '/' + pkg.name, 'v' + pkg.version);
const isDev: boolean = process.env.NODE_ENV == 'production';
console.log(isDev ? '🚀 Production Mode' : '🚀 Development Mode');
const configs = new configStore(isDev);
const configKeys: IConfigKeys = await configs.getConfigStore() as IConfigKeys;
hgqlInit();
app.use(cors());
app.use(helmet());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use('/health', (req, res) => {
return res.status(200).json({
app: pkg.name,
request_ip: req.ip,
uptime: process.uptime(),
hrtime: process.hrtime(),
});
});
console.log('☄', 'Base Route', '/');
app.use('/', routes);
app.use(notFoundHandler);
app.use(errorHandler);
app.listen(configKeys.PORT, async () => {
console.log(`\nServer running on port ${configKeys.PORT}`);
});
export { configKeys };

29
libs/customErrHandler.ts Normal file
View File

@ -0,0 +1,29 @@
import { Response } from 'express';
import { ClientError } from 'graphql-request';
import { CustomError } from './error';
import { ValidationError } from 'joi';
const customErrorHandler = async (res: Response, error: any) => {
if (error instanceof ValidationError) {
return res.status(400).json({
success: false,
message: 'Data validation failed',
details: error.details,
});
}
if (error instanceof CustomError) {
return res
.status(error.statusCode)
.send({ success: false, message: error.message, data: error.data });
}
if (error instanceof ClientError) {
const { errors = [] } = error.response;
const [err] = errors;
if (err?.message) {
return res.status(422).send({ success: false, message: err.message });
}
}
res.status(500).send({ success: false, message: 'Internal ServerError.' });
};
export default customErrorHandler;

24
libs/error.ts Normal file
View File

@ -0,0 +1,24 @@
export class CustomError extends Error {
public statusCode: number;
public data: any;
constructor(args: { message?: string; statusCode?: number; data?: any }) {
super(args.message);
this.statusCode = args.statusCode || 500;
this.data = args.data;
}
toString() {
return {
message: this.message,
statusCode: this.statusCode,
data: this.data,
};
}
}
export class NotFoundError extends CustomError {
constructor() {
super({ message: 'NOT_FOUND', statusCode: 404 });
}
}

2
libs/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './middleware';
export * from './utilities';

65
libs/middleware.ts Normal file
View File

@ -0,0 +1,65 @@
import { NextFunction, Request, Response } from 'express';
import Joi from 'joi';
import { CustomError, NotFoundError } from './error';
import { pick } from './utilities';
import { configKeys } from ".."
export const errorHandler = async (
err: any,
_req: Request,
res: Response,
// eslint-disable-next-line
_next: NextFunction
) => {
if ('statusCode' in err) {
return res.status(err.statusCode).json({
message: err.message,
error: true,
data: null,
});
}
return res.status(500).json({
message: err.message,
error: true,
data: null,
error_stack: configKeys.NODE_ENV === 'production' ? undefined : err.stack,
});
};
export const notFoundHandler = async (
_req: Request,
_res: Response,
next: NextFunction
) => {
return next(new NotFoundError());
};
export const validate =
(schema: any) => (req: Request, _res: Response, next: NextFunction) => {
if (Object.keys(req.body).length !== 0 && !req.is('application/json')) {
return next(new Error('Supports JSON request body only'));
}
const validSchema = pick(schema, ['params', 'query', 'body']);
const object = pick(req, Object.keys(validSchema));
const { value, error } = Joi.compile(validSchema)
.prefs({ errors: { label: 'key' } })
.validate(object);
if (error) {
const errorMessage = error.details
.map(details => details.message)
.join(', ');
return next(
new CustomError({
message: errorMessage,
statusCode: 400,
})
);
}
Object.assign(req, value);
return next();
};

145
libs/utilities.ts Normal file
View File

@ -0,0 +1,145 @@
import { PaginationType } from '../types';
export const makeResponse = (
data: any,
meta_data: any = null,
message = 'Success',
error = false
) => ({
message,
error,
meta_data,
data,
});
const joinPrefix = (...keys: string[]) => keys.join('_');
export const flattenObject = (obj: any, prefix = '') => {
let newObj: any = {};
for (const key in obj) {
const pfx = prefix ? joinPrefix(prefix, key) : key;
if (obj[key] instanceof Object) {
newObj = { ...newObj, ...flattenObject(obj[key], pfx) };
} else {
newObj = { ...newObj, [pfx]: obj[key] };
}
}
return newObj;
};
export const cleanObject = (obj: any) => {
const newObj: any = obj;
for (const k in obj) {
if (
(!k || !obj[k] || typeof k === 'undefined') &&
typeof obj[k] !== 'boolean' &&
obj[k] !== 0
)
delete obj[k];
}
return newObj;
};
export const cleanObjectKeepNull = (obj: any) => {
const newObj: any = obj;
for (const k in obj) {
if (
(!k || !obj[k] || typeof k === 'undefined') &&
typeof obj[k] !== 'boolean' &&
obj[k] !== 0 &&
obj[k] !== null
)
delete obj[k];
}
return newObj;
};
export const paginateRequest = (q: any): PaginationType => {
const filter_keys = Object.keys(q).filter(c => c.startsWith('filter_'));
const filters = filter_keys.length
? filter_keys
.map(filter_key => {
const filter_subset = filter_key.replace('filter_', '').split('.');
let mode = typeof q[filter_key] === 'number' ? '_eq' : '_iregex';
if (q[filter_key].includes('-')) {
mode = '_eq';
}
return parseFilter(filter_subset, q[filter_key], 0, mode);
})
.reduceRight((agg, cur) => {
const [cur_key] = Object.keys(cur);
if (cur_key in agg) {
if (Array.isArray(agg[cur_key])) {
agg[cur_key].push(cur);
} else {
cur[cur_key] = [cur[cur_key], agg[cur_key]];
}
return cur;
}
return {
...agg,
...cur,
};
}, {})
: undefined;
return {
page: parseInt(q.page) || 0,
limit: parseInt(q.limit || q.items) || 50,
sort_by: q.sort_by,
sort_order: q.sort_order || 'asc',
filters,
} as PaginationType;
};
export const parseFilter = (
filter: string[],
value: string,
index = 0,
filterMode = '_iregex'
) => {
let fx: any = { [filterMode]: value };
if (index < filter.length - 1) {
fx = parseFilter(filter, value, index + 1, filterMode);
}
const key = filter[index];
return { [key]: fx };
};
export const subtractHours = (date: Date, hours: number) => {
date.setHours(date.getHours() - hours);
return date.toISOString();
};
export const capitalizeEachWord = (str: string) => {
return str
.split(' ')
.map(word =>
!word.length ? '' : word[0].toUpperCase() + word.slice(1).toLowerCase()
)
.join(' ');
};
export const pick = (object: any, keys: any) => {
return keys.reduce((obj: any, key: any) => {
if (object && key in object) {
obj[key] = object[key];
}
return obj;
}, {});
};
export const getSortColumn = (
pg_sort_by?: string,
def = 'id',
options: string[] = []
) => {
pg_sort_by ||= def;
return options.includes(pg_sort_by) ? pg_sort_by : def;
};
export const is_uuid = (value: string) => {
const regex = /^()/;
return regex.test(value);
};

76
package.json Normal file
View File

@ -0,0 +1,76 @@
{
"name": "typescript-express-hasura-pgsql-template",
"version": "1.0.0",
"description": "Backend Repo Template",
"main": "index.ts",
"repository": "https://github.com/<username>/<repo-name>.git",
"author": {
"email": "<email>",
"name": "<name>",
"url": "<url>"
},
"license": "ISC",
"type": "module",
"private": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.226.0",
"axios": "^1.2.1",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"envfile": "^6.18.0",
"express": "^4.18.2",
"form-data": "^4.0.0",
"graphql": "^16.6.0",
"graphql-request": "^5.0.0",
"helmet": "^6.0.1",
"joi": "^17.7.0",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"multer-s3": "^3.0.1",
"napi-nanoid": "^0.0.4",
"node-cache": "^5.1.2",
"nodemailer": "^6.8.0",
"redis": "^4.5.1"
},
"scripts": {
"dev": "concurrently \"npm run dev:express\" \"npm run dev:hasura\"",
"dev:hasura": "cd hasura && hasura --skip-update-check --envfile ../.env console",
"dev:express": "cross-env NODE_ENV=development nodemon -x node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm index.ts --signal SIGKILL --ignore node_modules",
"build": "tsc",
"start": "node --es-module-specifier-resolution=node --loader ts-node/esm ./build/index.js",
"prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,scss,md}\"",
"prepare": "husky install",
"configure-husky": "npx husky install && npx husky add .husky/pre-commit \"npx --no-install lint-staged\""
},
"devDependencies": {
"@swc/core": "^1.3.23",
"@swc/wasm": "^1.3.23",
"@types/cors": "^2.8.13",
"@types/express": "^4.17.14",
"@types/morgan": "^1.9.3",
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@typescript-eslint/parser": "^5.54.1",
"concurrently": "^7.6.0",
"cross-env": "^7.0.3",
"eslint": "^8.35.0",
"eslint-config-prettier": "^8.7.0",
"hasura-cli": "^2.15.1",
"husky": "^8.0.3",
"lint-staged": "^13.1.2",
"nodemon": "^2.0.22",
"prettier": "^2.8.2",
"ts-node": "^10.9.1",
"typescript": "^4.9.3"
},
"lint-staged": {
"**/*.{js,json,ts,css}": [
"eslint --fix",
"prettier --write"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
}
}

18
routes/dev.routes.ts Normal file
View File

@ -0,0 +1,18 @@
import { Router } from 'express';
import DevController from '../controllers/dev.controller';
const { devFunc } = new DevController();
const router = Router();
router.get('/', devFunc);
router.all('/err', async (req, res, next) => {
try {
throw new Error('This is an error');
} catch (err) {
next(err);
}
});
export default router;

40
routes/index.ts Normal file
View File

@ -0,0 +1,40 @@
import path from 'path';
import { readdirSync } from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
import { Router } from 'express';
const router = Router();
const isCompiled = path.extname(__filename) === '.js';
const thisFileName = path.basename(__filename);
const loadRoutes = async (dirPath: string, prefix = '/') => {
readdirSync(dirPath, {
withFileTypes: true,
}).forEach(async f => {
if (f.isFile()) {
if (f.name == thisFileName) return;
const isRouteMod = f.name.endsWith(`.routes.${isCompiled ? 'js' : 'ts'}`);
if (isRouteMod) {
const route = f.name.replace(`.routes.${isCompiled ? 'js' : 'ts'}`, '');
const modRoute = path.join(prefix, route);
console.log('🛰️', 'Loaded', modRoute);
const mod = await import(path.join(baseDir, prefix + f.name));
router.use(modRoute, mod.default);
}
} else if (f.isDirectory()) {
await loadRoutes(path.resolve(dirPath, f.name), prefix + f.name + '/');
}
});
};
let baseDir = path.dirname(__filename);
baseDir = path.resolve(baseDir);
loadRoutes(baseDir);
export default router;

5
services/dev.service.ts Normal file
View File

@ -0,0 +1,5 @@
export default class DevClass {
public devRun = async (): Promise<any> => {
return 'Hello World!';
};
}

6763
third-party-licenses.txt Normal file

File diff suppressed because it is too large Load Diff

24
tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"lib": ["es2018", "es5", "dom"],
"typeRoots": ["node_modules/@types", "./types"],
"resolveJsonModule": true,
"esModuleInterop": true,
"target": "ES2017",
"strict": true,
"module": "ESNext",
"moduleResolution": "node",
"outDir": "./build",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"declaration": true,
"sourceMap": false,
"noImplicitAny": false
},
"exclude": ["./node_modules/**/*", "./build/**/*"],
"include": ["./**/*.ts", "./**/*.tsx", "./**/*.json", "./**/*.js"],
"ts-node": {
"swc": true
},
"files": ["types/index.d.ts"]
}

7
types/index.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
export interface PaginationType {
page: number;
limit: number;
sort_order?: 'asc' | 'desc';
sort_by?: string;
filters?: { [k: string]: any };
}