├── test ├── fixtures │ ├── testapp │ │ ├── src │ │ │ ├── view │ │ │ │ └── .gitkeep │ │ │ ├── app.ts │ │ │ ├── service │ │ │ │ └── DepdendencyInjectionService.ts │ │ │ ├── controller │ │ │ │ ├── ResponseFaultController.ts │ │ │ │ ├── PrefixRouteController.ts │ │ │ │ ├── MailController.ts │ │ │ │ ├── DependencyInjectionController.ts │ │ │ │ ├── ResponseController.ts │ │ │ │ ├── DatabaseController.ts │ │ │ │ ├── RequestHeaderTestController.ts │ │ │ │ └── RequestTestController.ts │ │ │ ├── email │ │ │ │ └── test.mjml │ │ │ └── entity │ │ │ │ └── Person.ts │ │ └── zen.json │ ├── config │ │ ├── zen.development.config.js │ │ ├── zen.json │ │ └── zen.test.config.js │ └── zenfiles │ │ ├── DefaultController.ts │ │ ├── FaultController.ts │ │ └── NamedExportController.ts ├── helper │ ├── getFixtureDir.ts │ └── loadFixtureTestAppConfig.ts ├── http │ ├── __snapshots__ │ │ └── request.test.ts.snap │ └── cookie.test.ts ├── decorators │ ├── controller.test.ts │ ├── inject.test.ts │ └── routing.test.ts ├── email │ └── email.test.ts ├── filesystem │ ├── validateInstallation.test.ts │ ├── fs.test.ts │ └── abstractZenFileLoader.test.ts ├── common │ ├── autoloader_registry.test.ts │ ├── config.test.ts │ ├── app.test.ts │ └── utils.test.ts ├── mock │ └── mockZenApp.ts ├── database │ └── database.test.ts └── controller │ ├── controllerFactory.test.ts │ └── controllerLoader.test.ts ├── src ├── log │ ├── index.ts │ └── logger.ts ├── router │ └── index.ts ├── http │ ├── requesthandlers │ │ ├── index.ts │ │ ├── SecurityRequestHandler.ts │ │ └── ControllerRequestHandler.ts │ ├── validate.ts │ ├── index.ts │ ├── RequestHeader.ts │ ├── BodyParser.ts │ ├── RequestFactory.ts │ ├── ResponseHeader.ts │ ├── Server.ts │ ├── Request.ts │ ├── IncomingRequest.ts │ ├── Cookie.ts │ └── Context.ts ├── filesystem │ ├── index.ts │ ├── validateInstallation.ts │ ├── readDirRecursiveGenerator.ts │ ├── AbstractZenFileLoader.ts │ └── FS.ts ├── service │ ├── index.ts │ ├── ServiceLoader.ts │ └── ServiceFactory.ts ├── email │ ├── index.ts │ ├── EmailTemplateLoader.ts │ └── EmailFactory.ts ├── types │ ├── index.ts │ └── enums.ts ├── config │ ├── index.ts │ ├── default.ts │ └── config.ts ├── template │ ├── TemplateResponse.ts │ ├── index.ts │ ├── Loader.ts │ ├── Environment.ts │ └── TemplateEngineLoader.ts ├── dependencies │ ├── index.ts │ ├── InjectorAction │ │ ├── AbstractAction.ts │ │ ├── index.ts │ │ ├── RedisAction.ts │ │ ├── EntityManagerAction.ts │ │ ├── ConnectionAction.ts │ │ ├── DependenciesAction.ts │ │ ├── EmailAction.ts │ │ ├── AllContextAction.ts │ │ ├── BodyContextAction.ts │ │ ├── ErrorContextAction.ts │ │ ├── QueryContextAction.ts │ │ ├── CookieContextAction.ts │ │ ├── ParamsContextAction.ts │ │ ├── RequestContextAction.ts │ │ ├── ResponseContextAction.ts │ │ ├── SecurityProviderAction.ts │ │ ├── RepositoryAction.ts │ │ └── SessionAction.ts │ ├── ModuleContext.ts │ └── Injector.ts ├── controller │ ├── index.ts │ ├── Controller.ts │ ├── ControllerFactory.ts │ └── ControllerLoader.ts ├── core │ ├── index.ts │ ├── Zen.ts │ ├── AbstractFactory.ts │ ├── ZenApp.ts │ ├── Registry.ts │ └── AutoLoader.ts ├── database │ ├── index.ts │ ├── createConnection.ts │ ├── DatabaseContainer.ts │ ├── EntityLoader.ts │ └── createRedisClient.ts ├── decorators │ ├── redis.ts │ ├── index.ts │ ├── email.ts │ ├── controller.ts │ ├── validation.ts │ ├── inject.ts │ ├── security.ts │ ├── routing.ts │ ├── context.ts │ └── database.ts ├── security │ ├── index.ts │ ├── Session.ts │ ├── strategies │ │ ├── CookieSecurityStrategy.ts │ │ ├── HeaderSecurityStrategy.ts │ │ └── HybridSecurityStrategy.ts │ ├── SessionStore.ts │ ├── SessionStoreAdapterFactory.ts │ ├── SecurityResponse.ts │ ├── SessionFactory.ts │ ├── JWT.ts │ ├── stores │ │ ├── RedisSessionStoreAdapter.ts │ │ └── DatabaseSessionStoreAdapter.ts │ ├── SecurityProviderLoader.ts │ └── SecurityProviderOptions.ts ├── utils │ ├── escapeHtml.ts │ ├── isObject.ts │ ├── unset.ts │ ├── getContentType.ts │ ├── encodeUrl.ts │ ├── get.ts │ └── set.ts └── index.ts ├── .eslintignore ├── docker ├── README.md └── services │ ├── postgresql │ └── Dockerfile │ └── mailcatcher │ └── Dockerfile ├── docs ├── .vuepress │ ├── public │ │ ├── favicon │ │ │ ├── favicon.ico │ │ │ ├── apple-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon-96x96.png │ │ │ ├── ms-icon-70x70.png │ │ │ ├── apple-icon-57x57.png │ │ │ ├── apple-icon-60x60.png │ │ │ ├── apple-icon-72x72.png │ │ │ ├── apple-icon-76x76.png │ │ │ ├── ms-icon-144x144.png │ │ │ ├── ms-icon-150x150.png │ │ │ ├── ms-icon-310x310.png │ │ │ ├── android-icon-36x36.png │ │ │ ├── android-icon-48x48.png │ │ │ ├── android-icon-72x72.png │ │ │ ├── android-icon-96x96.png │ │ │ ├── apple-icon-114x114.png │ │ │ ├── apple-icon-120x120.png │ │ │ ├── apple-icon-144x144.png │ │ │ ├── apple-icon-152x152.png │ │ │ ├── apple-icon-180x180.png │ │ │ ├── android-icon-144x144.png │ │ │ ├── android-icon-192x192.png │ │ │ ├── apple-icon-precomposed.png │ │ │ ├── browserconfig.xml │ │ │ └── manifest.json │ │ ├── zents_logo_icon.png │ │ ├── landingpage │ │ │ ├── hero.png │ │ │ ├── screen.png │ │ │ ├── footer-bg.png │ │ │ ├── section-bg3.png │ │ │ └── section_bg02.png │ │ ├── zents_logo_black.png │ │ ├── zents_logo_small.png │ │ ├── zents_logo_white.png │ │ └── icons │ │ │ ├── npm.svg │ │ │ ├── cube.svg │ │ │ ├── services.svg │ │ │ ├── template.svg │ │ │ ├── dependency_injection.svg │ │ │ ├── autoloading.svg │ │ │ ├── twitter.svg │ │ │ ├── leaf.svg │ │ │ ├── github.svg │ │ │ ├── rocket.svg │ │ │ ├── controller_routing.svg │ │ │ ├── orm.svg │ │ │ └── typescript.svg │ ├── components │ │ ├── ReadingTime.vue │ │ ├── GuideHeader.vue │ │ └── LandingPage │ │ │ └── Terminal.vue │ └── styles │ │ └── palette.styl ├── index.md ├── api │ └── index.md ├── roadmap.md └── guide │ └── README.md ├── .gitignore ├── .npmignore ├── .prettierrc ├── deploy-docs.sh ├── jest.config.json ├── docker-compose.yml ├── tsconfig.json ├── LICENSE ├── README.md └── .eslintrc.js /test/fixtures/testapp/src/view/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/log/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger' 2 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | export * from './RouterFactory' 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | example 4 | jest.config.js 5 | test -------------------------------------------------------------------------------- /src/http/requesthandlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ControllerRequestHandler' 2 | -------------------------------------------------------------------------------- /test/fixtures/testapp/zen.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "port": 3000 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/filesystem/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FS' 2 | export * from './AbstractZenFileLoader' 3 | -------------------------------------------------------------------------------- /src/service/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ServiceLoader' 2 | export * from './ServiceFactory' 3 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | The docker directory and files are only for testing the framework locally. 2 | -------------------------------------------------------------------------------- /src/email/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EmailFactory' 2 | export * from './EmailTemplateLoader' 3 | -------------------------------------------------------------------------------- /docker/services/postgresql/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:13 2 | 3 | ADD ./dump.sql /docker-entrypoint-initdb.d -------------------------------------------------------------------------------- /src/http/validate.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi' 2 | 3 | const validate = Joi 4 | 5 | export { validate } 6 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './enums' 2 | export * from './interfaces' 3 | export * from './types' 4 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config' 2 | export * from './default' 3 | export * from './validateConfig' 4 | -------------------------------------------------------------------------------- /src/template/TemplateResponse.ts: -------------------------------------------------------------------------------- 1 | export class TemplateResponse { 2 | constructor(public html: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/config/zen.development.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | web: { 3 | port: 5555, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/testapp/src/app.ts: -------------------------------------------------------------------------------- 1 | import { zen } from "zents"; 2 | 3 | (async () => { 4 | await zen(); 5 | })(); 6 | -------------------------------------------------------------------------------- /test/fixtures/config/zen.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "port": 1234, 4 | "publicPath": "/assets/" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/favicon.ico -------------------------------------------------------------------------------- /docs/.vuepress/public/zents_logo_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/zents_logo_icon.png -------------------------------------------------------------------------------- /src/dependencies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Injector' 2 | export * from './ModuleContext' 3 | export * from './InjectorAction' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .git/ 3 | node_modules/ 4 | npm-debug.log 5 | lib/ 6 | .vscode/ 7 | .env 8 | docs/.vuepress/dist/ 9 | docker/db/ -------------------------------------------------------------------------------- /docs/.vuepress/public/landingpage/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/landingpage/hero.png -------------------------------------------------------------------------------- /docs/.vuepress/public/zents_logo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/zents_logo_black.png -------------------------------------------------------------------------------- /docs/.vuepress/public/zents_logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/zents_logo_small.png -------------------------------------------------------------------------------- /docs/.vuepress/public/zents_logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/zents_logo_white.png -------------------------------------------------------------------------------- /src/controller/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Controller' 2 | export * from './ControllerLoader' 3 | export * from './ControllerFactory' 4 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Zen' 2 | export * from './ZenApp' 3 | export * from './AutoLoader' 4 | export * from './Registry' 5 | -------------------------------------------------------------------------------- /src/database/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createConnection' 2 | export * from './createRedisClient' 3 | export * from './EntityLoader' 4 | -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon.png -------------------------------------------------------------------------------- /docs/.vuepress/public/landingpage/screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/landingpage/screen.png -------------------------------------------------------------------------------- /test/fixtures/zenfiles/DefaultController.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from './../../../src' 2 | 3 | export default class extends Controller {} 4 | -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/ms-icon-70x70.png -------------------------------------------------------------------------------- /docs/.vuepress/public/landingpage/footer-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/landingpage/footer-bg.png -------------------------------------------------------------------------------- /test/fixtures/testapp/src/service/DepdendencyInjectionService.ts: -------------------------------------------------------------------------------- 1 | export default class { 2 | example() { 3 | return 'foo' 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .git/ 3 | docs/ 4 | node_modules/ 5 | npm-debug.log 6 | test/ 7 | .vscode/ 8 | .env 9 | docker-compose.yml 10 | docker/ -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-57x57.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-60x60.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-72x72.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-76x76.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/ms-icon-144x144.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/ms-icon-150x150.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/ms-icon-310x310.png -------------------------------------------------------------------------------- /docs/.vuepress/public/landingpage/section-bg3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/landingpage/section-bg3.png -------------------------------------------------------------------------------- /docs/.vuepress/public/landingpage/section_bg02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/landingpage/section_bg02.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/android-icon-36x36.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/android-icon-48x48.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/android-icon-72x72.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/android-icon-96x96.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-114x114.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-120x120.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-144x144.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-152x152.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-180x180.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/android-icon-144x144.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/android-icon-192x192.png -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sahachide/ZenTS/HEAD/docs/.vuepress/public/favicon/apple-icon-precomposed.png -------------------------------------------------------------------------------- /test/fixtures/config/zen.test.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | web: { 3 | port: 8080, 4 | }, 5 | database: { 6 | enable: true, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/zenfiles/FaultController.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from './../../../src' 2 | 3 | export class WrongExportedMemberController extends Controller {} 4 | -------------------------------------------------------------------------------- /test/fixtures/zenfiles/NamedExportController.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from './../../../src' 2 | 3 | export class NamedExportController extends Controller {} 4 | -------------------------------------------------------------------------------- /src/template/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Environment' 2 | export * from './Loader' 3 | export * from './TemplateEngineLoader' 4 | export * from './TemplateResponse' 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "singleQuote": true, 5 | "quoteProps": "consistent", 6 | "trailingComma": "all", 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /test/helper/getFixtureDir.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | 3 | export function getFixtureDir(which: string): string { 4 | return join(process.cwd(), `./test/fixtures/${which}`) 5 | } 6 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: LandingPage 3 | lang: en-US 4 | meta: 5 | - name: keywords 6 | content: ZenTS MVC framework TypeScript guide tutorial documentation MIT open source 7 | --- 8 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/AbstractAction.ts: -------------------------------------------------------------------------------- 1 | import type { Injector } from '../Injector' 2 | 3 | export abstract class AbstractAction { 4 | constructor(protected injector: Injector) {} 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/testapp/src/controller/ResponseFaultController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, controller } from '../../../../../src' 2 | 3 | @controller('Response') 4 | export default class extends Controller {} 5 | -------------------------------------------------------------------------------- /src/decorators/redis.ts: -------------------------------------------------------------------------------- 1 | import { REFLECT_METADATA } from '../types/enums' 2 | 3 | export function redis(target: any, propertyKey: string): void { 4 | Reflect.defineMetadata(REFLECT_METADATA.REDIS_CLIENT, propertyKey, target) 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/testapp/src/email/test.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hello world 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/security/index.ts: -------------------------------------------------------------------------------- 1 | export * from './JWT' 2 | export * from './SecurityProviderOptions' 3 | export * from './SecurityProvider' 4 | export * from './SecurityProviderLoader' 5 | export * from './SessionFactory' 6 | export * from './SessionStore' 7 | -------------------------------------------------------------------------------- /src/utils/escapeHtml.ts: -------------------------------------------------------------------------------- 1 | export function escapeHtml(str: string): string { 2 | return str 3 | .replace(/"/g, '"') 4 | .replace(/'/g, ''') 5 | .replace(//g, '>') 7 | .replace(/&/g, '&') 8 | } 9 | -------------------------------------------------------------------------------- /deploy-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | npm run docs:build 5 | cd docs/.vuepress/dist 6 | echo 'zents.dev' > CNAME 7 | git init 8 | git add -A 9 | git commit -m 'deploy' 10 | git push -f git@github.com:sahachide/ZenTS.git master:gh-pages 11 | cd - -------------------------------------------------------------------------------- /src/utils/isObject.ts: -------------------------------------------------------------------------------- 1 | export function isObject(value: any): boolean { 2 | if (value === null || Array.isArray(value)) { 3 | return false 4 | } 5 | 6 | const type = typeof value 7 | 8 | return type === 'object' || type === 'function' 9 | } 10 | -------------------------------------------------------------------------------- /src/core/Zen.ts: -------------------------------------------------------------------------------- 1 | import { ZenApp } from './ZenApp' 2 | import type { ZenConfig } from '../types/interfaces' 3 | 4 | export async function zen(config?: ZenConfig): Promise { 5 | const app = new ZenApp() 6 | await app.boot(config) 7 | 8 | return app 9 | } 10 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | # API reference 2 | 3 | 4 | [[toc]] 5 | 6 | 7 | ## Under construction 8 | 9 | The API reference is currently under construction. Please come back later and check the source code directly meanwhile. 10 | -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './routing' 2 | export * from './inject' 3 | export * from './database' 4 | export * from './controller' 5 | export * from './redis' 6 | export * from './security' 7 | export * from './context' 8 | export * from './email' 9 | export * from './validation' 10 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": ["./test"], 3 | "testPathIgnorePatterns": ["test/fixtures/config/zen.test.config.js"], 4 | "preset": "ts-jest", 5 | "collectCoverage": true, 6 | "collectCoverageFrom": ["src/**/*.ts"], 7 | "coverageReporters": ["text"], 8 | "testEnvironment": "node" 9 | } 10 | -------------------------------------------------------------------------------- /src/decorators/email.ts: -------------------------------------------------------------------------------- 1 | import type { Class } from 'type-fest' 2 | import { REFLECT_METADATA } from '../types/enums' 3 | 4 | export function email(target: Class, propertyKey: string, parameterIndex: number): void { 5 | Reflect.defineMetadata(REFLECT_METADATA.EMAIL, parameterIndex, target, propertyKey) 6 | } 7 | -------------------------------------------------------------------------------- /docs/.vuepress/components/ReadingTime.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ text }} 4 | 5 | 6 | 7 | 16 | 17 | -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /src/decorators/controller.ts: -------------------------------------------------------------------------------- 1 | import { REFLECT_METADATA } from '../types/enums' 2 | 3 | export function controller(key: string) { 4 | // eslint-disable-next-line @typescript-eslint/ban-types 5 | return (target: Function): void => { 6 | Reflect.defineMetadata(REFLECT_METADATA.CONTROLLER_KEY, key, target) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/testapp/src/entity/Person.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm' 2 | 3 | @Entity() 4 | export class Person { 5 | @PrimaryGeneratedColumn() 6 | id: number 7 | 8 | @Column() 9 | firstname: string 10 | 11 | @Column() 12 | lastname: string 13 | 14 | @Column() 15 | email: string 16 | } 17 | -------------------------------------------------------------------------------- /test/http/__snapshots__/request.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Request recives all request headers 1`] = ` 4 | Object { 5 | "result": Object { 6 | "accept": "application/json", 7 | "accept-encoding": "gzip, deflate", 8 | "connection": "close", 9 | "x-zen-test": "foo", 10 | }, 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /src/decorators/validation.ts: -------------------------------------------------------------------------------- 1 | import { REFLECT_METADATA } from '../types/enums' 2 | import type { ValidationSchema } from '../types/types' 3 | 4 | export function validation(schema: ValidationSchema) { 5 | return (target: any, propertyKey: string): void => { 6 | Reflect.defineMetadata(REFLECT_METADATA.VALIDATION_SCHEMA, schema, target, propertyKey) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/testapp/src/controller/PrefixRouteController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, controller, get, prefix } from '../../../../../src' 2 | 3 | @controller('Prefix') 4 | @prefix('/prefix/') 5 | export default class extends Controller { 6 | @get('example-without-slash') 7 | public async exampleWithoutSlash() { 8 | return { 9 | foo: 'bar', 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/npm.svg: -------------------------------------------------------------------------------- 1 | Logo Npm -------------------------------------------------------------------------------- /docker/services/mailcatcher/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.7.2-alpine3.12 2 | 3 | RUN set -xe \ 4 | && apk add --no-cache libstdc++ sqlite-libs \ 5 | && apk add --no-cache --virtual .build-deps build-base sqlite-dev \ 6 | && gem install mailcatcher -v 0.7.1 -N \ 7 | && apk del .build-deps 8 | 9 | ENTRYPOINT ["mailcatcher", "--no-quit", "--smtp-ip=0.0.0.0", "--http-ip=0.0.0.0", "--foreground"] 10 | -------------------------------------------------------------------------------- /test/fixtures/testapp/src/controller/MailController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, get, Email, email } from '../../../../../src' 2 | 3 | export default class extends Controller { 4 | @get('/send-mail') 5 | public async sendMail(@email email: Email) { 6 | await email.send({ 7 | template: 'test', 8 | to: 'test-runner@zents.dev', 9 | }) 10 | 11 | return { 12 | success: true, 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Server' 2 | export * from './Cookie' 3 | export * from './Response' 4 | export * from './ResponseHeader' 5 | export * from './ResponseError' 6 | export * from './Request' 7 | export * from './RequestHeader' 8 | export * from './RequestFactory' 9 | export * from './BodyParser' 10 | export * from './requesthandlers' 11 | export * from './validate' 12 | 13 | // export * from './Context' -> use Context interface instead 14 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/cube.svg: -------------------------------------------------------------------------------- 1 | Cube -------------------------------------------------------------------------------- /src/utils/unset.ts: -------------------------------------------------------------------------------- 1 | export function unset(obj: { [key: string]: any }, path: string): void { 2 | if (!obj || !path) { 3 | return 4 | } 5 | 6 | const paths = path.split('.') 7 | 8 | for (let i = 0; i < paths.length - 1; i++) { 9 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 10 | obj = obj[paths[i]] 11 | 12 | if (typeof obj === 'undefined') { 13 | return 14 | } 15 | } 16 | 17 | delete obj[paths.pop()] 18 | } 19 | -------------------------------------------------------------------------------- /test/decorators/controller.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { REFLECT_METADATA } from '../../src/types/enums' 4 | import { controller } from '../../src/decorators/controller' 5 | 6 | describe('Controller decorator', () => { 7 | it('should set a controller key', () => { 8 | @controller('Foo') 9 | class Test {} 10 | 11 | const key = Reflect.getMetadata(REFLECT_METADATA.CONTROLLER_KEY, Test) 12 | expect(key).toBe('Foo') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/index.ts: -------------------------------------------------------------------------------- 1 | export { AbstractAction } from './AbstractAction' 2 | export { ConnectionAction } from './ConnectionAction' 3 | export { DependenciesAction } from './DependenciesAction' 4 | export { EntityManagerAction } from './EntityManagerAction' 5 | export { RepositoryAction } from './RepositoryAction' 6 | export { RedisAction } from './RedisAction' 7 | export { SecurityProviderAction } from './SecurityProviderAction' 8 | export { SessionAction } from './SessionAction' 9 | -------------------------------------------------------------------------------- /src/utils/getContentType.ts: -------------------------------------------------------------------------------- 1 | import { contentType } from 'mime-types' 2 | 3 | const cache = new Map() 4 | 5 | export const getContentType = function (filenameOrExt: string): string | false { 6 | if (cache.has(filenameOrExt)) { 7 | return cache.get(filenameOrExt) 8 | } 9 | 10 | const mimeType = contentType(filenameOrExt) 11 | 12 | if (!mimeType) { 13 | return false 14 | } 15 | 16 | cache.set(filenameOrExt, mimeType) 17 | 18 | return mimeType 19 | } 20 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/services.svg: -------------------------------------------------------------------------------- 1 | Medkit -------------------------------------------------------------------------------- /test/fixtures/testapp/src/controller/DependencyInjectionController.ts: -------------------------------------------------------------------------------- 1 | import { Controller, inject } from '../../../../../src' 2 | 3 | import DepdendencyInjectionService from '../service/DepdendencyInjectionService' 4 | 5 | export default class extends Controller { 6 | @inject 7 | public injectedService: DepdendencyInjectionService 8 | 9 | public async example() { 10 | return { 11 | foo: 'bar', 12 | } 13 | } 14 | public valueFromInjectedService() { 15 | return this.injectedService.example() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgresql: 5 | container_name: postgresql 6 | build: 7 | context: docker/services/postgresql 8 | dockerfile: Dockerfile 9 | ports: 10 | - 54321:5432 11 | environment: 12 | POSTGRES_USER: test 13 | POSTGRES_PASSWORD: test 14 | POSTGRES_DB: test 15 | 16 | mailcatcher: 17 | container_name: mailcatcher 18 | build: 19 | context: docker/services/mailcatcher 20 | dockerfile: Dockerfile 21 | ports: 22 | - 1080:1080 23 | - 1025:1025 24 | -------------------------------------------------------------------------------- /docs/.vuepress/styles/palette.styl: -------------------------------------------------------------------------------- 1 | // https://v1.vuepress.vuejs.org/config/#palette-styl 2 | // https://github.com/tolking/vuepress-theme-default-prefers-color-scheme/blob/master/styles/palette.styl 3 | 4 | $accentColor = #6970e8 5 | $accentDarkColor = $accentColor 6 | 7 | $secondaryColor = rgb(126, 87, 194) 8 | 9 | $textColor = #322c50 10 | $textDarkColor = #F5F5F5 11 | 12 | $codeBgLightColor = $codeBgColor 13 | $lineNumbersLightColor = $lineNumbersColor 14 | $languageTextLightColor = $languageTextColor 15 | $preTextLightColor = $preTextColor 16 | $contentWidth = 920px 17 | 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "outDir": "./lib/", 5 | "target": "es2019", 6 | "module": "commonjs", 7 | "lib": ["es2020"], 8 | "alwaysStrict": true, 9 | "noImplicitAny": true, 10 | "esModuleInterop": true, 11 | "declaration": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "incremental": true, 16 | "sourceMap": true 17 | }, 18 | "include": ["src/**/*", ".eslintrc.js"], 19 | "exclude": ["node_modules", "docs", "test"] 20 | } 21 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/RedisAction.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAction } from './AbstractAction' 2 | import { InjectModuleInstance } from '../../types/interfaces' 3 | import { REFLECT_METADATA } from '../../types/enums' 4 | 5 | export class RedisAction extends AbstractAction { 6 | public run(instance: InjectModuleInstance): void { 7 | if (!Reflect.hasMetadata(REFLECT_METADATA.REDIS_CLIENT, instance)) { 8 | return 9 | } 10 | 11 | const propertyKey = Reflect.getMetadata(REFLECT_METADATA.REDIS_CLIENT, instance) as string 12 | 13 | instance[propertyKey] = this.injector.context.getRedisClient() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/filesystem/validateInstallation.ts: -------------------------------------------------------------------------------- 1 | import { fs } from './FS' 2 | 3 | /** 4 | * Validates that all needed directories are existing inside a ZenTS application. 5 | * This function is called on bootup and will throw an fatal error when a needed directory 6 | * doesn't exist. 7 | */ 8 | export async function validateInstallation(): Promise { 9 | const checkDirs = [fs.resolveZenPath('controller'), fs.resolveZenPath('view')] 10 | 11 | for (const checkDir of checkDirs) { 12 | if (!(await fs.exists(checkDir))) { 13 | throw new Error(`Fatal Error: Dir "${checkDir}" doesn't exists!`) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/decorators/inject.ts: -------------------------------------------------------------------------------- 1 | import type { Class } from 'type-fest' 2 | import type { ModuleDependency } from '../types/interfaces' 3 | import { REFLECT_METADATA } from '../types/enums' 4 | 5 | export function inject(target: any, propertyKey: string): void { 6 | const dependency: Class = Reflect.getMetadata('design:type', target, propertyKey) as Class 7 | const dependencies: ModuleDependency[] = 8 | (Reflect.getMetadata(REFLECT_METADATA.DEPENDENCIES, target) as ModuleDependency[]) ?? [] 9 | dependencies.push({ propertyKey, dependency }) 10 | 11 | Reflect.defineMetadata(REFLECT_METADATA.DEPENDENCIES, dependencies, target) 12 | } 13 | -------------------------------------------------------------------------------- /test/email/email.test.ts: -------------------------------------------------------------------------------- 1 | import type { ZenApp } from '../../src/core/ZenApp' 2 | import { mockZenApp } from '../mock/mockZenApp' 3 | import supertest from 'supertest' 4 | 5 | let app: ZenApp 6 | 7 | describe('Email', () => { 8 | beforeAll(async () => { 9 | app = await mockZenApp('./test/fixtures/testapp/') 10 | }) 11 | 12 | afterAll(() => { 13 | app.destroy() 14 | }) 15 | 16 | it('is send correctly', async () => { 17 | await supertest(app.nodeServer) 18 | .get('/send-mail') 19 | .expect('Content-Type', /json/) 20 | .expect(200) 21 | .expect({ 22 | success: true, 23 | }) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/template.svg: -------------------------------------------------------------------------------- 1 | Color Palette -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/EntityManagerAction.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAction } from './AbstractAction' 2 | import type { InjectModuleInstance } from '../../types/interfaces' 3 | import { REFLECT_METADATA } from '../../types/enums' 4 | 5 | export class EntityManagerAction extends AbstractAction { 6 | public run(instance: InjectModuleInstance): void { 7 | if (!Reflect.hasMetadata(REFLECT_METADATA.DATABASE_EM, instance)) { 8 | return 9 | } 10 | 11 | const propertyKey = Reflect.getMetadata(REFLECT_METADATA.DATABASE_EM, instance) as string 12 | 13 | instance[propertyKey] = this.injector.context.getConnection().manager 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/dependency_injection.svg: -------------------------------------------------------------------------------- 1 | Code Working -------------------------------------------------------------------------------- /test/helper/loadFixtureTestAppConfig.ts: -------------------------------------------------------------------------------- 1 | import { getFixtureDir } from './getFixtureDir' 2 | import { join } from 'path' 3 | import { loadConfig } from '../../src/config/config' 4 | 5 | export async function loadFixtureTestAppConfig(force: boolean = true): Promise { 6 | process.env.NODE_ENV = 'development' 7 | await loadConfig( 8 | { 9 | paths: { 10 | base: { 11 | src: join(process.cwd(), '/test/fixtures/testapp/src'), 12 | dist: join(process.cwd(), '/test/fixtures/testapp/dist'), 13 | }, 14 | }, 15 | }, 16 | getFixtureDir('config'), 17 | force, 18 | ) 19 | process.env.NODE_ENV = 'test' 20 | } 21 | -------------------------------------------------------------------------------- /test/filesystem/validateInstallation.test.ts: -------------------------------------------------------------------------------- 1 | import { validateInstallation } from '../../src/filesystem/validateInstallation' 2 | import { loadFixtureTestAppConfig } from '../helper/loadFixtureTestAppConfig' 3 | 4 | describe('validateInstallation', () => { 5 | it("throw an error if installation isn't valid", async () => { 6 | await expect(validateInstallation()).rejects.toThrow() 7 | }) 8 | it('validates a valid installation correctly', async () => { 9 | await loadFixtureTestAppConfig() 10 | process.env.NODE_ENV = 'development' 11 | await expect(validateInstallation()).resolves.toBeUndefined() 12 | process.env.NODE_ENV = 'test' 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/filesystem/readDirRecursiveGenerator.ts: -------------------------------------------------------------------------------- 1 | import { promises } from 'fs' 2 | import { resolve } from 'path' 3 | 4 | /** 5 | * Generator function to read the content of a directory recursively. 6 | * 7 | * @param dir A absolute path to a folder 8 | */ 9 | export async function* readDirRecursive(dir: string): AsyncGenerator { 10 | const dirContent = await promises.readdir(dir, { 11 | withFileTypes: true, 12 | }) 13 | 14 | for (const content of dirContent) { 15 | const file = resolve(dir, content.name) 16 | 17 | if (content.isDirectory()) { 18 | yield* readDirRecursive(file) 19 | } else { 20 | yield file 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/autoloading.svg: -------------------------------------------------------------------------------- 1 | Speedometer -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/ConnectionAction.ts: -------------------------------------------------------------------------------- 1 | import { AbstractAction } from './AbstractAction' 2 | import { InjectModuleInstance } from '../../types/interfaces' 3 | import { REFLECT_METADATA } from '../../types/enums' 4 | 5 | export class ConnectionAction extends AbstractAction { 6 | public run(instance: InjectModuleInstance): void { 7 | if (!Reflect.hasMetadata(REFLECT_METADATA.DATABASE_CONNECTION, instance)) { 8 | return 9 | } 10 | 11 | const propertyKey = Reflect.getMetadata( 12 | REFLECT_METADATA.DATABASE_CONNECTION, 13 | instance, 14 | ) as string 15 | 16 | instance[propertyKey] = this.injector.context.getConnection() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/DependenciesAction.ts: -------------------------------------------------------------------------------- 1 | import type { InjectModuleInstance, ModuleDependency } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import { REFLECT_METADATA } from '../../types/enums' 5 | 6 | export class DependenciesAction extends AbstractAction { 7 | public run(instance: InjectModuleInstance): void { 8 | const dependencies = 9 | (Reflect.getMetadata(REFLECT_METADATA.DEPENDENCIES, instance) as ModuleDependency[]) ?? [] 10 | 11 | for (const { dependency, propertyKey } of dependencies) { 12 | instance[propertyKey] = this.injector.inject(dependency, []) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/twitter.svg: -------------------------------------------------------------------------------- 1 | Logo Twitter -------------------------------------------------------------------------------- /src/security/Session.ts: -------------------------------------------------------------------------------- 1 | import type { SessionStore } from './SessionStore' 2 | import type { SessionStoreAdapter } from '../types/interfaces' 3 | 4 | export class Session { 5 | constructor( 6 | public id: string, 7 | public user: any | null, 8 | public data: SessionStore, 9 | public adapter: SessionStoreAdapter, 10 | public provider: string, 11 | ) {} 12 | 13 | public isAuth(): boolean { 14 | return this.user !== null 15 | } 16 | 17 | public async destroy(): Promise { 18 | await this.adapter.remove(this.id) 19 | this.id = null 20 | this.user = null 21 | this.data = null 22 | this.provider = null 23 | this.adapter = null 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/encodeUrl.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Source: https://github.com/pillarjs/encodeurl/blob/master/index.js 3 | Copyright(c) 2016 Douglas Christopher Wilson 4 | MIT Licensed 5 | */ 6 | 7 | const ENCODE_CHARS_REGEXP = /(?:[^\x21\x25\x26-\x3B\x3D\x3F-\x5B\x5D\x5F\x61-\x7A\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g 8 | const UNMATCHED_SURROGATE_PAIR_REGEXP = /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g 9 | const UNMATCHED_SURROGATE_PAIR_REPLACE = '$1\uFFFD$2' 10 | 11 | export function encodeUrl(url: string): string { 12 | return url 13 | .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) 14 | .replace(ENCODE_CHARS_REGEXP, encodeURI) 15 | } 16 | -------------------------------------------------------------------------------- /src/security/strategies/CookieSecurityStrategy.ts: -------------------------------------------------------------------------------- 1 | import type { Context, SecurityStrategy } from '../../types/interfaces' 2 | 3 | import { config } from '../../config/config' 4 | 5 | export class CookieSecurityStrategy implements SecurityStrategy { 6 | get cookieKey(): string { 7 | return config.security?.cookieKey ?? 'zenapp_jwt' 8 | } 9 | 10 | public hasToken(context: Context): boolean { 11 | return context.cookie.has(this.cookieKey) 12 | } 13 | 14 | public getToken(context: Context): string { 15 | return context.cookie.get(this.cookieKey) 16 | } 17 | 18 | public setToken(context: Context, token: string): void { 19 | context.cookie.set(this.cookieKey, token) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/EmailAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import { REFLECT_METADATA } from '../../types/enums' 5 | 6 | export class EmailAction extends AbstractAction { 7 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter { 8 | if (!Reflect.hasMetadata(REFLECT_METADATA.EMAIL, instance, method)) { 9 | return 10 | } 11 | 12 | const metadata = Reflect.getMetadata(REFLECT_METADATA.EMAIL, instance, method) as number 13 | 14 | return { 15 | index: metadata, 16 | value: this.injector.context.getEmailFactory(), 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/controller/Controller.ts: -------------------------------------------------------------------------------- 1 | import type { Environment } from '../template/Environment' 2 | import { TemplateResponse } from '../template/TemplateResponse' 3 | 4 | export abstract class Controller { 5 | constructor(protected templateEnvironment: Environment) {} 6 | 7 | protected async render( 8 | key: string, 9 | context?: Record, 10 | ): Promise { 11 | return new Promise((resolve, reject) => { 12 | this.templateEnvironment.render(key, context, (err, html): void => { 13 | if (err) { 14 | return reject(err) 15 | } 16 | 17 | const templateResponse = new TemplateResponse(html) 18 | 19 | return resolve(templateResponse) 20 | }) 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | export { config } from './config/' 4 | export { Controller } from './controller/' 5 | export { zen, ZenApp } from './core/' 6 | export { createConnection, createRedisClient } from './database/' 7 | export * from './decorators/' 8 | export { fs } from './filesystem/' 9 | export { Request, Response, ResponseError, ResponseHeader, Server, validate } from './http/' 10 | export { log } from './log/' 11 | export { SecurityProviderOptions, SecurityProvider } from './security/' 12 | export { 13 | Context, 14 | Email, 15 | TemplateFilter, 16 | QueryString, 17 | MailOptions, 18 | InjectedConnection, 19 | InjectedEntityManager, 20 | InjectedRepository, 21 | RedisClient, 22 | Session, 23 | } from './types/' 24 | -------------------------------------------------------------------------------- /test/fixtures/testapp/src/controller/ResponseController.ts: -------------------------------------------------------------------------------- 1 | import { body, Controller, get, post, put, req, Request } from '../../../../../src' 2 | 3 | export default class extends Controller { 4 | @get('/ping') 5 | public ping() { 6 | return { 7 | answer: 'pong', 8 | } 9 | } 10 | 11 | @get('/json-object') 12 | public jsonObject() { 13 | return { 14 | foo: 'bar', 15 | baz: 'battzz', 16 | } 17 | } 18 | 19 | @get('/json-array') 20 | public jsonArray() { 21 | return ['foo', 'bar', 'baz'] 22 | } 23 | 24 | @post('/post-echo') 25 | public postEcho(@body body: any) { 26 | return body 27 | } 28 | 29 | @put('/put-echo') 30 | public putEcho(@req req: Request) { 31 | return req.body 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/leaf.svg: -------------------------------------------------------------------------------- 1 | Leaf -------------------------------------------------------------------------------- /src/security/strategies/HeaderSecurityStrategy.ts: -------------------------------------------------------------------------------- 1 | import type { Context, SecurityStrategy } from '../../types/interfaces' 2 | 3 | export class HeaderSecurityStrategy implements SecurityStrategy { 4 | public hasToken(context: Context): boolean { 5 | return context.request.header.has('authorization') 6 | } 7 | 8 | public getToken(context: Context): string | false { 9 | const headerToken = context.request.header.get('authorization') 10 | const splitted = headerToken.trim().split(' ') 11 | 12 | if (splitted.length < 2 || splitted[0].toLowerCase() !== 'bearer') { 13 | return false 14 | } 15 | 16 | return splitted[1] 17 | } 18 | 19 | public setToken(context: Context, token: string): void { 20 | context.request.header.set('token', token) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/common/autoloader_registry.test.ts: -------------------------------------------------------------------------------- 1 | import { AutoLoader } from '../../src/core/AutoLoader' 2 | import { loadFixtureTestAppConfig } from '../helper/loadFixtureTestAppConfig' 3 | 4 | describe('AutoLoader and Registry', () => { 5 | beforeAll(async () => { 6 | await loadFixtureTestAppConfig() 7 | process.env.NODE_ENV = 'development' 8 | }) 9 | 10 | afterAll(() => { 11 | process.env.NODE_ENV = 'test' 12 | }) 13 | 14 | it('should let the AutoLoader create a Registry', async () => { 15 | const autoLoader = new AutoLoader() 16 | const registry = await autoLoader.createRegistry() 17 | 18 | expect(registry.getControllers().size).toBeGreaterThanOrEqual(3) 19 | expect(registry.getServices().size).toBeGreaterThanOrEqual(1) 20 | expect(registry.getConnection()).toBeUndefined() 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/database/createConnection.ts: -------------------------------------------------------------------------------- 1 | import type { Connection, ConnectionOptions } from 'typeorm' 2 | 3 | import { config } from '../config/config' 4 | import { fs } from '../filesystem/FS' 5 | import { join } from 'path' 6 | import { createConnection as typeormCreateConnection } from 'typeorm' 7 | 8 | export async function createConnection(): Promise { 9 | if (!config.database.enable) { 10 | return null 11 | } 12 | 13 | let connection: Connection 14 | 15 | try { 16 | const options = Object.assign({}, config.database, { 17 | entities: [join(fs.resolveZenPath('entity'), `*${fs.resolveZenFileExtension()}`)], 18 | }) as ConnectionOptions 19 | 20 | connection = await typeormCreateConnection(options) 21 | } catch (e) { 22 | throw new Error(e) 23 | } 24 | 25 | return connection 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/get.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | 4 | // Source: https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_get 5 | export function get( 6 | obj: Record, 7 | path: string, 8 | defaultValue: T = undefined, 9 | ): T { 10 | const travel = (regexp: RegExp): T => { 11 | return String.prototype.split 12 | .call(path, regexp) 13 | .filter(Boolean) 14 | .reduce( 15 | (res: Record, key: string) => 16 | res !== null && res !== undefined ? res[key] : res, 17 | obj, 18 | ) as T 19 | } 20 | 21 | const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/) 22 | 23 | return result === undefined || result === obj ? defaultValue : result 24 | } 25 | -------------------------------------------------------------------------------- /src/database/DatabaseContainer.ts: -------------------------------------------------------------------------------- 1 | import type { Connection } from 'typeorm' 2 | import { DB_TYPE } from '../types/enums' 3 | import type { DatabaseObjectType } from '../types/types' 4 | import type { Redis } from 'ioredis' 5 | 6 | export class DatabaseContainer { 7 | protected container = new Map() 8 | 9 | constructor(dbConnection: Connection | null, redisClient: Redis | null) { 10 | if (dbConnection) { 11 | this.container.set(DB_TYPE.ORM, dbConnection) 12 | } 13 | 14 | if (redisClient) { 15 | this.container.set(DB_TYPE.REDIS, redisClient) 16 | } 17 | } 18 | 19 | public get(type: T): DatabaseObjectType { 20 | return this.container.get(type) as DatabaseObjectType 21 | } 22 | 23 | public has(type: DB_TYPE): boolean { 24 | return this.container.has(type) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/service/ServiceLoader.ts: -------------------------------------------------------------------------------- 1 | import { AbstractZenFileLoader } from '../filesystem/AbstractZenFileLoader' 2 | import type { Services } from '../types/types' 3 | import { fs } from '../filesystem' 4 | 5 | export class ServiceLoader extends AbstractZenFileLoader { 6 | public async load(): Promise { 7 | const services = new Map() as Services 8 | 9 | if (!(await fs.exists(fs.resolveZenPath('service')))) { 10 | return services 11 | } 12 | 13 | const filePaths = (await fs.readDir(fs.resolveZenPath('service'))).filter((filePath: string) => 14 | filePath.toLowerCase().endsWith(fs.resolveZenFileExtension('service')), 15 | ) 16 | 17 | for (const filePath of filePaths) { 18 | const { key, module } = await this.loadModule(filePath) 19 | 20 | services.set(key, module) 21 | } 22 | 23 | return services 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/github.svg: -------------------------------------------------------------------------------- 1 | Logo Github -------------------------------------------------------------------------------- /docs/.vuepress/public/favicon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/rocket.svg: -------------------------------------------------------------------------------- 1 | Rocket -------------------------------------------------------------------------------- /src/database/EntityLoader.ts: -------------------------------------------------------------------------------- 1 | import { AbstractZenFileLoader } from '../filesystem/AbstractZenFileLoader' 2 | import type { Entities } from '../types/types' 3 | import { config } from '../config/config' 4 | import { fs } from '../filesystem/FS' 5 | import { log } from '../log/logger' 6 | 7 | export class EntityLoader extends AbstractZenFileLoader { 8 | public async load(): Promise { 9 | const entities = new Map() as Entities 10 | 11 | if (!config.database?.enable) { 12 | return entities 13 | } 14 | 15 | const filePaths = await fs.readDir(fs.resolveZenPath('entity'), false) 16 | 17 | for (const filePath of filePaths) { 18 | const { key, module } = await this.loadModule(filePath) 19 | 20 | if (entities.has(key)) { 21 | log.warn(`Entity with key "${key}" is already registered!`) 22 | 23 | continue 24 | } 25 | 26 | entities.set(key, module) 27 | } 28 | 29 | return entities 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/security/SessionStore.ts: -------------------------------------------------------------------------------- 1 | import type { SessionStoreAdapter } from '../types/interfaces' 2 | import { get } from '../utils/get' 3 | import { set } from '../utils/set' 4 | import { unset } from '../utils/unset' 5 | 6 | export class SessionStore { 7 | private isModified = false 8 | 9 | constructor( 10 | public sessionId: string, 11 | protected data: Record, 12 | protected adapter: SessionStoreAdapter, 13 | ) {} 14 | 15 | public async save(): Promise { 16 | if (!this.isModified) { 17 | return 18 | } 19 | 20 | await this.adapter.persist(this.sessionId, this.data) 21 | this.isModified = false 22 | } 23 | 24 | public get(path: string): T { 25 | return get(this.data, path) 26 | } 27 | 28 | public set(path: string, value: any): void { 29 | set(this.data, path, value) 30 | this.isModified = true 31 | } 32 | 33 | public remove(path: string): void { 34 | unset(this.data, path) 35 | this.isModified = true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/AllContextAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import type { Context } from '../../http/Context' 5 | import type { Injector } from '../Injector' 6 | import { REFLECT_METADATA } from '../../types' 7 | 8 | export class AllContextAction extends AbstractAction { 9 | protected readonly context: Context 10 | 11 | constructor(injector: Injector, context: Context) { 12 | super(injector) 13 | 14 | this.context = context 15 | } 16 | 17 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter { 18 | if (!Reflect.hasMetadata(REFLECT_METADATA.CONTEXT_ALL, instance, method)) { 19 | return 20 | } 21 | 22 | const metadata = Reflect.getMetadata(REFLECT_METADATA.CONTEXT_ALL, instance, method) as number 23 | 24 | return { 25 | index: metadata, 26 | value: this.context, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/BodyContextAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import type { Context } from '../../http/Context' 5 | import type { Injector } from '../Injector' 6 | import { REFLECT_METADATA } from '../../types' 7 | 8 | export class BodyContextAction extends AbstractAction { 9 | protected readonly context: Context 10 | 11 | constructor(injector: Injector, context: Context) { 12 | super(injector) 13 | 14 | this.context = context 15 | } 16 | 17 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter { 18 | if (!Reflect.hasMetadata(REFLECT_METADATA.CONTEXT_BODY, instance, method)) { 19 | return 20 | } 21 | 22 | const metadata = Reflect.getMetadata(REFLECT_METADATA.CONTEXT_BODY, instance, method) as number 23 | 24 | return { 25 | index: metadata, 26 | value: this.context.body, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/ErrorContextAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import type { Context } from '../../http/Context' 5 | import type { Injector } from '../Injector' 6 | import { REFLECT_METADATA } from '../../types' 7 | 8 | export class ErrorContextAction extends AbstractAction { 9 | protected readonly context: Context 10 | 11 | constructor(injector: Injector, context: Context) { 12 | super(injector) 13 | 14 | this.context = context 15 | } 16 | 17 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter { 18 | if (!Reflect.hasMetadata(REFLECT_METADATA.CONTEXT_ERROR, instance, method)) { 19 | return 20 | } 21 | 22 | const metadata = Reflect.getMetadata(REFLECT_METADATA.CONTEXT_ERROR, instance, method) as number 23 | 24 | return { 25 | index: metadata, 26 | value: this.context.error, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/QueryContextAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import type { Context } from '../../http/Context' 5 | import type { Injector } from '../Injector' 6 | import { REFLECT_METADATA } from '../../types' 7 | 8 | export class QueryContextAction extends AbstractAction { 9 | protected readonly context: Context 10 | 11 | constructor(injector: Injector, context: Context) { 12 | super(injector) 13 | 14 | this.context = context 15 | } 16 | 17 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter { 18 | if (!Reflect.hasMetadata(REFLECT_METADATA.CONTEXT_QUERY, instance, method)) { 19 | return 20 | } 21 | 22 | const metadata = Reflect.getMetadata(REFLECT_METADATA.CONTEXT_QUERY, instance, method) as number 23 | 24 | return { 25 | index: metadata, 26 | value: this.context.query, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/controller_routing.svg: -------------------------------------------------------------------------------- 1 | Layers -------------------------------------------------------------------------------- /test/fixtures/testapp/src/controller/DatabaseController.ts: -------------------------------------------------------------------------------- 1 | import type { Repository, EntityManager, Connection } from 'typeorm' 2 | import { Controller, get, repository, entityManager, connection } from '../../../../../src' 3 | 4 | import { Person } from '../entity/Person' 5 | 6 | export default class extends Controller { 7 | @entityManager 8 | private em: EntityManager 9 | 10 | @connection 11 | private con: Connection 12 | 13 | @get('/database-test/persons') 14 | public async persons(@repository(Person) repo: Repository) { 15 | const products = await repo.find() 16 | 17 | return { 18 | products, 19 | } 20 | } 21 | 22 | @get('/database-test/persons2') 23 | public async persons2() { 24 | const products = await this.em.getRepository(Person).find() 25 | 26 | return { 27 | products, 28 | } 29 | } 30 | 31 | @get('/database-test/persons3') 32 | public async persons3() { 33 | const products = await this.con.getRepository(Person).find() 34 | 35 | return { 36 | products, 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/CookieContextAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import type { Context } from '../../http/Context' 5 | import type { Injector } from '../Injector' 6 | import { REFLECT_METADATA } from '../../types' 7 | 8 | export class CookieContextAction extends AbstractAction { 9 | protected readonly context: Context 10 | 11 | constructor(injector: Injector, context: Context) { 12 | super(injector) 13 | 14 | this.context = context 15 | } 16 | 17 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter { 18 | if (!Reflect.hasMetadata(REFLECT_METADATA.CONTEXT_COOKIE, instance, method)) { 19 | return 20 | } 21 | 22 | const metadata = Reflect.getMetadata( 23 | REFLECT_METADATA.CONTEXT_COOKIE, 24 | instance, 25 | method, 26 | ) as number 27 | 28 | return { 29 | index: metadata, 30 | value: this.context.cookie, 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/ParamsContextAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import type { Context } from '../../http/Context' 5 | import type { Injector } from '../Injector' 6 | import { REFLECT_METADATA } from '../../types' 7 | 8 | export class ParamsContextAction extends AbstractAction { 9 | protected readonly context: Context 10 | 11 | constructor(injector: Injector, context: Context) { 12 | super(injector) 13 | 14 | this.context = context 15 | } 16 | 17 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter { 18 | if (!Reflect.hasMetadata(REFLECT_METADATA.CONTEXT_PARAMS, instance, method)) { 19 | return 20 | } 21 | 22 | const metadata = Reflect.getMetadata( 23 | REFLECT_METADATA.CONTEXT_PARAMS, 24 | instance, 25 | method, 26 | ) as number 27 | 28 | return { 29 | index: metadata, 30 | value: this.context.params, 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/mock/mockZenApp.ts: -------------------------------------------------------------------------------- 1 | import type { ZenApp } from '../../src/core/ZenApp' 2 | import { join } from 'path' 3 | import { zen } from '../../src/core/Zen' 4 | 5 | export async function mockZenApp(basePath: string): Promise { 6 | process.env.NODE_ENV = 'development' 7 | const app = await zen({ 8 | web: { 9 | port: Math.floor(Math.random() * 30000) + 5000, 10 | }, 11 | database: { 12 | enable: true, 13 | type: 'postgres', 14 | host: 'localhost', 15 | port: 54321, 16 | username: 'test', 17 | password: 'test', 18 | database: 'test', 19 | }, 20 | email: { 21 | enable: true, 22 | engine: 'mjml', 23 | host: 'localhost', 24 | port: 1025, 25 | mailOptions: { 26 | from: 'test@zents.dev', 27 | }, 28 | }, 29 | paths: { 30 | base: { 31 | src: join(process.cwd(), basePath, 'src'), 32 | dist: join(process.cwd(), basePath, 'dist'), 33 | }, 34 | public: false, 35 | }, 36 | }) 37 | process.env.NODE_ENV = 'test' 38 | 39 | return app 40 | } 41 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/RequestContextAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import type { Context } from '../../http/Context' 5 | import type { Injector } from '../Injector' 6 | import { REFLECT_METADATA } from '../../types' 7 | 8 | export class RequestContextAction extends AbstractAction { 9 | protected readonly context: Context 10 | 11 | constructor(injector: Injector, context: Context) { 12 | super(injector) 13 | 14 | this.context = context 15 | } 16 | 17 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter { 18 | if (!Reflect.hasMetadata(REFLECT_METADATA.CONTEXT_REQUEST, instance, method)) { 19 | return 20 | } 21 | 22 | const metadata = Reflect.getMetadata( 23 | REFLECT_METADATA.CONTEXT_REQUEST, 24 | instance, 25 | method, 26 | ) as number 27 | 28 | return { 29 | index: metadata, 30 | value: this.context.request, 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/ResponseContextAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import type { Context } from '../../http/Context' 5 | import type { Injector } from '../Injector' 6 | import { REFLECT_METADATA } from '../../types' 7 | 8 | export class ResponseContextAction extends AbstractAction { 9 | protected readonly context: Context 10 | 11 | constructor(injector: Injector, context: Context) { 12 | super(injector) 13 | 14 | this.context = context 15 | } 16 | 17 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter { 18 | if (!Reflect.hasMetadata(REFLECT_METADATA.CONTEXT_RESPONSE, instance, method)) { 19 | return 20 | } 21 | 22 | const metadata = Reflect.getMetadata( 23 | REFLECT_METADATA.CONTEXT_RESPONSE, 24 | instance, 25 | method, 26 | ) as number 27 | 28 | return { 29 | index: metadata, 30 | value: this.context.response, 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/service/ServiceFactory.ts: -------------------------------------------------------------------------------- 1 | import type { SecurityProviders, Services } from '../types/types' 2 | 3 | import { AbstractFactory } from '../core/AbstractFactory' 4 | import type { DatabaseContainer } from '../database/DatabaseContainer' 5 | import type { EmailFactory } from '../email/EmailFactory' 6 | import type { SessionFactory } from '../security/SessionFactory' 7 | 8 | export class ServiceFactory extends AbstractFactory { 9 | constructor( 10 | protected services: Services, 11 | emailFactory: EmailFactory, 12 | sessionFactory: SessionFactory, 13 | securityProviders: SecurityProviders, 14 | databaseContainer: DatabaseContainer, 15 | ) { 16 | super() 17 | this.injector = this.buildInjector({ 18 | databaseContainer, 19 | emailFactory, 20 | sessionFactory, 21 | securityProviders, 22 | }) 23 | } 24 | 25 | public build(key: string): T { 26 | const module = this.services.get(key) 27 | 28 | if (!module) { 29 | return null 30 | } 31 | const instance = this.injector.inject(module, []) 32 | 33 | return instance 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/security/SessionStoreAdapterFactory.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseContainer } from '../database/DatabaseContainer' 2 | import { DatabaseSessionStoreAdapter } from './stores/DatabaseSessionStoreAdapter' 3 | import { RedisSessionStoreAdapter } from './stores/RedisSessionStoreAdapter' 4 | import type { SecurityProviderOptions } from './SecurityProviderOptions' 5 | import type { SessionStoreAdapter } from '../types/interfaces' 6 | 7 | export class SessionStoreAdapterFactory { 8 | constructor(protected databaseContainer: DatabaseContainer) {} 9 | 10 | public build(providerOptions: SecurityProviderOptions): SessionStoreAdapter { 11 | let adapter 12 | 13 | switch (providerOptions.storeType) { 14 | case 'redis': 15 | adapter = new RedisSessionStoreAdapter(this.databaseContainer, providerOptions) 16 | break 17 | 18 | case 'database': 19 | adapter = new DatabaseSessionStoreAdapter(this.databaseContainer, providerOptions) 20 | break 21 | 22 | default: 23 | throw new Error(`Invalid session store "${providerOptions.storeType}"`) 24 | } 25 | 26 | return adapter 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/core/AbstractFactory.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseContainer } from '../database/DatabaseContainer' 2 | import type { EmailFactory } from '../email/EmailFactory' 3 | import { Injector } from '../dependencies/Injector' 4 | import { ModuleContext } from '../dependencies/ModuleContext' 5 | import type { SecurityProviders } from '../types/types' 6 | import type { SessionFactory } from '../security/SessionFactory' 7 | 8 | export abstract class AbstractFactory { 9 | protected injector: Injector 10 | 11 | public getInjector(): Injector { 12 | return this.injector 13 | } 14 | 15 | protected buildInjector({ 16 | databaseContainer, 17 | emailFactory, 18 | securityProviders, 19 | sessionFactory, 20 | }: { 21 | databaseContainer: DatabaseContainer 22 | emailFactory: EmailFactory 23 | securityProviders: SecurityProviders 24 | sessionFactory: SessionFactory 25 | }): Injector { 26 | const context = new ModuleContext( 27 | databaseContainer, 28 | emailFactory, 29 | sessionFactory, 30 | securityProviders, 31 | ) 32 | const injector = new Injector(context) 33 | 34 | return injector 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/SecurityProviderAction.ts: -------------------------------------------------------------------------------- 1 | import type { GenericControllerInstance, InjectorFunctionParameter } from '../../types/interfaces' 2 | 3 | import { AbstractAction } from './AbstractAction' 4 | import { REFLECT_METADATA } from '../../types' 5 | import { SecurityProviderReflectionMetadata } from '../../types/interfaces' 6 | 7 | export class SecurityProviderAction extends AbstractAction { 8 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter[] { 9 | if (!Reflect.hasMetadata(REFLECT_METADATA.SECURITY_PROVIDER, instance, method)) { 10 | return [] 11 | } 12 | 13 | const metadata = Reflect.getMetadata( 14 | REFLECT_METADATA.SECURITY_PROVIDER, 15 | instance, 16 | method, 17 | ) as SecurityProviderReflectionMetadata[] 18 | 19 | const parameters = metadata.map((meta) => { 20 | const value = this.injector.context.getSecurityProvider(meta.name) 21 | 22 | if (!value) { 23 | return null 24 | } 25 | 26 | return { 27 | index: meta.index, 28 | value, 29 | } 30 | }) 31 | 32 | return parameters 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2020-present day Sascha Habbes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /docs/.vuepress/components/GuideHeader.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ $page.readingTime.text }} ({{ $page.readingTime.words }} words) 5 | 6 | 7 | 8 | 9 | # Table of contents 10 | 11 | 12 | 13 | 14 | 32 | 33 | 51 | -------------------------------------------------------------------------------- /src/http/RequestHeader.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHeaders, RequestHeadersValue } from '../types/types' 2 | 3 | import type { IncomingMessage } from 'http' 4 | 5 | export class RequestHeader { 6 | protected data: RequestHeaders 7 | constructor(req: IncomingMessage) { 8 | this.data = new Map() as RequestHeaders 9 | 10 | for (const [key, value] of Object.entries(req.headers)) { 11 | this.data.set(key, value) 12 | } 13 | } 14 | 15 | public all(): IterableIterator<[string, RequestHeadersValue]> { 16 | return this.data.entries() 17 | } 18 | public get(key: string): T { 19 | return this.data.get(key.toLowerCase()) as T 20 | } 21 | public has(key: string): boolean { 22 | return this.data.has(key.toLowerCase()) 23 | } 24 | public remove(key: string): void { 25 | this.data.delete(key.toLowerCase()) 26 | } 27 | public set(key: string, value: RequestHeadersValue): void { 28 | this.data.set(key.toLowerCase(), value) 29 | } 30 | public getHost(): string | undefined { 31 | return this.data.get('host') as string | undefined 32 | } 33 | public getAccept(): string | undefined { 34 | return this.data.get('accept') as string | undefined 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/http/BodyParser.ts: -------------------------------------------------------------------------------- 1 | import { IncomingForm } from 'formidable' 2 | import type { IncomingMessage } from 'http' 3 | import type { ParsedBody } from '../types/interfaces' 4 | import { config } from '../config/config' 5 | import { tmpdir } from 'os' 6 | 7 | export class BodyParser extends IncomingForm { 8 | constructor() { 9 | super() 10 | 11 | this.encoding = config.web?.bodyParser?.encoding ?? 'utf-8' 12 | this.maxFields = config.web?.bodyParser?.maxFields ?? 1000 13 | this.maxFieldsSize = config.web?.bodyParser?.maxFieldsSize ?? 20 * 1024 * 1024 14 | this.maxFileSize = config.web?.bodyParser?.maxFileSize ?? 200 * 1024 * 1024 15 | this.keepExtensions = config.web?.bodyParser?.keepExtensions ?? false 16 | this.uploadDir = config.web?.bodyParser?.uploadDir ?? tmpdir() 17 | this.multiples = config.web?.bodyParser?.multiples ?? false 18 | } 19 | public async parse(req: IncomingMessage): Promise { 20 | return new Promise((resolve, reject) => { 21 | super.parse(req, (err, fields, files) => { 22 | if (err) { 23 | return reject(err) 24 | } 25 | 26 | return resolve({ 27 | fields, 28 | files, 29 | }) 30 | }) 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/http/cookie.test.ts: -------------------------------------------------------------------------------- 1 | import { Cookie } from '../../src/http/Cookie' 2 | 3 | describe('Cookie', () => { 4 | it('constructs a existing cookie', () => { 5 | const cookie = new Cookie({ 6 | cookie: 'foo=%22bar%22', 7 | }) 8 | 9 | expect(cookie.has('foo')).toBe(true) 10 | expect(cookie.get('foo')).toBe('bar') 11 | expect(cookie.get('notfoo')).toBeUndefined() 12 | }) 13 | 14 | it('sets a simple cookie value', () => { 15 | const cookie = new Cookie({}) 16 | 17 | cookie.set('foo', 'bar', { 18 | httpOnly: true, 19 | }) 20 | expect(cookie.has('foo')).toBe(true) 21 | expect(cookie.get('foo')).toBe('bar') 22 | expect(cookie.serialize()).toBe('foo=%22bar%22; HttpOnly') 23 | }) 24 | 25 | it('sets a complex cookie value', () => { 26 | const cookie = new Cookie({}) 27 | 28 | cookie.set('foo', { 29 | bar: 'baz', 30 | }) 31 | expect(cookie.has('foo')).toBe(true) 32 | expect(cookie.get('foo')).toStrictEqual({ bar: 'baz' }) 33 | expect(cookie.serialize()).toBe('foo=%7B%22bar%22%3A%22baz%22%7D') 34 | }) 35 | 36 | it("doesn't serialize() unmodified keys", () => { 37 | const cookie = new Cookie({}) 38 | 39 | expect(cookie.serialize()).toBeFalsy() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /test/database/database.test.ts: -------------------------------------------------------------------------------- 1 | import type { ZenApp } from '../../src/core/ZenApp' 2 | import { mockZenApp } from '../mock/mockZenApp' 3 | import supertest from 'supertest' 4 | 5 | let app: ZenApp 6 | 7 | describe('Database', () => { 8 | beforeAll(async () => { 9 | app = await mockZenApp('./test/fixtures/testapp/') 10 | }) 11 | 12 | afterAll(() => { 13 | app.destroy() 14 | }) 15 | 16 | it('returns all person records', async () => { 17 | const response = await supertest(app.nodeServer) 18 | .get('/database-test/persons') 19 | .expect(200) 20 | .expect('Content-Type', /json/) 21 | 22 | expect(response.body).toMatchSnapshot() 23 | }) 24 | 25 | it('can access records via EntityManager', async () => { 26 | const response = await supertest(app.nodeServer) 27 | .get('/database-test/persons2') 28 | .expect(200) 29 | .expect('Content-Type', /json/) 30 | 31 | expect(response.body).toMatchSnapshot() 32 | }) 33 | 34 | it('can access records via connection', async () => { 35 | const response = await supertest(app.nodeServer) 36 | .get('/database-test/persons3') 37 | .expect(200) 38 | .expect('Content-Type', /json/) 39 | 40 | expect(response.body).toMatchSnapshot() 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/database/createRedisClient.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | import { config } from '../config/config' 3 | import { log } from '../log/logger' 4 | 5 | export async function createRedisClient(): Promise { 6 | if (!config.redis?.enable) { 7 | return null 8 | } 9 | 10 | return new Promise((resolve, reject) => { 11 | const client = new Redis( 12 | Object.assign({}, config.redis, { 13 | lazyConnect: false, 14 | enableReadyCheck: true, 15 | }), 16 | ) 17 | 18 | if (config.redis?.log) { 19 | client.on('connect', () => log.success('Redis client connected successfully!')) 20 | client.on('ready', () => log.success('Redis connection is ready to accept commands!')) 21 | client.on('error', (err) => log.error(err)) 22 | client.on('close', () => log.info('Connection to redis server closed.')) 23 | client.on('reconnecting', () => log.info('Redis client reconnecting to server')) 24 | } 25 | 26 | let isReadySend = false 27 | client.once('ready', () => { 28 | isReadySend = true 29 | resolve(client) 30 | }) 31 | client.once('end', () => { 32 | if (!isReadySend) { 33 | reject('Failed to connect to redis server.') 34 | } 35 | }) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/http/requesthandlers/SecurityRequestHandler.ts: -------------------------------------------------------------------------------- 1 | import type { Connection } from 'typeorm' 2 | import type { RequestConfigSecurity } from '../../types/interfaces' 3 | import { SECURITY_ACTION } from '../../types/enums' 4 | import type { SecurityProvider } from '../../security/SecurityProvider' 5 | import type { SecurityRequestContext } from '../../types/types' 6 | 7 | export class SecurityRequestHandler { 8 | protected provider: SecurityProvider 9 | protected action: SECURITY_ACTION 10 | private didRun: boolean = false 11 | 12 | constructor( 13 | protected context: SecurityRequestContext, 14 | protected connection: Connection, 15 | { action, provider }: RequestConfigSecurity, 16 | ) { 17 | this.provider = provider 18 | this.action = action 19 | } 20 | 21 | public async run(): Promise { 22 | if (this.didRun) { 23 | return 24 | } 25 | 26 | this.didRun = true 27 | 28 | switch (this.action) { 29 | case SECURITY_ACTION.LOGIN: 30 | await this.provider.login(this.context) 31 | 32 | break 33 | 34 | case SECURITY_ACTION.LOGOUT: 35 | await this.provider.logout(this.context) 36 | 37 | break 38 | 39 | default: 40 | this.context.error.internal() 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/email/EmailTemplateLoader.ts: -------------------------------------------------------------------------------- 1 | import type { EmailTemplates } from '../types/types' 2 | import { config } from '../config/config' 3 | import { fs } from '../filesystem/FS' 4 | import { parse } from 'path' 5 | import { promises } from 'fs' 6 | 7 | export class EmailTemplateLoader { 8 | public async load(): Promise { 9 | const emailTemplates = new Map() as EmailTemplates 10 | const emailFilesPath = fs.resolveZenPath('email') 11 | 12 | if (!((await fs.exists(emailFilesPath)) || config.email.engine === 'plain')) { 13 | return emailTemplates 14 | } 15 | 16 | const filePaths = await this.loadFiles(emailFilesPath) 17 | 18 | for (const filePath of filePaths) { 19 | const { name } = parse(filePath) 20 | const content = await promises.readFile(filePath, { 21 | encoding: 'utf-8', 22 | }) 23 | 24 | emailTemplates.set(name, content) 25 | } 26 | 27 | return emailTemplates 28 | } 29 | protected async loadFiles(emailFilesPath: string): Promise { 30 | const fileExtension = config.email.engine === 'mjml' ? '.mjml' : `.${config.template.extension}` 31 | 32 | return (await fs.readDir(emailFilesPath)).filter((filePath: string) => 33 | filePath.endsWith(fileExtension), 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/template/Loader.ts: -------------------------------------------------------------------------------- 1 | import type { ILoader, LoaderSource } from 'nunjucks' 2 | import { join, parse, sep } from 'path' 3 | 4 | import type { LoaderTemplates } from '../types/types' 5 | import { config } from '../config/config' 6 | import { fs } from '../filesystem/FS' 7 | import { readFileSync } from 'fs' 8 | 9 | export class Loader implements ILoader { 10 | protected templates: LoaderTemplates = new Map() as LoaderTemplates 11 | constructor(templateFiles: string[]) { 12 | const files = templateFiles.map((filePath) => parse(filePath)) 13 | let basePath = fs.resolveZenPath('view') 14 | 15 | if (basePath.endsWith(sep)) { 16 | basePath = basePath.slice(0, -1) 17 | } 18 | 19 | for (const file of files) { 20 | const filePath = join(file.dir, file.base) 21 | let key = file.dir.replace(basePath, '').substr(1).replace(sep, '/') 22 | key += key.length ? `/${file.name}` : file.name 23 | 24 | this.templates.set(key, { 25 | path: filePath, 26 | src: readFileSync(filePath, { 27 | encoding: config.template.encoding, 28 | }), 29 | noCache: config.template.noCache ?? false, 30 | }) 31 | } 32 | } 33 | public getSource(key: string): LoaderSource { 34 | return this.templates.get(key) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/decorators/inject.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { REFLECT_METADATA } from '../../src/types/enums' 4 | import { inject } from '../../src/decorators/inject' 5 | 6 | describe('Inject decorator', () => { 7 | it('should inject services', () => { 8 | class MyService1 { 9 | public foo() { 10 | return 'bar' 11 | } 12 | } 13 | class MyService2 { 14 | public bar() { 15 | return 'foo' 16 | } 17 | } 18 | class MyController { 19 | @inject 20 | public myService1: MyService1 21 | @inject 22 | public myService2: MyService2 23 | 24 | public test() { 25 | return `${this.myService1.foo()}-${this.myService2.bar()}` 26 | } 27 | } 28 | 29 | const dependencies = Reflect.getMetadata(REFLECT_METADATA.DEPENDENCIES, MyController.prototype) 30 | 31 | expect(dependencies.length).toBe(2) 32 | expect(dependencies[0].propertyKey).toBe('myService1') 33 | expect(dependencies[0].dependency).toBeInstanceOf(Function) 34 | expect(dependencies[1].propertyKey).toBe('myService2') 35 | expect(dependencies[1].dependency).toBeInstanceOf(Function) 36 | 37 | const initializedDependency = new dependencies[0].dependency() 38 | 39 | expect(initializedDependency).toBeInstanceOf(MyService1) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/http/RequestFactory.ts: -------------------------------------------------------------------------------- 1 | import type { RequestConfig, Route, SecurityRequestContext } from '../types/' 2 | 3 | import type { Context } from './Context' 4 | import { ControllerRequestHandler } from './requesthandlers/ControllerRequestHandler' 5 | import { REQUEST_TYPE } from '../types/enums' 6 | import type { Registry } from '../core/Registry' 7 | import { SecurityRequestHandler } from './requesthandlers/SecurityRequestHandler' 8 | 9 | export class RequestFactory { 10 | constructor(protected registry: Registry) {} 11 | 12 | public build( 13 | context: Context, 14 | config: RequestConfig, 15 | route: Route, 16 | ): ControllerRequestHandler | SecurityRequestHandler { 17 | let handler 18 | switch (config.type) { 19 | case REQUEST_TYPE.CONTROLLER: 20 | handler = new ControllerRequestHandler( 21 | context, 22 | this.registry.factories.controller, 23 | config.controllerKey, 24 | config.loadedUser, 25 | route, 26 | ) 27 | 28 | break 29 | 30 | case REQUEST_TYPE.SECURITY: 31 | handler = new SecurityRequestHandler( 32 | context as SecurityRequestContext, 33 | this.registry.getConnection(), 34 | config, 35 | ) 36 | 37 | break 38 | } 39 | 40 | return handler 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/security/strategies/HybridSecurityStrategy.ts: -------------------------------------------------------------------------------- 1 | import type { Context, SecurityStrategy } from '../../types/interfaces' 2 | 3 | import { CookieSecurityStrategy } from './CookieSecurityStrategy' 4 | import { HeaderSecurityStrategy } from './HeaderSecurityStrategy' 5 | 6 | export class HybridSecurityStrategy implements SecurityStrategy { 7 | protected headerSecurityStrategy: HeaderSecurityStrategy 8 | protected cookieSecurityStrategy: CookieSecurityStrategy 9 | 10 | constructor() { 11 | this.headerSecurityStrategy = new HeaderSecurityStrategy() 12 | this.cookieSecurityStrategy = new CookieSecurityStrategy() 13 | } 14 | 15 | public hasToken(context: Context): boolean { 16 | return ( 17 | this.headerSecurityStrategy.hasToken(context) || this.cookieSecurityStrategy.hasToken(context) 18 | ) 19 | } 20 | 21 | public getToken(context: Context): string | false { 22 | const headerResult = this.headerSecurityStrategy.getToken(context) 23 | 24 | if (typeof headerResult === 'string') { 25 | return headerResult 26 | } 27 | 28 | return this.cookieSecurityStrategy.getToken(context) 29 | } 30 | 31 | public setToken(context: Context, token: string): void { 32 | this.headerSecurityStrategy.setToken(context, token) 33 | this.cookieSecurityStrategy.setToken(context, token) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/orm.svg: -------------------------------------------------------------------------------- 1 | Server -------------------------------------------------------------------------------- /src/dependencies/ModuleContext.ts: -------------------------------------------------------------------------------- 1 | import type { Connection } from 'typeorm' 2 | import { DB_TYPE } from '../types' 3 | import type { DatabaseContainer } from '../database/DatabaseContainer' 4 | import type { EmailFactory } from '../email/EmailFactory' 5 | import type { Redis } from 'ioredis' 6 | import type { SecurityProvider } from '../security/SecurityProvider' 7 | import type { SecurityProviders } from '../types/types' 8 | import type { SessionFactory } from '../security/SessionFactory' 9 | 10 | export class ModuleContext { 11 | constructor( 12 | protected readonly databaseContainer: DatabaseContainer, 13 | protected readonly emailFactory: EmailFactory, 14 | protected readonly sessionFactory: SessionFactory, 15 | protected readonly securityProviders: SecurityProviders, 16 | ) {} 17 | public getConnection(): Connection { 18 | return this.databaseContainer.get(DB_TYPE.ORM) 19 | } 20 | public hasConnection(): boolean { 21 | return this.databaseContainer.has(DB_TYPE.ORM) 22 | } 23 | public getRedisClient(): Redis { 24 | return this.databaseContainer.get(DB_TYPE.REDIS) 25 | } 26 | public getSecurityProvider(key: string): SecurityProvider { 27 | return this.securityProviders.get(key) 28 | } 29 | public getSessionFactory(): SessionFactory { 30 | return this.sessionFactory 31 | } 32 | public getEmailFactory(): EmailFactory { 33 | return this.emailFactory 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/log/logger.ts: -------------------------------------------------------------------------------- 1 | import consola, { Consola, LogLevel } from 'consola' 2 | 3 | import { config } from '../config/config' 4 | 5 | const getLogLevel = (): number => { 6 | let level = LogLevel.Info 7 | 8 | if (typeof config === 'undefined') { 9 | // When ZenTS is booting, config will be undefined. In that case, we just log errors. 10 | return LogLevel.Error 11 | } 12 | 13 | switch (config.log.level) { 14 | case 'debug': 15 | level = LogLevel.Debug 16 | break 17 | 18 | case 'fatal': 19 | level = LogLevel.Fatal 20 | break 21 | 22 | case 'error': 23 | level = LogLevel.Error 24 | break 25 | 26 | case 'warn': 27 | level = LogLevel.Warn 28 | break 29 | 30 | case 'log': 31 | level = LogLevel.Log 32 | break 33 | 34 | case 'info': 35 | level = LogLevel.Info 36 | break 37 | 38 | case 'success': 39 | level = LogLevel.Success 40 | break 41 | 42 | case 'trace': 43 | level = LogLevel.Trace 44 | break 45 | 46 | default: 47 | level = LogLevel.Info 48 | } 49 | 50 | return level 51 | } 52 | 53 | const createLoggerInternal = (): Consola => { 54 | return consola.create({ 55 | level: getLogLevel(), 56 | }) 57 | } 58 | 59 | export let log = createLoggerInternal() 60 | 61 | export const createLogger = (): void => { 62 | log = createLoggerInternal() 63 | 64 | if (config.log.wrapConsole) { 65 | log.wrapConsole() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/config/default.ts: -------------------------------------------------------------------------------- 1 | import type { ZenConfig } from './../types/interfaces' 2 | import { fs } from './../filesystem/FS' 3 | import { join } from 'path' 4 | 5 | const appDir = fs ? fs.appDir() : process.cwd() 6 | 7 | /** 8 | * The default ZenTS config 9 | */ 10 | export const defaultConfig: ZenConfig = { 11 | paths: { 12 | base: { 13 | src: join(appDir, 'src'), 14 | dist: join(appDir, 'dist'), 15 | }, 16 | controller: './controller/', 17 | view: './view/', 18 | template: './template/', 19 | service: './service/', 20 | entity: './entity/', 21 | email: './email/', 22 | public: join(appDir, 'public'), 23 | }, 24 | web: { 25 | host: 'localhost', 26 | port: 3000, 27 | publicPath: '/public/', 28 | https: { 29 | enable: false, 30 | }, 31 | cookie: { 32 | enable: true, 33 | }, 34 | redirectBodyType: 'none', 35 | }, 36 | database: { 37 | enable: false, 38 | }, 39 | redis: { 40 | enable: false, 41 | log: true, 42 | }, 43 | security: { 44 | enable: false, 45 | }, 46 | template: { 47 | extension: 'njk', 48 | encoding: 'utf-8', 49 | autoescape: true, 50 | throwOnUndefined: false, 51 | trimBlocks: false, 52 | lstripBlocks: false, 53 | }, 54 | email: { 55 | enable: false, 56 | engine: 'mjml', 57 | htmlToText: { 58 | enable: true, 59 | }, 60 | }, 61 | log: { 62 | level: 'info', 63 | wrapConsole: false, 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /src/controller/ControllerFactory.ts: -------------------------------------------------------------------------------- 1 | import type { Controllers, SecurityProviders, TemplateEngineLoaderResult } from '../types/' 2 | import { Environment, Loader } from '../template/' 3 | 4 | import { AbstractFactory } from '../core/AbstractFactory' 5 | import type { DatabaseContainer } from '../database/DatabaseContainer' 6 | import type { EmailFactory } from '../email/EmailFactory' 7 | import type { SessionFactory } from '../security/SessionFactory' 8 | 9 | export class ControllerFactory extends AbstractFactory { 10 | protected templateEnvironment: Environment 11 | 12 | constructor( 13 | protected readonly controllers: Controllers, 14 | emailFactory: EmailFactory, 15 | sessionFactory: SessionFactory, 16 | securityProviders: SecurityProviders, 17 | databaseContainer: DatabaseContainer, 18 | templateData: TemplateEngineLoaderResult, 19 | ) { 20 | super() 21 | 22 | const templateLoader = new Loader(templateData.files) 23 | this.templateEnvironment = new Environment(templateLoader, templateData) 24 | this.injector = this.buildInjector({ 25 | databaseContainer, 26 | emailFactory, 27 | securityProviders, 28 | sessionFactory, 29 | }) 30 | } 31 | 32 | public build(key: string): T { 33 | const controller = this.controllers.get(key) 34 | 35 | if (!controller) { 36 | return null 37 | } 38 | 39 | const instance = this.injector.inject(controller.module, [this.templateEnvironment]) 40 | 41 | return instance 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/RepositoryAction.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GenericControllerInstance, 3 | InjectorFunctionParameter, 4 | RepositoryReflectionMetadata, 5 | } from '../../types/interfaces' 6 | import { REFLECT_METADATA, REPOSITORY_TYPE } from '../../types' 7 | 8 | import { AbstractAction } from './AbstractAction' 9 | 10 | export class RepositoryAction extends AbstractAction { 11 | public run(instance: GenericControllerInstance, method: string): InjectorFunctionParameter[] { 12 | if (!Reflect.hasMetadata(REFLECT_METADATA.DATABASE_REPOSITORY, instance, method)) { 13 | return [] 14 | } 15 | 16 | const connection = this.injector.context.getConnection() 17 | const metadata = Reflect.getMetadata( 18 | REFLECT_METADATA.DATABASE_REPOSITORY, 19 | instance, 20 | method, 21 | ) as RepositoryReflectionMetadata[] 22 | const parameters = metadata.map((meta) => { 23 | let value = null 24 | 25 | switch (meta.repositoryType) { 26 | case REPOSITORY_TYPE.REPOSITORY: 27 | value = connection.getRepository(meta.entity) 28 | break 29 | 30 | case REPOSITORY_TYPE.TREE: 31 | value = connection.getTreeRepository(meta.entity) 32 | break 33 | 34 | case REPOSITORY_TYPE.CUSTOM: 35 | value = connection.getCustomRepository(meta.entity) 36 | break 37 | } 38 | 39 | return { 40 | index: meta.index, 41 | value, 42 | } 43 | }) 44 | 45 | return parameters 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/template/Environment.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NunjucksFilterCallback, 3 | TemplateEngineLoaderResult, 4 | TemplateFiltersMap, 5 | } from '../types/' 6 | 7 | import type { Loader } from './Loader' 8 | import { Environment as NunjucksEnvironment } from 'nunjucks' 9 | import { config } from '../config/config' 10 | 11 | export class Environment extends NunjucksEnvironment { 12 | constructor(loader: Loader, templateData: TemplateEngineLoaderResult) { 13 | super(loader, config.template) 14 | 15 | this.loadFilters(templateData.filters) 16 | } 17 | protected loadFilters(filters: TemplateFiltersMap): void { 18 | for (const [name, filter] of filters) { 19 | const instance = new filter.module() 20 | 21 | if (!filter.async) { 22 | this.addFilter( 23 | name, 24 | (...args: any[]) => { 25 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 26 | return instance.run(...args) 27 | }, 28 | false, 29 | ) 30 | } else { 31 | this.addFilter( 32 | name, 33 | async (...args: any[]) => { 34 | const next = args.pop() as NunjucksFilterCallback 35 | let err = null 36 | let result 37 | 38 | try { 39 | result = (await instance.run(...args)) as unknown 40 | } catch (e) { 41 | err = e as unknown 42 | } finally { 43 | next(err, result) 44 | } 45 | }, 46 | true, 47 | ) 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/decorators/security.ts: -------------------------------------------------------------------------------- 1 | import type { Class } from 'type-fest' 2 | import { REFLECT_METADATA } from '../types/enums' 3 | import type { SecurityProviderReflectionMetadata } from '../types/interfaces' 4 | 5 | export function auth(provider: string = 'default') { 6 | return (target: any, propertyKey: string): void => { 7 | Reflect.defineMetadata(REFLECT_METADATA.AUTH_PROVIDER, provider, target, propertyKey) 8 | } 9 | } 10 | 11 | export function securityProvider(provider: string = 'default') { 12 | return (target: Class, propertyKey: string, parameterIndex: number): void => { 13 | const providers = 14 | (Reflect.getMetadata( 15 | REFLECT_METADATA.SECURITY_PROVIDER, 16 | target, 17 | propertyKey, 18 | ) as SecurityProviderReflectionMetadata[]) ?? [] 19 | 20 | providers.push({ 21 | index: parameterIndex, 22 | propertyKey, 23 | target, 24 | name: provider, 25 | }) 26 | 27 | Reflect.defineMetadata(REFLECT_METADATA.SECURITY_PROVIDER, providers, target, propertyKey) 28 | } 29 | } 30 | 31 | export function session(provider: string = 'default') { 32 | return (target: Class, propertyKey: string, parameterIndex: number): void => { 33 | const sessions = 34 | (Reflect.getMetadata( 35 | REFLECT_METADATA.SESSION, 36 | target, 37 | propertyKey, 38 | ) as SecurityProviderReflectionMetadata[]) ?? [] 39 | 40 | sessions.push({ 41 | index: parameterIndex, 42 | propertyKey, 43 | target, 44 | name: provider, 45 | }) 46 | 47 | Reflect.defineMetadata(REFLECT_METADATA.SESSION, sessions, target, propertyKey) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/filesystem/fs.test.ts: -------------------------------------------------------------------------------- 1 | import { fs } from '../../src/filesystem/FS' 2 | import { getFixtureDir } from '../helper/getFixtureDir' 3 | import { join } from 'path' 4 | 5 | describe('FS', () => { 6 | it("checks that a file exists or doesn't extist", async () => { 7 | const exists = await fs.exists(join(getFixtureDir('config'), 'zen.json')) 8 | expect(exists).toBe(true) 9 | 10 | const doesntExists = await fs.exists('./filedoesntexists') 11 | expect(doesntExists).toBe(false) 12 | }) 13 | 14 | it('read the directory content recursively', async () => { 15 | const testDir = getFixtureDir('testapp') 16 | const content = await fs.readDir(testDir) 17 | const normalizedContent = content.map((filePath) => filePath.replace(testDir, '')) 18 | 19 | expect(normalizedContent).toContain('/src/controller/ResponseController.ts') 20 | expect(normalizedContent).toContain('/src/service/DepdendencyInjectionService.ts') 21 | expect(normalizedContent).toContain('/zen.json') 22 | }) 23 | 24 | it('resolves file extension depending on the environment', () => { 25 | process.env.NODE_ENV = 'production' 26 | const notDevWithoutFilename = fs.resolveZenFileExtension() 27 | expect(notDevWithoutFilename).toBe('.js') 28 | 29 | const notDevWithFilename = fs.resolveZenFileExtension('filename') 30 | expect(notDevWithFilename).toBe('filename.js') 31 | 32 | process.env.NODE_ENV = 'development' 33 | const devWithoutFilename = fs.resolveZenFileExtension() 34 | expect(devWithoutFilename).toBe('.ts') 35 | 36 | const devWithFilename = fs.resolveZenFileExtension('filename') 37 | expect(devWithFilename).toBe('filename.ts') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /test/common/config.test.ts: -------------------------------------------------------------------------------- 1 | import { config, loadConfig } from '../../src/config/config' 2 | 3 | import { defaultConfig } from '../../src/config/default' 4 | import { getFixtureDir } from '../helper/getFixtureDir' 5 | 6 | const testConfigDir = getFixtureDir('config') 7 | 8 | describe('Config', () => { 9 | it('has the default config before calling loadConfig()', () => { 10 | expect(config).toEqual(defaultConfig) 11 | }) 12 | 13 | it('loads configuration files correctly', async () => { 14 | await loadConfig(undefined, testConfigDir, true) 15 | 16 | expect(config.web?.port).toBe(8080) 17 | expect(config.web?.publicPath).toBe('/assets/') 18 | expect(config.database?.enable).toBe(true) 19 | }) 20 | 21 | it('overwrites config options passed via argument', async () => { 22 | await loadConfig( 23 | { 24 | web: { 25 | port: 3333, 26 | }, 27 | }, 28 | testConfigDir, 29 | true, 30 | ) 31 | 32 | expect(config.web?.port).toBe(3333) 33 | expect(config.web?.publicPath).toBe('/assets/') 34 | expect(config.database?.enable).toBe(true) 35 | }) 36 | 37 | it("doesn't load configs twice", async () => { 38 | await loadConfig( 39 | { 40 | web: { 41 | port: 1234, 42 | }, 43 | }, 44 | testConfigDir, 45 | false, 46 | ) 47 | 48 | expect(config.web?.port).toBe(3333) 49 | }) 50 | 51 | it("loads development config when NODE_ENV isn't present", async () => { 52 | delete process.env.NODE_ENV 53 | 54 | await loadConfig(undefined, testConfigDir, true) 55 | expect(config.web?.port).toBe(5555) 56 | 57 | process.env.NODE_ENV = 'test' 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /docs/.vuepress/public/icons/typescript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/set.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/restrict-plus-operands */ 2 | /* eslint-disable no-bitwise */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 5 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 6 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 7 | 8 | // Source: https://stackoverflow.com/questions/54733539/javascript-implementation-of-lodash-set-method 9 | export function set( 10 | obj: Record, 11 | path: string, 12 | value: any, 13 | ): Record { 14 | if (Object(obj) !== obj) { 15 | return obj 16 | } // When obj is not an object 17 | // If not yet an array, get the keys from the string-path 18 | if (!Array.isArray(path)) { 19 | // @ts-ignore 20 | path = path.toString().match(/[^.[\]]+/g) || [] 21 | } 22 | 23 | // @ts-ignore 24 | path.slice(0, -1).reduce( 25 | ( 26 | // @ts-ignore 27 | a, 28 | // @ts-ignore 29 | c, 30 | // @ts-ignore 31 | i, // Iterate all of them except the last one 32 | ) => 33 | Object(a[c]) === a[c] // Does the key exist and is its value an object? 34 | ? // Yes: then follow that path 35 | a[c] 36 | : // No: create the key. Is the next key a potential array-index? 37 | (a[c] = 38 | // @ts-ignore 39 | Math.abs(path[i + 1]) >> 0 === +path[i + 1] 40 | ? [] // Yes: assign a new array object 41 | : {}), // No: assign a new plain object 42 | obj, 43 | )[path[path.length - 1]] = value // Finally assign the value to the last key 44 | 45 | return obj // Return the top-level object to allow chaining 46 | } 47 | -------------------------------------------------------------------------------- /test/filesystem/abstractZenFileLoader.test.ts: -------------------------------------------------------------------------------- 1 | import { AbstractZenFileLoader } from '../../src/filesystem/AbstractZenFileLoader' 2 | import DefaultController from '../fixtures/zenfiles/DefaultController' 3 | import { NamedExportController } from '../fixtures/zenfiles/NamedExportController' 4 | import { getFixtureDir } from '../helper/getFixtureDir' 5 | import { join } from 'path' 6 | 7 | const MockLoader = class extends AbstractZenFileLoader { 8 | public async mockModuleLoad(filePath: string) { 9 | return await this.loadModule(filePath) 10 | } 11 | } 12 | const loader = new MockLoader() 13 | const filesDir = getFixtureDir('zenfiles') 14 | 15 | describe('AbstractZenFileLoader', () => { 16 | it('loads controller with default export', async () => { 17 | const result = await loader.mockModuleLoad( 18 | join(filesDir, 'DefaultController'), 19 | ) 20 | 21 | expect(result).toHaveProperty('key') 22 | expect(result).toHaveProperty('module') 23 | expect(result.key).toBe('defaultcontroller') 24 | expect(result.module).toStrictEqual(DefaultController) 25 | }) 26 | 27 | it('loads controller with a named export', async () => { 28 | const result = await loader.mockModuleLoad( 29 | join(filesDir, 'NamedExportController'), 30 | ) 31 | 32 | expect(result).toHaveProperty('key') 33 | expect(result).toHaveProperty('module') 34 | expect(result.key).toBe('namedexportcontroller') 35 | expect(result.module).toStrictEqual(NamedExportController) 36 | }) 37 | 38 | it("throws an error when export member isn't found", async () => { 39 | await expect(loader.mockModuleLoad(join(filesDir, 'FaultController'))).rejects.toThrow() 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/security/SecurityResponse.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from '../types/interfaces' 2 | import type { SecurityProviderOptions } from './SecurityProviderOptions' 3 | 4 | export class SecurityResponse { 5 | constructor(protected options: SecurityProviderOptions) {} 6 | 7 | public loginSuccess(context: Context, token: string): void { 8 | if (this.options.responseType === 'json') { 9 | return context.res 10 | .json({ 11 | token, 12 | }) 13 | .send() 14 | } 15 | 16 | return context.res.redirect(this.options.loginRedirectUrl) 17 | } 18 | 19 | public loginFailed(context: Context): void { 20 | if (this.options.responseType === 'json') { 21 | return context.error.forbidden('Unauthorized access', { 22 | detail: 'Username or password not found', 23 | }) 24 | } 25 | 26 | return context.res.redirect(this.options.failedRedirectUrl) 27 | } 28 | 29 | public logoutSuccess(context: Context): void { 30 | if (this.options.responseType === 'json') { 31 | return context.res 32 | .json({ 33 | logout: true, 34 | }) 35 | .send() 36 | } 37 | 38 | return context.res.redirect(this.options.logoutRedirectUrl) 39 | } 40 | 41 | public logoutFailed(context: Context): void { 42 | if (this.options.responseType === 'json') { 43 | return context.error.badRequest('Authorization missing') 44 | } 45 | 46 | return context.res.redirect(this.options.failedRedirectUrl) 47 | } 48 | 49 | public forbidden(context: Context): void { 50 | if (this.options.responseType === 'json') { 51 | return context.error.forbidden() 52 | } 53 | 54 | return context.res.redirect(this.options.forbiddenRedirectUrl) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/decorators/routing.ts: -------------------------------------------------------------------------------- 1 | import type { Class } from 'type-fest' 2 | import { REFLECT_METADATA } from '../types/enums' 3 | 4 | export function get(path: string) { 5 | return (target: any, propertyKey: string): void => { 6 | Reflect.defineMetadata(REFLECT_METADATA.HTTP_METHOD, 'get', target, propertyKey) 7 | Reflect.defineMetadata(REFLECT_METADATA.URL_PATH, path, target, propertyKey) 8 | } 9 | } 10 | 11 | export function post(path: string) { 12 | return (target: any, propertyKey: string): void => { 13 | Reflect.defineMetadata(REFLECT_METADATA.HTTP_METHOD, 'post', target, propertyKey) 14 | Reflect.defineMetadata(REFLECT_METADATA.URL_PATH, path, target, propertyKey) 15 | } 16 | } 17 | 18 | export function put(path: string) { 19 | return (target: any, propertyKey: string): void => { 20 | Reflect.defineMetadata(REFLECT_METADATA.HTTP_METHOD, 'put', target, propertyKey) 21 | Reflect.defineMetadata(REFLECT_METADATA.URL_PATH, path, target, propertyKey) 22 | } 23 | } 24 | 25 | export function del(path: string) { 26 | return (target: any, propertyKey: string): void => { 27 | Reflect.defineMetadata(REFLECT_METADATA.HTTP_METHOD, 'delete', target, propertyKey) 28 | Reflect.defineMetadata(REFLECT_METADATA.URL_PATH, path, target, propertyKey) 29 | } 30 | } 31 | 32 | export function options(path: string) { 33 | return (target: any, propertyKey: string): void => { 34 | Reflect.defineMetadata(REFLECT_METADATA.HTTP_METHOD, 'options', target, propertyKey) 35 | Reflect.defineMetadata(REFLECT_METADATA.URL_PATH, path, target, propertyKey) 36 | } 37 | } 38 | 39 | export function prefix(path: string) { 40 | return (target: Class): void => { 41 | Reflect.defineMetadata(REFLECT_METADATA.URL_PREFIX, path, target) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/decorators/context.ts: -------------------------------------------------------------------------------- 1 | import type { Class } from 'type-fest' 2 | import { REFLECT_METADATA } from '../types/enums' 3 | 4 | export function body(target: Class, propertyKey: string, parameterIndex: number): void { 5 | Reflect.defineMetadata(REFLECT_METADATA.CONTEXT_BODY, parameterIndex, target, propertyKey) 6 | } 7 | 8 | export function query(target: Class, propertyKey: string, parameterIndex: number): void { 9 | Reflect.defineMetadata(REFLECT_METADATA.CONTEXT_QUERY, parameterIndex, target, propertyKey) 10 | } 11 | 12 | export function params(target: Class, propertyKey: string, parameterIndex: number): void { 13 | Reflect.defineMetadata(REFLECT_METADATA.CONTEXT_PARAMS, parameterIndex, target, propertyKey) 14 | } 15 | 16 | export function cookie(target: Class, propertyKey: string, parameterIndex: number): void { 17 | Reflect.defineMetadata(REFLECT_METADATA.CONTEXT_COOKIE, parameterIndex, target, propertyKey) 18 | } 19 | 20 | export function request(target: Class, propertyKey: string, parameterIndex: number): void { 21 | Reflect.defineMetadata(REFLECT_METADATA.CONTEXT_REQUEST, parameterIndex, target, propertyKey) 22 | } 23 | 24 | export const req = request 25 | 26 | export function response(target: Class, propertyKey: string, parameterIndex: number): void { 27 | Reflect.defineMetadata(REFLECT_METADATA.CONTEXT_RESPONSE, parameterIndex, target, propertyKey) 28 | } 29 | 30 | export const res = response 31 | 32 | export function error(target: Class, propertyKey: string, parameterIndex: number): void { 33 | Reflect.defineMetadata(REFLECT_METADATA.CONTEXT_ERROR, parameterIndex, target, propertyKey) 34 | } 35 | 36 | export function context(target: Class, propertyKey: string, parameterIndex: number): void { 37 | Reflect.defineMetadata(REFLECT_METADATA.CONTEXT_ALL, parameterIndex, target, propertyKey) 38 | } 39 | -------------------------------------------------------------------------------- /test/common/app.test.ts: -------------------------------------------------------------------------------- 1 | import type { ZenApp } from '../../src/core/ZenApp' 2 | import { mockZenApp } from '../mock/mockZenApp' 3 | import supertest from 'supertest' 4 | 5 | let app: ZenApp 6 | 7 | describe('ZenTS test app', () => { 8 | beforeAll(async () => { 9 | app = await mockZenApp('./test/fixtures/testapp/') 10 | }) 11 | 12 | afterAll(() => { 13 | app.destroy() 14 | }) 15 | 16 | it('answers ping message', async () => { 17 | await supertest(app.nodeServer).get('/ping').expect('Content-Type', /json/).expect(200).expect({ 18 | answer: 'pong', 19 | }) 20 | }) 21 | 22 | it('returns a JSON object response', async () => { 23 | await supertest(app.nodeServer) 24 | .get('/json-object') 25 | .expect(200) 26 | .expect('Content-Type', /json/) 27 | .expect({ 28 | foo: 'bar', 29 | baz: 'battzz', 30 | }) 31 | }) 32 | 33 | it('returns a JSON array response', async () => { 34 | await supertest(app.nodeServer) 35 | .get('/json-array') 36 | .expect(200) 37 | .expect('Content-Type', /json/) 38 | .expect(['foo', 'bar', 'baz']) 39 | }) 40 | 41 | it('parses a POST request body', async () => { 42 | const body = { 43 | foo: 'bar', 44 | } 45 | 46 | await supertest(app.nodeServer) 47 | .post('/post-echo') 48 | .send(body) 49 | .set('Accept', 'application/json') 50 | .expect(201) 51 | .expect('Content-Type', /json/) 52 | .expect(body) 53 | }) 54 | 55 | it('parses a PUT request body', async () => { 56 | const body = { 57 | foo: 'bar', 58 | } 59 | 60 | await supertest(app.nodeServer) 61 | .put('/put-echo') 62 | .send(body) 63 | .set('Accept', 'application/json') 64 | .expect(200) 65 | .expect('Content-Type', /json/) 66 | .expect(body) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/security/SessionFactory.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from '../http/Context' 2 | import type { DatabaseContainer } from '../database/DatabaseContainer' 3 | import type { RequestConfigControllerUser } from '../types/interfaces' 4 | import type { SecurityProviders } from '../types/types' 5 | import { Session } from './Session' 6 | import { SessionStore } from './SessionStore' 7 | import { SessionStoreAdapterFactory } from './SessionStoreAdapterFactory' 8 | 9 | export class SessionFactory { 10 | protected storeFactory: SessionStoreAdapterFactory 11 | constructor( 12 | protected readonly securityProviders: SecurityProviders, 13 | databaseContainer: DatabaseContainer, 14 | ) { 15 | this.storeFactory = new SessionStoreAdapterFactory(databaseContainer) 16 | } 17 | 18 | public async build( 19 | providerKey: string, 20 | context: Context, 21 | previouslyLoadedUser?: RequestConfigControllerUser, 22 | ): Promise { 23 | const securityProvider = this.securityProviders.get(providerKey) 24 | let user = null 25 | let sessionId: string 26 | 27 | if (typeof previouslyLoadedUser !== 'undefined') { 28 | user = previouslyLoadedUser.user 29 | sessionId = previouslyLoadedUser.sessionId 30 | } else { 31 | const authorize = await securityProvider.authorize(context) 32 | 33 | if (authorize.isAuth) { 34 | user = authorize.user 35 | sessionId = authorize.sessionId 36 | } 37 | } 38 | 39 | if (typeof sessionId !== 'string') { 40 | sessionId = securityProvider.generateSessionId() 41 | } 42 | 43 | const adapter = this.storeFactory.build(securityProvider.options) 44 | const storeData = await adapter.load(sessionId) 45 | const store = new SessionStore(sessionId, storeData, adapter) 46 | const session = new Session(sessionId, user, store, adapter, providerKey) 47 | 48 | return session 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/http/ResponseHeader.ts: -------------------------------------------------------------------------------- 1 | import type { HeaderValue, HeaderValues } from '../types' 2 | import type { OutgoingHttpHeaders, ServerResponse } from 'http' 3 | 4 | import { getContentType } from '../utils/getContentType' 5 | 6 | export class ResponseHeader { 7 | constructor(private res: ServerResponse) {} 8 | 9 | public all(): OutgoingHttpHeaders { 10 | return this.res.getHeaders() 11 | } 12 | public get(key: string): HeaderValue { 13 | return this.res.getHeader(key) 14 | } 15 | public has(key: string): boolean { 16 | if (this.isSend()) { 17 | return false 18 | } 19 | 20 | return this.res.hasHeader(key) 21 | } 22 | public set(key: string, value: HeaderValue): void { 23 | if (this.isSend()) { 24 | return 25 | } 26 | 27 | this.res.setHeader(key, value.toString()) 28 | } 29 | public multiple(headers: HeaderValues[]): void { 30 | if (this.isSend()) { 31 | return 32 | } 33 | 34 | for (const header of headers) { 35 | this.set(header.key, header.value) 36 | } 37 | } 38 | public setContentType(filenameOrExt: string): boolean { 39 | const type = getContentType(filenameOrExt) 40 | 41 | if (!type) { 42 | return false 43 | } 44 | 45 | this.set('Content-Type', filenameOrExt) 46 | 47 | return true 48 | } 49 | public getContentType(): string | null { 50 | const contentType = this.get('Content-Type') 51 | 52 | if (typeof contentType !== 'string') { 53 | return null 54 | } 55 | 56 | return contentType 57 | } 58 | public remove(key: string): void { 59 | if (this.isSend()) { 60 | return 61 | } 62 | 63 | this.res.removeHeader(key) 64 | } 65 | public flush(): void { 66 | this.res.flushHeaders() 67 | } 68 | public keys(): string[] { 69 | return this.res.getHeaderNames() 70 | } 71 | public isSend(): boolean { 72 | return this.res.headersSent 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/decorators/database.ts: -------------------------------------------------------------------------------- 1 | import { REFLECT_METADATA, REPOSITORY_TYPE } from '../types/enums' 2 | 3 | import type { Class } from 'type-fest' 4 | import type { RepositoryReflectionMetadata } from '../types/interfaces' 5 | 6 | export function connection(target: any, propertyKey: string): void { 7 | Reflect.defineMetadata(REFLECT_METADATA.DATABASE_CONNECTION, propertyKey, target) 8 | } 9 | 10 | export function entityManager(target: any, propertyKey: string): void { 11 | Reflect.defineMetadata(REFLECT_METADATA.DATABASE_EM, propertyKey, target) 12 | } 13 | 14 | function repositoryDecorator( 15 | repositoryType: REPOSITORY_TYPE, 16 | entity: Class, 17 | ): (target: Class, propertyKey: string, parameterIndex: number) => void { 18 | return (target: Class, propertyKey: string, parameterIndex: number): void => { 19 | const repositories = 20 | (Reflect.getMetadata( 21 | REFLECT_METADATA.DATABASE_REPOSITORY, 22 | target, 23 | propertyKey, 24 | ) as RepositoryReflectionMetadata[]) ?? [] 25 | 26 | repositories.push({ 27 | index: parameterIndex, 28 | propertyKey, 29 | entity, 30 | repositoryType, 31 | }) 32 | 33 | Reflect.defineMetadata(REFLECT_METADATA.DATABASE_REPOSITORY, repositories, target, propertyKey) 34 | } 35 | } 36 | 37 | export function repository( 38 | entity: Class, 39 | ): (target: Class, propertyKey: string, parameterIndex: number) => void { 40 | return repositoryDecorator(REPOSITORY_TYPE.REPOSITORY, entity) 41 | } 42 | 43 | export function treeRepository( 44 | entity: Class, 45 | ): (target: Class, propertyKey: string, parameterIndex: number) => void { 46 | return repositoryDecorator(REPOSITORY_TYPE.TREE, entity) 47 | } 48 | 49 | export function customRepository( 50 | repository: Class, 51 | ): (target: Class, propertyKey: string, parameterIndex: number) => void { 52 | return repositoryDecorator(REPOSITORY_TYPE.CUSTOM, repository) 53 | } 54 | -------------------------------------------------------------------------------- /src/template/TemplateEngineLoader.ts: -------------------------------------------------------------------------------- 1 | import { AbstractZenFileLoader, fs } from '../filesystem' 2 | import type { 3 | TemplateEngineLoaderResult, 4 | TemplateFilter, 5 | TemplateFiltersMap, 6 | TemplateStaticFilterModule, 7 | } from '../types/' 8 | 9 | import { config } from '../config/config' 10 | import { join } from 'path' 11 | import { log } from '../log/logger' 12 | 13 | export class TemplateEngineLoader extends AbstractZenFileLoader { 14 | public async load(): Promise { 15 | const [files, filters] = await Promise.all([this.loadFiles(), this.loadFilters()]) 16 | 17 | return { 18 | files, 19 | filters, 20 | } 21 | } 22 | protected async loadFiles(): Promise { 23 | return (await fs.readDir(fs.resolveZenPath('view'))).filter((filePath: string) => 24 | filePath.endsWith(`.${config.template.extension}`), 25 | ) 26 | } 27 | protected async loadFilters(): Promise { 28 | const filters = new Map() as TemplateFiltersMap 29 | const dir = join(fs.resolveZenPath('template'), 'filter') 30 | 31 | if (!(await fs.exists(dir))) { 32 | return filters 33 | } 34 | 35 | const filePaths = (await fs.readDir(dir)).filter((filePath: string) => 36 | filePath.endsWith(fs.resolveZenFileExtension()), 37 | ) 38 | 39 | for (const filePath of filePaths) { 40 | const { key, module } = await this.loadModule(filePath) 41 | const filterModule = module as TemplateStaticFilterModule 42 | const name = typeof filterModule.filtername === 'string' ? filterModule.filtername : key 43 | 44 | if (filters.has(name)) { 45 | log.warn(`Template filter "${name}" has already been set. Skipping...`) 46 | continue 47 | } 48 | 49 | filters.set(name, { 50 | async: filterModule.async ?? false, 51 | module, 52 | }) 53 | } 54 | 55 | return filters 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/fixtures/testapp/src/controller/RequestHeaderTestController.ts: -------------------------------------------------------------------------------- 1 | import { Context, Controller, get, prefix, req, Request, request } from '../../../../../src' 2 | 3 | const customHeaderKey = 'x-zen-test' 4 | 5 | @prefix('/request-header') 6 | export default class extends Controller { 7 | @get('/all') 8 | public allTest(@request req: Request) { 9 | const result: { [key: string]: string | string[] } = {} 10 | 11 | for (const [key, value] of req.header.all()) { 12 | if (key !== 'host') { 13 | result[key] = value 14 | } 15 | } 16 | 17 | return { 18 | result, 19 | } 20 | } 21 | 22 | @get('/get') 23 | public getTest(@req request: Request) { 24 | return { 25 | value: request.header.get(customHeaderKey), 26 | } 27 | } 28 | 29 | @get('/has') 30 | public hasTest(@request request: Request) { 31 | return { 32 | exist: request.header.has(customHeaderKey), 33 | doesntexist: request.header.has('not-send'), 34 | } 35 | } 36 | 37 | @get('/remove') 38 | public removeTest(@request request: Request) { 39 | if (!request.header.has(customHeaderKey)) { 40 | return { 41 | success: false, 42 | } 43 | } 44 | 45 | const value = request.header.get(customHeaderKey) 46 | 47 | request.header.remove(customHeaderKey) 48 | 49 | return { 50 | success: true, 51 | typeofCurrentValue: typeof request.header.get(customHeaderKey), 52 | oldValue: value, 53 | has: request.header.has(customHeaderKey), 54 | } 55 | } 56 | 57 | @get('/set') 58 | public setTest(@request request: Request) { 59 | request.header.set('foo', 'bar') 60 | 61 | return { 62 | has: request.header.has('foo'), 63 | value: request.header.get('foo'), 64 | } 65 | } 66 | 67 | @get('/accept') 68 | public acceptTest(@request request: Request) { 69 | return { 70 | accept: request.header.getAccept(), 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/types/enums.ts: -------------------------------------------------------------------------------- 1 | // ---- A 2 | // ---- B 3 | // ---- C 4 | // ---- D 5 | 6 | export enum DB_TYPE { 7 | ORM = 'orm', 8 | REDIS = 'redis', 9 | } 10 | 11 | // ---- E 12 | 13 | export enum ERROR { 14 | PACKAGE_JSON_NOT_FOUND = "Project's package.json could not been found!", 15 | } 16 | 17 | // ---- F 18 | // ---- G 19 | // ---- H 20 | // ---- I 21 | // ---- J 22 | // ---- K 23 | // ---- L 24 | // ---- M 25 | // ---- N 26 | // ---- O 27 | // ---- P 28 | // ---- Q 29 | // ---- R 30 | 31 | export enum REFLECT_METADATA { 32 | AUTH_PROVIDER = 'authProvider', 33 | CONTEXT_ALL = 'contextAll', 34 | CONTEXT_BODY = 'contextBody', 35 | CONTEXT_COOKIE = 'contextCookie', 36 | CONTEXT_ERROR = 'contextError', 37 | CONTEXT_QUERY = 'contextQuery', 38 | CONTEXT_PARAMS = 'contextParams', 39 | CONTEXT_REQUEST = 'contextRequest', 40 | CONTEXT_RESPONSE = 'contextResponse', 41 | CONTROLLER_KEY = 'controllerKey', 42 | DATABASE_CONNECTION = 'database:connection', 43 | DATABASE_EM = 'database:em', 44 | DATABASE_REPOSITORY = 'database:repository', 45 | DEPENDENCIES = 'dependencies', 46 | EMAIL = 'email', 47 | HTTP_METHOD = 'httpMethod', 48 | REDIS_CLIENT = 'redisClient', 49 | SECURITY_PROVIDER = 'securityProvider', 50 | SESSION = 'session', 51 | URL_PATH = 'urlPath', 52 | URL_PREFIX = 'urlPrefix', 53 | VALIDATION_SCHEMA = 'validationSchema', 54 | } 55 | 56 | export enum REQUEST_TYPE { 57 | CONTROLLER = 'controller', 58 | SECURITY = 'security', 59 | } 60 | 61 | export enum RESPONSE_BODY_TYPE { 62 | BUFFER = 'buffer', 63 | JSON = 'json', 64 | HTML = 'html', 65 | STREAM = 'stream', 66 | TEXT = 'text', 67 | } 68 | 69 | export enum REPOSITORY_TYPE { 70 | CUSTOM = 'customRepository', 71 | REPOSITORY = 'repository', 72 | TREE = 'treeRepository', 73 | } 74 | 75 | // ---- S 76 | 77 | export enum SECURITY_ACTION { 78 | LOGIN = 'login', 79 | LOGOUT = 'logout', 80 | } 81 | 82 | // ---- T 83 | // ---- U 84 | // ---- V 85 | // ---- W 86 | // ---- X 87 | // ---- Y 88 | // ---- Z 89 | -------------------------------------------------------------------------------- /test/common/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process' 2 | import { get } from '../../src/utils/get' 3 | import { getContentType } from '../../src/utils/getContentType' 4 | import { isObject } from '../../src/utils/isObject' 5 | import { set } from '../../src/utils/set' 6 | 7 | describe('Util', () => { 8 | it('getContentType() returns the correct mime type', () => { 9 | expect(getContentType('json')).toBe('application/json; charset=utf-8') 10 | expect(getContentType('foo.html')).toBe('text/html; charset=utf-8') 11 | expect(getContentType('app.js')).toBe('application/javascript; charset=utf-8') 12 | expect(getContentType('app.thismimetypedoesntexist')).toBe(false) 13 | }) 14 | 15 | it('getContentType() has a working cached copy', () => { 16 | expect(getContentType('json')).toBe('application/json; charset=utf-8') 17 | expect(getContentType('json')).toBe('application/json; charset=utf-8') 18 | }) 19 | 20 | it('isObject()', () => { 21 | expect(isObject({ foo: 'bar' })).toBe(true) 22 | expect(isObject(['foo', 'bar'])).toBe(false) 23 | expect(isObject('foo')).toBe(false) 24 | expect(isObject(42)).toBe(false) 25 | expect(isObject(true)).toBe(false) 26 | }) 27 | 28 | it('set()', () => { 29 | const base: { [key: string]: any } = { 30 | foo: 'bar', 31 | } 32 | 33 | set(base, 'test', 'test') 34 | expect(base).toHaveProperty('test') 35 | expect(base.test).toBe('test') 36 | 37 | set(base, 'nested.value', 'foo') 38 | expect(base).toHaveProperty('nested') 39 | expect(base.nested).toHaveProperty('value') 40 | expect(base.nested.value).toBe('foo') 41 | }) 42 | 43 | it('get()', () => { 44 | const testObj = { 45 | foo: 'bar', 46 | nested: { 47 | value: { 48 | deep: true, 49 | }, 50 | }, 51 | } 52 | 53 | expect(get(testObj, 'foo')).toBe('bar') 54 | expect(get(testObj, 'nested.value.deep')).toBe(true) 55 | expect(get(testObj, 'not.there')).toBeUndefined 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/core/ZenApp.ts: -------------------------------------------------------------------------------- 1 | import { config, loadConfig } from '../config/config' 2 | import { createLogger, log } from '../log/logger' 3 | 4 | import { AutoLoader } from './AutoLoader' 5 | import type { Server as NodeHttpServer } from 'http' 6 | import type { Server as NodeHttpsServer } from 'https' 7 | import type { Registry } from './Registry' 8 | import { Server } from '../http/Server' 9 | import type { ZenConfig } from '../types/interfaces' 10 | import { validateInstallation } from '../filesystem/validateInstallation' 11 | 12 | export class ZenApp { 13 | /** 14 | * Inidicates if the application has completly booted. 15 | */ 16 | public isBooted: boolean = false 17 | 18 | /** 19 | * A reference to an initialized {@link Registry}. 20 | */ 21 | public registry: Registry 22 | 23 | public nodeServer: NodeHttpsServer | NodeHttpServer 24 | 25 | /** 26 | * This function boots the entire application, prepares the config, Registry and starts the webserver. 27 | */ 28 | public async boot(config?: ZenConfig): Promise { 29 | await loadConfig(config) 30 | createLogger() 31 | await validateInstallation() 32 | 33 | const autoloader = new AutoLoader() 34 | this.registry = await autoloader.createRegistry() 35 | 36 | await this.startServer() 37 | this.isBooted = true 38 | } 39 | 40 | public destroy(): void { 41 | this.nodeServer.close() 42 | } 43 | 44 | /** 45 | * Creates a new webserver, which can be configured inside the config.web property (see {@link config} for more details) 46 | */ 47 | protected async startServer(): Promise { 48 | return new Promise((resolve) => { 49 | const server = new Server(this.registry) 50 | 51 | this.nodeServer = server.listen( 52 | { 53 | host: config.web.host, 54 | port: config.web.port, 55 | }, 56 | () => { 57 | log.success(`ZenTS web-server listening on http://${config.web.host}:${config.web.port}`) 58 | 59 | resolve() 60 | }, 61 | ) 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/controller/controllerFactory.test.ts: -------------------------------------------------------------------------------- 1 | import { ControllerFactory } from '../../src/controller/ControllerFactory' 2 | import { ControllerLoader } from '../../src/controller/ControllerLoader' 3 | import type { Controllers } from '../../src/types' 4 | import DepdendencyInjectionService from '../fixtures/testapp/src/service/DepdendencyInjectionService' 5 | import type DependencyInjectionController from '../fixtures/testapp/src/controller/DependencyInjectionController' 6 | import type ResponseController from '../fixtures/testapp/src/controller/ResponseController' 7 | import { loadFixtureTestAppConfig } from '../helper/loadFixtureTestAppConfig' 8 | 9 | let controllers: Controllers 10 | 11 | describe('ControllerFactory', () => { 12 | beforeAll(async () => { 13 | await loadFixtureTestAppConfig() 14 | 15 | process.env.NODE_ENV = 'development' 16 | const controllerLoader = new ControllerLoader() 17 | controllers = await controllerLoader.load() 18 | }) 19 | 20 | afterAll(() => { 21 | process.env.NODE_ENV = 'test' 22 | }) 23 | 24 | it('should build controller', () => { 25 | const controllerFactory = new ControllerFactory(controllers, null, null, null, null, { 26 | files: [], 27 | filters: new Map(), 28 | }) 29 | 30 | const instance = controllerFactory.build('responsecontroller') 31 | 32 | expect(instance).not.toBeNull() 33 | expect(typeof instance.ping).toBe('function') 34 | 35 | const notExists = controllerFactory.build('loremipsumloremipsumloremipsum') 36 | 37 | expect(notExists).toBeNull() 38 | }) 39 | 40 | it('should inject dependencies', () => { 41 | const controllerFactory = new ControllerFactory(controllers, null, null, null, null, { 42 | files: [], 43 | filters: new Map(), 44 | }) 45 | 46 | const instance = controllerFactory.build( 47 | 'dependencyinjectioncontroller', 48 | ) 49 | 50 | expect(instance).not.toBeNull() 51 | expect(instance.injectedService).toBeInstanceOf(DepdendencyInjectionService) 52 | expect(instance.valueFromInjectedService()).toBe('foo') 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/filesystem/AbstractZenFileLoader.ts: -------------------------------------------------------------------------------- 1 | import type { CommonJSZenModule, LoadModuleResult } from '../types/interfaces' 2 | 3 | import { parse } from 'path' 4 | 5 | /** 6 | * The AbstractZenFileLoader acts as a base class for ZenTS's loader classes (e.g. ControllerLoader). 7 | * It's main purpose is to supply an interface to easy load modules dynamiclly and parsing them to the 8 | * extending classes in a unified way. If you write a new loader, you should use this class to load the 9 | * Node.js modules. 10 | */ 11 | export abstract class AbstractZenFileLoader { 12 | /** 13 | * Load a given module and returns an unitialized version of the module. The exported class is guessed either 14 | * by using the default export (CommonJS) or by using the modules filename to determine the exported member. 15 | * This function will throw an error when no exported member could be found. 16 | * 17 | * @param filePath Absolute path to the module file 18 | */ 19 | protected async loadModule(filePath: string): Promise> { 20 | const module = (await import(filePath)) as CommonJSZenModule 21 | const moduleKeys = Object.keys(module) 22 | const { name: filename } = parse(filePath) 23 | let classModule 24 | 25 | if (moduleKeys.includes('default')) { 26 | classModule = module.default 27 | } else if (moduleKeys.includes(filename)) { 28 | classModule = module[filename] 29 | } else { 30 | throw new Error('Unable to find the exported module member. Please use default export.') 31 | } 32 | 33 | return { 34 | key: filename.toLowerCase(), 35 | module: classModule, 36 | } 37 | } 38 | /** 39 | * Returns all method names of a class. 40 | * 41 | * @param classObjProto The prototype of a class 42 | */ 43 | protected getClassMethods(classObjProto: Record | null): string[] { 44 | if (classObjProto === Object.prototype || !classObjProto) { 45 | return [] 46 | } 47 | 48 | return [ 49 | ...Object.getOwnPropertyNames(classObjProto), 50 | ...this.getClassMethods(Object.getPrototypeOf(classObjProto)), 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/security/JWT.ts: -------------------------------------------------------------------------------- 1 | import { decode, sign, verify } from 'jsonwebtoken' 2 | 3 | import type { JWTOptions } from '../types/interfaces' 4 | import { config } from '../config/config' 5 | import { isObject } from '../utils/isObject' 6 | 7 | export abstract class JWT { 8 | public static async sign(payload: { [key: string]: any }): Promise { 9 | return new Promise((resolve, reject) => { 10 | const options = Object.assign({}, this.getOptions(), { noTimestamp: true }) 11 | 12 | sign(payload, config.security.secretKey, options, (err, jwt: string) => { 13 | if (err) { 14 | return reject(err) 15 | } 16 | 17 | return resolve(jwt) 18 | }) 19 | }) 20 | } 21 | 22 | public static async verify(token: string): Promise { 23 | return new Promise((resolve) => { 24 | const options = Object.assign({}, this.getOptions(), { 25 | ignoreExpiration: true, 26 | ignoreNotBefore: true, 27 | }) 28 | 29 | verify(token, config.security.secretKey, options, (err) => { 30 | if (err) { 31 | return resolve(false) 32 | } 33 | 34 | return resolve(true) 35 | }) 36 | }) 37 | } 38 | 39 | public static decode(token: string): T { 40 | return decode(token, { 41 | json: true, 42 | }) as T 43 | } 44 | 45 | protected static getOptions(): JWTOptions { 46 | const options: JWTOptions = {} 47 | 48 | if (isObject(config.security?.token)) { 49 | const stringKeys: ('issuer' | 'algorithm' | 'subject' | 'jwtid' | 'keyid')[] = [ 50 | 'issuer', 51 | 'algorithm', 52 | 'subject', 53 | 'jwtid', 54 | 'keyid', 55 | ] 56 | 57 | for (const key of stringKeys) { 58 | if (typeof config.security?.token[key] === 'string') { 59 | options[key] = config.security?.token[key] 60 | } 61 | } 62 | 63 | if ( 64 | typeof config.security?.token?.audience === 'string' || 65 | Array.isArray(config.security?.token?.audience) 66 | ) { 67 | options.audience = config.security.token.audience 68 | } 69 | } 70 | 71 | return options 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Roadmap 3 | lang: en-US 4 | meta: 5 | - name: keywords 6 | content: ZenTS guide tutorial documentation roadmap framework mvc TypeScript 7 | --- 8 | 9 | # {{ $frontmatter.title }} 10 | 11 | 12 | [[toc]] 13 | 14 | 15 | ## Introduction 16 | 17 | ZenTS is still under heavy development and not ready for production use yet (breaking changes can be introduces at any time). The Roadmap describes the missing modules, features and tools need to be finished before a stable release (v1.0.0) is going to happen. 18 | 19 | ## REST API support 20 | 21 | With the current controller implementation you can already create REST APIs quite easily. But that isn't enough yet, ZenTS will feature a 1st level support for building REST JSON APIs super fast, with automatic bindings to entities and protected endpoints. 22 | 23 | ## Plugins and middleware 24 | 25 | Lets be honest, no good Node.js framework without some plugin or/and middleware support. Currently that feature is completely missing in ZenTS, but it's planned to add a flexible plugin system to ZenTS, allowing to extend applications easily. This is a huge topic and will be started after ZenTS is more feature complete. 26 | 27 | ## More documentation 28 | 29 | A good documentation is the key to a successful framework. It doesn't help if a framework is full of great features, but if nobody knows how to use them they become useless (or unused). ZenTS's documentation isn't complete yet, even it contains already a lot of guides. But there is more to come: more guides, improve existing guides, a cheat sheet, a better search, a contribution guide, the API reference and so on. 30 | 31 | ## More tests 32 | 33 | The ZenTS repository contains already a couple of tests, but they doesn't cover all parts of the framework yet. They will be added over time, of course, new features also will need tests to be written. 34 | 35 | ## CI builds 36 | 37 | Currently the repository doesn't have any CI integration. Releasing a new version is a manual process which can lead to release mistakes. The plan is to add GitHub actions for the repository, which lints, tests and releases ZenTS automatically. 38 | -------------------------------------------------------------------------------- /src/dependencies/InjectorAction/SessionAction.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GenericControllerInstance, 3 | InjectorFunctionParameter, 4 | RequestConfigControllerUser, 5 | } from '../../types/interfaces' 6 | 7 | import { AbstractAction } from './AbstractAction' 8 | import type { Context } from '../../http/Context' 9 | import type { Injector } from '../Injector' 10 | import { REFLECT_METADATA } from '../../types/enums' 11 | import { SecurityProviderReflectionMetadata } from '../../types/interfaces' 12 | import type { Session } from '../../security/Session' 13 | import { isObject } from '../../utils/isObject' 14 | 15 | export class SessionAction extends AbstractAction { 16 | protected readonly loadedUser: RequestConfigControllerUser 17 | protected readonly context: Context 18 | protected injectedSessions: Session[] 19 | 20 | constructor( 21 | injector: Injector, 22 | context: Context, 23 | loadedUser: RequestConfigControllerUser, 24 | injectedSessions: Session[], 25 | ) { 26 | super(injector) 27 | 28 | this.context = context 29 | this.loadedUser = loadedUser 30 | this.injectedSessions = injectedSessions 31 | } 32 | 33 | public async run( 34 | instance: GenericControllerInstance, 35 | method: string, 36 | ): Promise { 37 | if (!Reflect.hasMetadata(REFLECT_METADATA.SESSION, instance, method)) { 38 | return [] 39 | } 40 | 41 | const factory = this.injector.context.getSessionFactory() 42 | 43 | const metadata = Reflect.getMetadata( 44 | REFLECT_METADATA.SESSION, 45 | instance, 46 | method, 47 | ) as SecurityProviderReflectionMetadata[] 48 | 49 | const parameters = [] 50 | 51 | for (const meta of metadata) { 52 | const session = await factory.build( 53 | meta.name, 54 | this.context, 55 | isObject(this.loadedUser) && this.loadedUser.provider === meta.name 56 | ? this.loadedUser 57 | : undefined, 58 | ) 59 | 60 | this.injectedSessions.push(session) 61 | parameters.push({ 62 | index: meta.index, 63 | value: session, 64 | }) 65 | } 66 | 67 | return parameters 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/controller/controllerLoader.test.ts: -------------------------------------------------------------------------------- 1 | import { ControllerLoader } from '../../src/controller/ControllerLoader' 2 | import { loadFixtureTestAppConfig } from '../helper/loadFixtureTestAppConfig' 3 | 4 | describe('ControllerLoader', () => { 5 | beforeAll(async () => { 6 | await loadFixtureTestAppConfig() 7 | process.env.NODE_ENV = 'development' 8 | }) 9 | afterAll(() => { 10 | process.env.NODE_ENV = 'test' 11 | }) 12 | 13 | it('loads controllers in a given directory correctly', async () => { 14 | const controllerLoader = new ControllerLoader() 15 | const controllers = await controllerLoader.load() 16 | 17 | expect(controllers.has('responsecontroller')).toBe(true) 18 | expect(controllers.get('responsecontroller')).toHaveProperty('module') 19 | expect(typeof controllers.get('responsecontroller').module).toBe('function') 20 | expect(Array.isArray(controllers.get('responsecontroller').routes)).toBe(true) 21 | }) 22 | 23 | it('should handle the @controller annotation correctly', async () => { 24 | const controllerLoader = new ControllerLoader() 25 | const controllers = await controllerLoader.load() 26 | 27 | expect(controllers.has('prefixcontroller')).toBe(true) 28 | }) 29 | 30 | it('should register annotated controller routes correctly', async () => { 31 | const controllerLoader = new ControllerLoader() 32 | const controllers = await controllerLoader.load() 33 | 34 | const responseController = controllers.get('responsecontroller') 35 | const prefixController = controllers.get('prefixcontroller') 36 | 37 | expect(responseController).not.toBeUndefined() 38 | expect(prefixController).not.toBeUndefined() 39 | 40 | expect(responseController).toHaveProperty('routes') 41 | expect(prefixController).toHaveProperty('routes') 42 | 43 | expect(responseController.routes.length).toBeGreaterThanOrEqual(1) 44 | expect(responseController.routes[0]).toStrictEqual({ 45 | method: 'GET', 46 | path: '/ping', 47 | controllerMethod: 'ping', 48 | }) 49 | 50 | expect(prefixController.routes.length).toBeGreaterThanOrEqual(1) 51 | expect(prefixController.routes[0].path).toBe('/prefix/example-without-slash') 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import type { CosmicConfigResult, ZenConfig } from './../types/interfaces' 2 | 3 | import { cosmiconfig } from 'cosmiconfig' 4 | import { defaultConfig } from './default' 5 | import { log } from '../log/logger' 6 | import merge from 'lodash.merge' 7 | import { validateConfig } from './validateConfig' 8 | 9 | const moduleName = 'zen' 10 | 11 | async function loadDefaultFile(searchFrom?: string): Promise { 12 | const explorer = cosmiconfig(moduleName, { 13 | searchPlaces: [ 14 | 'package.json', 15 | '.zenrc', 16 | '.zenrc.js', 17 | 'zen.json', 18 | 'zen.yaml', 19 | 'zen.yml', 20 | 'zen.config.json', 21 | 'zen.config.js', 22 | 'zen.config.yaml', 23 | 'zen.config.yml', 24 | ], 25 | }) 26 | 27 | return await explorer.search(searchFrom) 28 | } 29 | 30 | async function loadEnviormentFile(searchFrom?: string): Promise { 31 | const env = process.env.NODE_ENV ?? 'development' 32 | const explorer = cosmiconfig(moduleName, { 33 | searchPlaces: [ 34 | `zen.${env}.json`, 35 | `zen.${env}.yaml`, 36 | `zen.${env}.yml`, 37 | `zen.${env}.config.json`, 38 | `zen.${env}.config.js`, 39 | `zen.${env}.config.yaml`, 40 | `zen.${env}.config.yml`, 41 | ], 42 | }) 43 | 44 | return await explorer.search(searchFrom) 45 | } 46 | 47 | export let config: ZenConfig = defaultConfig 48 | export let isConfigLoaded: boolean = false 49 | 50 | export async function loadConfig( 51 | manualConfig?: ZenConfig, 52 | searchFrom?: string, 53 | forceLoading: boolean = false, 54 | ): Promise { 55 | if (isConfigLoaded && !forceLoading) { 56 | return 57 | } 58 | 59 | isConfigLoaded = true 60 | 61 | const defaultFileConfig = await loadDefaultFile(searchFrom) 62 | const envFileConfig = await loadEnviormentFile(searchFrom) 63 | 64 | config = merge( 65 | {}, 66 | defaultConfig, 67 | defaultFileConfig ? defaultFileConfig.config : {}, 68 | envFileConfig ? envFileConfig.config : {}, 69 | manualConfig ? manualConfig : {}, 70 | ) 71 | 72 | const { isValid, errors } = validateConfig(config) 73 | 74 | if (!isValid) { 75 | for (const error of errors) { 76 | log.error(error) 77 | 78 | throw new Error('Failed to boot! Config is invalid.') 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/email/EmailFactory.ts: -------------------------------------------------------------------------------- 1 | import type { EmailTemplates, MailOptions } from '../types/types' 2 | 3 | import type { MailResponse } from '../types/interfaces' 4 | import type { Transporter } from 'nodemailer' 5 | import { config } from '../config/config' 6 | import { createTransport } from 'nodemailer' 7 | import { htmlToText } from 'html-to-text' 8 | import { log } from '../log/logger' 9 | import mjml2html from 'mjml' 10 | import { renderString } from 'nunjucks' 11 | 12 | export class EmailFactory { 13 | protected transporter: Transporter 14 | 15 | constructor(protected emailTemplates: EmailTemplates) { 16 | if (config.email?.enable) { 17 | this.transporter = createTransport(config.email) 18 | } else { 19 | this.transporter = null 20 | } 21 | } 22 | 23 | public async send(options: MailOptions): Promise { 24 | if (this.transporter === null) { 25 | throw new Error( 26 | 'Trying to send an E-Mail without proper email configuration. Please enable email in your ZenTS configuration before sending emails', 27 | ) 28 | } else if (!this.emailTemplates.has(options.template)) { 29 | throw new Error(`Email template "${options.template}" not found!`) 30 | } 31 | 32 | const engine = options.engine ?? config.email.engine 33 | const { template, payload } = options 34 | let content = this.emailTemplates.get(template) 35 | 36 | if (engine !== 'plain') { 37 | content = renderString(content, payload) 38 | } 39 | 40 | if (engine === 'mjml') { 41 | const result = mjml2html(content, config.email?.mjml) 42 | 43 | if (result.errors.length) { 44 | log.error(result.errors) 45 | 46 | throw new Error('Failed to render MJML! See error(s) above for more information.') 47 | } 48 | 49 | content = result.html 50 | } 51 | 52 | const data: MailOptions = 53 | typeof config.email.mailOptions === 'undefined' 54 | ? options 55 | : Object.assign({}, config.email.mailOptions, options) 56 | 57 | if (engine !== 'plain') { 58 | data.html = content 59 | 60 | if (config.email?.htmlToText?.enable) { 61 | data.text = htmlToText(content, config.email?.htmlToText) 62 | } 63 | } else if (!data.keepText) { 64 | data.text = content 65 | } 66 | 67 | return (await this.transporter.sendMail(data)) as MailResponse 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /docs/.vuepress/components/LandingPage/Terminal.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Quick Start 5 | 6 | 7 | npm i zents-cli -g 8 | zen create myproject 9 | cd myproject && zen dev 10 | 11 | 12 | 13 | 14 | 15 | 121 | -------------------------------------------------------------------------------- /src/http/Server.ts: -------------------------------------------------------------------------------- 1 | import type { HTTPVersion, Instance as RouterInstance } from 'find-my-way' 2 | import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'http' 3 | 4 | import type { Controllers } from '../types/types' 5 | import { IncomingRequest } from './IncomingRequest' 6 | import type { Server as NodeHttpsServer } from 'https' 7 | import type { Registry } from '../core/Registry' 8 | import { config } from '../config/config' 9 | import { createServer as createHttpServer } from 'http' 10 | import { createServer as createHttpsServer } from 'https' 11 | 12 | export class Server { 13 | public router: RouterInstance 14 | protected controllers: Controllers 15 | constructor(registry: Registry) { 16 | const securityProviders = registry.getSecurityProviders() 17 | 18 | this.controllers = registry.getControllers() 19 | this.router = registry.factories.router.generate( 20 | this.controllers, 21 | securityProviders, 22 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 23 | async (config, route, req, res, params): Promise => { 24 | const incomingRequest = new IncomingRequest() 25 | 26 | await incomingRequest.handle( 27 | registry.factories.request, 28 | route, 29 | req, 30 | res, 31 | params, 32 | config, 33 | securityProviders, 34 | ) 35 | }, 36 | ) 37 | } 38 | public listen(...args: any[]): NodeHttpServer | NodeHttpsServer { 39 | const server = !config.web.https.enable 40 | ? createHttpServer(this.createRequestHandler()) 41 | : this.createHttpsServer() 42 | 43 | return server.listen(...args) 44 | } 45 | protected createRequestHandler(): (req: IncomingMessage, res: ServerResponse) => void { 46 | const handler = (req: IncomingMessage, res: ServerResponse): void => 47 | this.router.lookup(req, res) 48 | 49 | return handler 50 | } 51 | protected createHttpsServer(): NodeHttpsServer { 52 | const usePem = 53 | typeof config.web?.https?.key !== 'undefined' && 54 | typeof config.web?.https?.cert !== 'undefined' 55 | 56 | const options = usePem 57 | ? { 58 | key: config.web.https.key, 59 | cert: config.web.https.cert, 60 | } 61 | : { 62 | pfx: config.web.https.pfx, 63 | passphrase: config.web.https.passphrase, 64 | } 65 | 66 | return createHttpsServer(options, this.createRequestHandler()) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](http://zents.dev) 2 | 3 |  4 |  5 |  6 |  7 |  8 |  9 |  10 |  11 | 12 | [Website](https://zents.dev) | [Documentation](https://zents.dev/guide/) | [Roadmap](https://zents.dev/roadmap) | [Changelog](https://github.com/sahachide/ZenTS/blob/master/CHANGELOG.md) | [Twitter](https://twitter.com/ZenTS_Framework) | [npm](https://www.npmjs.com/package/zents) 13 | 14 | ZenTS is a fast and modern MVC framework for Node.js & TypeScript. 15 | 16 | ## Quick Start 17 | 18 | ZenTS is a [Node.js](https://nodejs.org) framework and available through the 19 | [npm registry](https://www.npmjs.com/). 20 | 21 | Before you can start using ZenTS, you need to [download and install Node.js](https://nodejs.org/en/download/) for your operation system. After installing [Node.js](https://nodejs.org) you can create a fresh ZenTS project with the CLI: 22 | 23 | ```shell 24 | npm i zents-cli -g 25 | zen create myproject 26 | cd myproject 27 | zen dev 28 | ``` 29 | 30 | The above command will install the latest version of the CLI globally and creates a new ZenTS project in the `myproject` folder. 31 | 32 | ## Features 33 | 34 | - Robust controller and service containers 35 | - Super fast routing system 36 | - Autoloading capabilities, never manage a list of project dependencies by yourself again 37 | - Session and user management with redis, ORM or filesystem storage 38 | - Ships with TypeORM out-of-the-box 39 | - Includes a battle tested template engine (Nunjucks) 40 | - Easy accessible request and response context 41 | - Email handling with responsive render engine 42 | - Auto response workflows 43 | - Validation 44 | - [Many, many more](https://zents.dev) 45 | 46 | ## Documentation 47 | 48 | Head over to the [official website](https://zents.dev) and read the [documentation](https://zents.dev/guide/). 49 | 50 | ## License 51 | 52 | MIT 53 | -------------------------------------------------------------------------------- /src/core/Registry.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Controllers, 3 | EmailTemplates, 4 | Entities, 5 | RegistryFactories, 6 | SecurityProviders, 7 | Services, 8 | TemplateEngineLoaderResult, 9 | } from '../types' 10 | 11 | import type { Connection } from 'typeorm' 12 | import { ControllerFactory } from '../controller/ControllerFactory' 13 | import { DB_TYPE } from '../types' 14 | import { DatabaseContainer } from '../database/DatabaseContainer' 15 | import { EmailFactory } from '../email' 16 | import type { Redis } from 'ioredis' 17 | import { RequestFactory } from '../http/RequestFactory' 18 | import { RouterFactory } from '../router/RouterFactory' 19 | import { ServiceFactory } from '../service/ServiceFactory' 20 | import { SessionFactory } from '../security/SessionFactory' 21 | 22 | export class Registry { 23 | public factories: RegistryFactories 24 | 25 | constructor( 26 | protected readonly controllers: Controllers, 27 | protected readonly services: Services, 28 | templateData: TemplateEngineLoaderResult, 29 | emailTemplates: EmailTemplates, 30 | protected readonly databaseContainer: DatabaseContainer, 31 | protected readonly entities: Entities, 32 | protected readonly securityProviders: SecurityProviders, 33 | ) { 34 | const sessionFactory = new SessionFactory(securityProviders, databaseContainer) 35 | const emailFactory = new EmailFactory(emailTemplates) 36 | 37 | this.factories = { 38 | router: new RouterFactory(), 39 | controller: new ControllerFactory( 40 | controllers, 41 | emailFactory, 42 | sessionFactory, 43 | securityProviders, 44 | databaseContainer, 45 | templateData, 46 | ), 47 | request: new RequestFactory(this), 48 | service: new ServiceFactory( 49 | services, 50 | emailFactory, 51 | sessionFactory, 52 | securityProviders, 53 | databaseContainer, 54 | ), 55 | session: sessionFactory, 56 | email: emailFactory, 57 | } 58 | } 59 | 60 | public getControllers(): Controllers { 61 | return this.controllers 62 | } 63 | 64 | public getServices(): Services { 65 | return this.services 66 | } 67 | 68 | public getEntities(): Entities { 69 | return this.entities 70 | } 71 | 72 | public getConnection(): Connection { 73 | return this.databaseContainer.get(DB_TYPE.ORM) 74 | } 75 | 76 | public getRedisClient(): Redis { 77 | return this.databaseContainer.get(DB_TYPE.REDIS) 78 | } 79 | 80 | public getSecurityProviders(): SecurityProviders { 81 | return this.securityProviders 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/http/Request.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingParams, ParsedBody, QueryString } from '../types/interfaces' 2 | import { parse, stringify } from 'qs' 3 | 4 | import type { IncomingMessage } from 'http' 5 | import type { JsonValue } from 'type-fest' 6 | import { RequestHeader } from './RequestHeader' 7 | import type { Socket } from 'net' 8 | import { config } from '../config/config' 9 | import parseurl from 'parseurl' 10 | 11 | export class Request { 12 | public header: RequestHeader 13 | protected _body: JsonValue 14 | protected _query: QueryString 15 | protected _params: IncomingParams 16 | protected _pathname: string = null 17 | constructor(public nodeReq: IncomingMessage, parsedBody: ParsedBody, params: IncomingParams) { 18 | this.header = new RequestHeader(nodeReq) 19 | this.query = parse(parseurl(nodeReq).query as string, config.web?.querystring) 20 | this.body = parsedBody ? parsedBody.fields : {} 21 | this.pathname = parseurl(this.nodeReq).pathname 22 | this._params = params 23 | } 24 | get body(): JsonValue { 25 | return this._body 26 | } 27 | set body(body: JsonValue) { 28 | this._body = body 29 | } 30 | get params(): IncomingParams { 31 | return this._params 32 | } 33 | set params(params: IncomingParams) { 34 | this._params = params 35 | } 36 | get query(): QueryString { 37 | return this._query 38 | } 39 | set query(query: QueryString) { 40 | this._query = query 41 | } 42 | get querystring(): string { 43 | return stringify(this._query) 44 | } 45 | set querystring(querystring: string) { 46 | this.query = parse(querystring, config.web?.querystring) 47 | } 48 | get search(): string { 49 | if (!this.querystring.length) { 50 | return '' 51 | } 52 | 53 | return `?${this.querystring}` 54 | } 55 | get url(): string { 56 | return this.nodeReq.url 57 | } 58 | set url(url: string) { 59 | this.nodeReq.url = url 60 | } 61 | get httpMethod(): string { 62 | return this.nodeReq.method.toLowerCase() 63 | } 64 | set httpMethod(httpMethod: string) { 65 | this.nodeReq.method = httpMethod 66 | } 67 | get pathname(): string { 68 | return this._pathname 69 | } 70 | set pathname(pathname: string) { 71 | this._pathname = pathname 72 | } 73 | get httpVersion(): string { 74 | return this.nodeReq.httpVersion.toLowerCase() 75 | } 76 | get httpVersionMajor(): number { 77 | return this.nodeReq.httpVersionMajor 78 | } 79 | get httpVersionMinor(): number { 80 | return this.nodeReq.httpVersionMinor 81 | } 82 | get socket(): Socket { 83 | return this.nodeReq.socket 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /test/fixtures/testapp/src/controller/RequestTestController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Context, 3 | Controller, 4 | get, 5 | params, 6 | post, 7 | query, 8 | request, 9 | Request, 10 | validate, 11 | validation, 12 | body, 13 | } from '../../../../../src' 14 | 15 | import type { QueryString } from '../../../../../src/types/interfaces' 16 | 17 | export default class extends Controller { 18 | @get('/request-test/:foo/:bar') 19 | public paramsTest(@params params: { foo?: string; bar?: string; paramsSetter?: string }) { 20 | const foo = params.foo 21 | const bar = params.bar 22 | 23 | params = { 24 | paramsSetter: 'test', 25 | } 26 | 27 | return { 28 | foo, 29 | bar, 30 | paramsSetter: params.paramsSetter, 31 | } 32 | } 33 | 34 | @get('/request-test-queryparams') 35 | public querystringTest( 36 | @query 37 | query: { 38 | [key: string]: string 39 | }, 40 | @request req: Request, 41 | ) { 42 | const parsed: { 43 | [key: string]: string | QueryString | string[] | QueryString[] 44 | } = { 45 | foo: query.foo, 46 | bar: query.bar, 47 | search: req.search, 48 | querystring: req.querystring, 49 | querystringSetter: null, 50 | } 51 | 52 | req.querystring = 'querystringSetter=foo' 53 | parsed.querystringSetter = req.query.querystringSetter 54 | req.querystring = '' 55 | parsed.resetQuerystringSearch = req.search 56 | 57 | return parsed 58 | } 59 | 60 | @get('/url-test') 61 | public urlTest(@request req: Request) { 62 | const requestData = { 63 | requestUrl: req.url, 64 | pathname: req.pathname, 65 | httpMethod: req.httpMethod, 66 | httpVersion: req.httpVersion, 67 | httpVersionMajor: req.httpVersionMajor, 68 | httpVersionMinor: req.httpVersionMinor, 69 | } 70 | 71 | req.url = '/rewrite' 72 | req.httpMethod = 'POST' 73 | req.pathname = '' 74 | 75 | const setterTests = { 76 | requestUrl: req.url, 77 | pathname: req.pathname, 78 | httpMethod: req.httpMethod, 79 | } 80 | 81 | return { 82 | request: requestData, 83 | setterTests, 84 | } 85 | } 86 | 87 | @post('/request-test-validate') 88 | @validation( 89 | validate.object({ 90 | foo: validate.string().required().alphanum().min(3).max(30), 91 | bar: validate.string().required().alphanum().min(3).max(30), 92 | }), 93 | ) 94 | public validationTest( 95 | @body 96 | { foo, bar }: { foo: string; bar: string }, 97 | ) { 98 | return { foo, bar } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require('path') 3 | 4 | module.exports = { 5 | root: true, 6 | env: { 7 | es6: true, 8 | node: true, 9 | }, 10 | parser: '@typescript-eslint/parser', 11 | parserOptions: { 12 | project: path.resolve(__dirname, './tsconfig.json'), 13 | sourceType: 'module', 14 | tsconfigRootDir: __dirname, 15 | }, 16 | plugins: ['@typescript-eslint'], 17 | extends: [ 18 | 'eslint:recommended', 19 | 'plugin:@typescript-eslint/eslint-recommended', 20 | 'plugin:@typescript-eslint/recommended', 21 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 22 | 'plugin:import/errors', 23 | 'plugin:import/warnings', 24 | 'plugin:import/typescript', 25 | 'plugin:prettier/recommended', 26 | ], 27 | rules: { 28 | '@typescript-eslint/ban-types': 'error', 29 | '@typescript-eslint/ban-ts-comment': 'off', 30 | '@typescript-eslint/explicit-function-return-type': 'error', 31 | '@typescript-eslint/member-ordering': 'error', 32 | '@typescript-eslint/no-empty-function': ['error', { allow: ['constructors'] }], 33 | '@typescript-eslint/no-empty-interface': 'error', 34 | '@typescript-eslint/no-explicit-any': 'off', 35 | '@typescript-eslint/no-for-in-array': 'error', 36 | '@typescript-eslint/no-inferrable-types': 'off', 37 | '@typescript-eslint/no-misused-new': 'error', 38 | '@typescript-eslint/no-parameter-properties': 'off', 39 | '@typescript-eslint/no-this-alias': 'error', 40 | '@typescript-eslint/no-unused-vars': ['error', { args: 'all', argsIgnorePattern: '^_' }], 41 | '@typescript-eslint/no-var-requires': 'error', 42 | '@typescript-eslint/prefer-for-of': 'error', 43 | '@typescript-eslint/prefer-nullish-coalescing': 'error', 44 | '@typescript-eslint/prefer-string-starts-ends-with': 'error', 45 | '@typescript-eslint/unified-signatures': 'error', 46 | '@typescript-eslint/explicit-module-boundary-types': 'off', 47 | 'constructor-super': 'error', 48 | 'curly': 'error', 49 | 'default-case': 'off', 50 | 'dot-notation': 'error', 51 | 'guard-for-in': 'error', 52 | 'max-classes-per-file': ['error', 1], 53 | 'no-bitwise': 'error', 54 | 'no-caller': 'error', 55 | 'no-console': 'error', 56 | 'no-empty-function': 'off', 57 | 'no-eval': 'error', 58 | 'no-fallthrough': 'off', 59 | 'no-new-wrappers': 'error', 60 | 'no-throw-literal': 'error', 61 | 'no-undef-init': 'error', 62 | 'object-shorthand': 'error', 63 | 'radix': 'error', 64 | 'sort-imports': 'error', 65 | 'import/no-unresolved': 'off', 66 | 'import/no-default-export': 'error', 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /test/decorators/routing.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { REFLECT_METADATA } from '../../src/types/enums' 4 | import { get, post, put, del, options } from '../../src/decorators/' 5 | 6 | class MyController { 7 | @get('/get-route-test') 8 | public getTest() {} 9 | 10 | @post('/post-route-test') 11 | public postTest() {} 12 | 13 | @put('/put-route-test') 14 | public putTest() {} 15 | 16 | @del('/del-route-test') 17 | public delTest() {} 18 | 19 | @options('/options-route-test') 20 | public optionsTest() {} 21 | } 22 | 23 | describe('Routing decorator', () => { 24 | it('should register GET routes', () => { 25 | const method = 'getTest' 26 | const httpMethod = Reflect.getMetadata( 27 | REFLECT_METADATA.HTTP_METHOD, 28 | MyController.prototype, 29 | method, 30 | ) 31 | const urlPath = Reflect.getMetadata(REFLECT_METADATA.URL_PATH, MyController.prototype, method) 32 | 33 | expect(httpMethod).toBe('get') 34 | expect(urlPath).toBe('/get-route-test') 35 | }) 36 | 37 | it('should register POST routes', () => { 38 | const method = 'postTest' 39 | const httpMethod = Reflect.getMetadata( 40 | REFLECT_METADATA.HTTP_METHOD, 41 | MyController.prototype, 42 | method, 43 | ) 44 | const urlPath = Reflect.getMetadata(REFLECT_METADATA.URL_PATH, MyController.prototype, method) 45 | 46 | expect(httpMethod).toBe('post') 47 | expect(urlPath).toBe('/post-route-test') 48 | }) 49 | 50 | it('should register PUT routes', () => { 51 | const method = 'putTest' 52 | const httpMethod = Reflect.getMetadata( 53 | REFLECT_METADATA.HTTP_METHOD, 54 | MyController.prototype, 55 | method, 56 | ) 57 | const urlPath = Reflect.getMetadata(REFLECT_METADATA.URL_PATH, MyController.prototype, method) 58 | 59 | expect(httpMethod).toBe('put') 60 | expect(urlPath).toBe('/put-route-test') 61 | }) 62 | 63 | it('should register DEL routes', () => { 64 | const method = 'delTest' 65 | const httpMethod = Reflect.getMetadata( 66 | REFLECT_METADATA.HTTP_METHOD, 67 | MyController.prototype, 68 | method, 69 | ) 70 | const urlPath = Reflect.getMetadata(REFLECT_METADATA.URL_PATH, MyController.prototype, method) 71 | 72 | expect(httpMethod).toBe('delete') 73 | expect(urlPath).toBe('/del-route-test') 74 | }) 75 | 76 | it('should register OPTIONS routes', () => { 77 | const method = 'optionsTest' 78 | const httpMethod = Reflect.getMetadata( 79 | REFLECT_METADATA.HTTP_METHOD, 80 | MyController.prototype, 81 | method, 82 | ) 83 | const urlPath = Reflect.getMetadata(REFLECT_METADATA.URL_PATH, MyController.prototype, method) 84 | 85 | expect(httpMethod).toBe('options') 86 | expect(urlPath).toBe('/options-route-test') 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/http/IncomingRequest.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from 'http' 2 | import type { IncomingParams, IncomingRequestAuthenticateResult, Route } from '../types/interfaces' 3 | import type { RequestConfig, SecurityProviders } from '../types/types' 4 | 5 | import { Context } from './Context' 6 | import { REQUEST_TYPE } from '../types/enums' 7 | import type { RequestFactory } from './RequestFactory' 8 | import { config } from '../config' 9 | 10 | export class IncomingRequest { 11 | public async handle( 12 | factory: RequestFactory, 13 | route: Route, 14 | req: IncomingMessage, 15 | res: ServerResponse, 16 | params: IncomingParams, 17 | requestConfig: RequestConfig, 18 | securityProviders: SecurityProviders, 19 | ): Promise { 20 | const context = await this.buildContext(req, res, params, route) 21 | const authentication = await this.authenticate(route.authProvider, context, securityProviders) 22 | 23 | if (authentication.isAuth) { 24 | if (requestConfig.type === REQUEST_TYPE.CONTROLLER) { 25 | requestConfig.loadedUser = { 26 | provider: route.authProvider, 27 | user: authentication.user, 28 | sessionId: authentication.sessionId, 29 | } 30 | } 31 | 32 | if (context.isValid) { 33 | const handler = factory.build(context, requestConfig, route) 34 | 35 | await handler.run() 36 | } else { 37 | context.error.badData('Bad Data', { 38 | errors: context.validationErrors, 39 | }) 40 | } 41 | } else if (authentication.securityProvider) { 42 | await authentication.securityProvider.forbidden(context) 43 | } else { 44 | context.error.forbidden() 45 | } 46 | } 47 | 48 | protected async buildContext( 49 | req: IncomingMessage, 50 | res: ServerResponse, 51 | params: IncomingParams, 52 | route: Route, 53 | ): Promise { 54 | const context = new Context() 55 | 56 | await context.build(req, res, params, route) 57 | 58 | return context 59 | } 60 | 61 | protected async authenticate( 62 | authProvider: unknown, 63 | context: Context, 64 | securityProviders: SecurityProviders, 65 | ): Promise { 66 | if (!config.security?.enable || typeof authProvider !== 'string') { 67 | return { 68 | isAuth: true, 69 | } 70 | } 71 | 72 | const securityProvider = securityProviders.get(authProvider) 73 | 74 | if (typeof securityProviders === 'undefined') { 75 | return { 76 | isAuth: false, 77 | } 78 | } 79 | 80 | const authorize = await securityProvider.authorize(context) 81 | 82 | return { 83 | isAuth: authorize.isAuth, 84 | user: authorize.user, 85 | sessionId: authorize.sessionId, 86 | securityProvider, 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/security/stores/RedisSessionStoreAdapter.ts: -------------------------------------------------------------------------------- 1 | import { DB_TYPE } from '../../types/enums' 2 | import type { DatabaseContainer } from '../../database/DatabaseContainer' 3 | import type { Redis } from 'ioredis' 4 | import type { SecurityProviderOptions } from '../SecurityProviderOptions' 5 | import type { SessionStoreAdapter } from '../../types/interfaces' 6 | import { log } from '../../log/logger' 7 | import ms from 'ms' 8 | 9 | export class RedisSessionStoreAdapter implements SessionStoreAdapter { 10 | protected readonly redisClient: Redis 11 | protected readonly prefix: string 12 | protected readonly expire: number 13 | protected readonly keepTTL: boolean 14 | 15 | constructor(databaseContainer: DatabaseContainer, providerOptions: SecurityProviderOptions) { 16 | this.redisClient = databaseContainer.get(DB_TYPE.REDIS) 17 | this.prefix = providerOptions.storePrefix 18 | this.expire = providerOptions.expireInMS 19 | this.keepTTL = providerOptions.redisKeepTTL 20 | } 21 | 22 | public async create(sessionId: string): Promise { 23 | const prefixedSessionId = this.getPrefixedSessionId(sessionId) 24 | const record = await this.redisClient.get(prefixedSessionId) 25 | 26 | if (record) { 27 | return 28 | } 29 | 30 | const options: any[] = [] 31 | 32 | if (this.expire !== -1) { 33 | options.push('PX', this.expire) 34 | } else { 35 | options.push('PX', ms('7d')) 36 | } 37 | 38 | if (this.keepTTL) { 39 | options.push('KEEPTTL') 40 | } 41 | 42 | await this.redisClient.set(prefixedSessionId, '{}', ...options) 43 | } 44 | 45 | public async load(sessionId: string): Promise> { 46 | const record = await this.redisClient.get(this.getPrefixedSessionId(sessionId)) 47 | 48 | let data = {} 49 | 50 | if (!record || !record.length) { 51 | return data 52 | } 53 | 54 | try { 55 | data = JSON.parse(record) as Record 56 | } catch (e) { 57 | data = {} 58 | log.error(e) 59 | } 60 | 61 | return data 62 | } 63 | 64 | public async persist(sessionId: string, data: Record): Promise { 65 | let record: string 66 | 67 | try { 68 | record = JSON.stringify(data) 69 | } catch (e) { 70 | record = '' 71 | log.error(e) 72 | } 73 | 74 | await this.redisClient.set(this.getPrefixedSessionId(sessionId), record) 75 | } 76 | 77 | public async remove(sessionId: string): Promise { 78 | if (!(await this.has(sessionId))) { 79 | return 80 | } 81 | 82 | await this.redisClient.del(this.getPrefixedSessionId(sessionId)) 83 | } 84 | 85 | public async has(sessionId: string): Promise { 86 | return !!(await this.redisClient.exists(this.getPrefixedSessionId(sessionId))) 87 | } 88 | 89 | private getPrefixedSessionId(sessionId: string): string { 90 | return `${this.prefix}${sessionId}` 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/core/AutoLoader.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Controllers, 3 | EmailTemplates, 4 | Entities, 5 | SecurityProviders, 6 | Services, 7 | TemplateEngineLoaderResult, 8 | } from '../types/' 9 | 10 | import { ControllerLoader } from '../controller/ControllerLoader' 11 | import { DatabaseContainer } from '../database/DatabaseContainer' 12 | import { EmailTemplateLoader } from '../email/EmailTemplateLoader' 13 | import { EntityLoader } from '../database/EntityLoader' 14 | import { Registry } from './Registry' 15 | import { SecurityProviderLoader } from '../security/SecurityProviderLoader' 16 | import { ServiceLoader } from '../service/ServiceLoader' 17 | import { TemplateEngineLoader } from '../template/TemplateEngineLoader' 18 | import { createConnection } from '../database/createConnection' 19 | import { createRedisClient } from '../database/createRedisClient' 20 | 21 | export class AutoLoader { 22 | public async createRegistry(): Promise { 23 | const [ 24 | controllers, 25 | services, 26 | templateData, 27 | emailTemplates, 28 | entities, 29 | connection, 30 | redisClient, 31 | ] = await Promise.all([ 32 | this.loadControllers(), 33 | this.loadServices(), 34 | this.loadTemplateData(), 35 | this.loadEmailTemplates(), 36 | this.loadEntities(), 37 | createConnection(), 38 | createRedisClient(), 39 | ]) 40 | 41 | const databaseContainer = new DatabaseContainer(connection, redisClient) 42 | 43 | const securityProviders = this.loadSecurityProviders(entities, databaseContainer) 44 | const registry = new Registry( 45 | controllers, 46 | services, 47 | templateData, 48 | emailTemplates, 49 | databaseContainer, 50 | entities, 51 | securityProviders, 52 | ) 53 | 54 | return registry 55 | } 56 | 57 | protected loadSecurityProviders( 58 | entities: Entities, 59 | databaseContainer: DatabaseContainer, 60 | ): SecurityProviders { 61 | const securityProviderLoader = new SecurityProviderLoader() 62 | 63 | return securityProviderLoader.load(entities, databaseContainer) 64 | } 65 | 66 | protected async loadControllers(): Promise { 67 | const controllerLoader = new ControllerLoader() 68 | 69 | return await controllerLoader.load() 70 | } 71 | 72 | protected async loadServices(): Promise { 73 | const serviceLoader = new ServiceLoader() 74 | 75 | return await serviceLoader.load() 76 | } 77 | 78 | protected async loadTemplateData(): Promise { 79 | const templateEngineLoader = new TemplateEngineLoader() 80 | 81 | return await templateEngineLoader.load() 82 | } 83 | 84 | protected async loadEntities(): Promise { 85 | const entityLoader = new EntityLoader() 86 | 87 | return await entityLoader.load() 88 | } 89 | 90 | protected async loadEmailTemplates(): Promise { 91 | const emailTemplateLoader = new EmailTemplateLoader() 92 | 93 | return await emailTemplateLoader.load() 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Welcome to the ZenTS documentation 3 | lang: en-US 4 | meta: 5 | - name: keywords 6 | content: ZenTS guide tutorial documentation framework mvc TypeScript 7 | --- 8 | 9 | # {{ $frontmatter.title }} 10 | 11 | ZenTS is a modern Node.js & TypeScript MVC framework for building rich web-applications. The documentation covers all important parts of ZenTS. 12 | 13 | ::: danger 14 | ZenTS is still **_under heavy development_** and **_not_** ready for production use yet (breaking changes can be introduces at any time). Please report any issues on [GitHub](https://github.com/sahachide/ZenTS/issues). 15 | ::: 16 | 17 | ## Getting started 18 | 19 | In the getting started guides you'll install ZenTS and create a new ZenTS application with the CLI. Furthermore you'll create your first _Hello World_ example application and get in touch with the base building blocks of a ZenTS application. 20 | 21 | Read the Getting started guide [here](./gettingstarted/installation.md). 22 | 23 | ## Controller & Routing 24 | 25 | Controllers are a key component of every MVC application. In the controller guide, you'll learn how to create and use them in a ZenTS application. On the other hand, routes will connect controllers to the outside world by binding them to an URL. 26 | 27 | Read the controller guide [here](./advancedguides/controllers.md), the routing guide can be found [here](./advancedguides/routing.md). 28 | 29 | ## Request & response context 30 | 31 | When dealing with web requests, a strong partner is needed to handle user input and create appropriate responses. 32 | 33 | Read the [request guide](./advancedguides/request.md) and the response guide [here](./advancedguides/response.md) to learn how to handle requests and responses in a ZenTS application. 34 | 35 | ## Databases interactions 36 | 37 | Data is the new gold and even smaller web applications have some kind of database attached to it. ZenTS ships with a powerful and battle-tested ORM, which has builtin support for a lot of different databases. Furthermore ZenTS comes with a (optional) Redis client out-of-the-box, so you can directly make use of the super fast key/value storage. 38 | 39 | Check out the database guide [here](./advancedguides/database.md). The Redis guide can be found [here](./advancedguides/redis.md). 40 | 41 | ## Template engine 42 | 43 | Having a fast and solid template engine at your hand can be very useful to create beautiful and interactive websites. ZenTS ships with a rich template engine that you can use to generate server-side HTML code. 44 | 45 | Read the template engine guide [here](./advancedguides/templates.md). 46 | 47 | ## And more 48 | 49 | There is much more to learn about ZenTS. ZenTS is a highly [configurable](./configuration.md) framework, has [service containers](./advancedguides/services.md) support and allows you to write applications after the single-responsibility principle using [dependency injection](./advancedguides/dependency_injection.md). Also make sure to read about the awesome [CLI](./cli.md), which supercharge your application with a couple of helpful CLI commands. 50 | -------------------------------------------------------------------------------- /src/security/stores/DatabaseSessionStoreAdapter.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseSessionStoreAdapterEntity, SessionStoreAdapter } from '../../types/interfaces' 2 | import { MoreThan, Repository } from 'typeorm' 3 | 4 | import { DB_TYPE } from '../../types/enums' 5 | import type { DatabaseContainer } from '../../database/DatabaseContainer' 6 | import type { DatabaseSessionStoreAdapterEntityClass } from '../../types/types' 7 | import type { SecurityProviderOptions } from '../SecurityProviderOptions' 8 | import dayjs from 'dayjs' 9 | import { log } from '../../log/logger' 10 | 11 | export class DatabaseSessionStoreAdapter implements SessionStoreAdapter { 12 | protected readonly repository: Repository 13 | protected readonly entity: DatabaseSessionStoreAdapterEntityClass 14 | protected readonly expire: number 15 | 16 | constructor(databaseContainer: DatabaseContainer, providerOptions: SecurityProviderOptions) { 17 | this.repository = databaseContainer 18 | .get(DB_TYPE.ORM) 19 | .getRepository(providerOptions.dbStoreEntity) 20 | this.entity = providerOptions.dbStoreEntity as DatabaseSessionStoreAdapterEntityClass 21 | this.expire = providerOptions.expireInMS 22 | } 23 | 24 | public async create(sessionId: string): Promise { 25 | const record = await this.getRecord(sessionId) 26 | 27 | if (record) { 28 | return 29 | } 30 | 31 | const session = new this.entity() 32 | 33 | session.id = sessionId 34 | session.data = JSON.stringify({}) 35 | session.created_at = new Date() 36 | session.expired_at = 37 | this.expire > 0 ? dayjs().add(this.expire, 'ms').toDate() : dayjs().add(7, 'day').toDate() 38 | 39 | await this.repository.save(session) 40 | } 41 | 42 | public async load(sessionId: string): Promise> { 43 | const record = await this.getRecord(sessionId) 44 | let data = {} 45 | 46 | if (!record) { 47 | return data 48 | } 49 | 50 | try { 51 | data = JSON.parse(record.data) as Record 52 | } catch (e) { 53 | log.error(e) 54 | } 55 | 56 | return data 57 | } 58 | 59 | public async persist(sessionId: string, data: Record): Promise { 60 | let record: string 61 | 62 | try { 63 | record = JSON.stringify(data) 64 | } catch (e) { 65 | record = '{}' 66 | log.error(e) 67 | } 68 | 69 | await this.repository.update( 70 | { 71 | id: sessionId, 72 | }, 73 | { 74 | data: record, 75 | }, 76 | ) 77 | } 78 | 79 | public async remove(sessionId: string): Promise { 80 | await this.repository.delete({ 81 | id: sessionId, 82 | }) 83 | } 84 | 85 | public async has(sessionId: string): Promise { 86 | return !!(await this.getRecord(sessionId)) 87 | } 88 | 89 | protected async getRecord(sessionId: string): Promise { 90 | return await this.repository.findOne({ 91 | id: sessionId, 92 | expired_at: MoreThan(new Date()), 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/http/Cookie.ts: -------------------------------------------------------------------------------- 1 | import type { Except, JsonValue } from 'type-fest' 2 | import { parse, serialize } from 'cookie' 3 | 4 | import type { CookieOptions } from '../types/interfaces' 5 | import type { IncomingHttpHeaders } from 'http' 6 | import { config } from '../config/config' 7 | import dayjs from 'dayjs' 8 | import ms from 'ms' 9 | 10 | export class Cookie { 11 | protected data = new Map< 12 | string, 13 | { 14 | options: CookieOptions 15 | value: JsonValue 16 | } 17 | >() 18 | private modifiedKeys = new Set() 19 | 20 | constructor(headers: IncomingHttpHeaders) { 21 | const cookies = parse(headers.cookie ?? '') 22 | 23 | for (const [key, value] of Object.entries(cookies)) { 24 | try { 25 | const cookieValue = JSON.parse(value) as JsonValue 26 | 27 | this.data.set(key, { 28 | value: cookieValue, 29 | options: this.getCookieOptions(), 30 | }) 31 | } catch (e) { 32 | // silent 33 | } 34 | } 35 | } 36 | public set( 37 | key: string, 38 | value: JsonValue, 39 | options?: Except, 40 | ): void { 41 | this.modifiedKeys.add(key) 42 | this.data.set(key, { 43 | value, 44 | options: this.getCookieOptions(options), 45 | }) 46 | } 47 | public get(key: string): T { 48 | if (!this.data.has(key)) { 49 | return undefined 50 | } 51 | 52 | return this.data.get(key).value as T 53 | } 54 | public has(key: string): boolean { 55 | return this.data.has(key) 56 | } 57 | public serialize(): string { 58 | const cookies = [] 59 | 60 | for (const [key, cookie] of this.data) { 61 | if (!this.modifiedKeys.has(key)) { 62 | continue 63 | } 64 | 65 | const options: CookieOptions & { 66 | expires?: Date 67 | } = cookie.options 68 | 69 | if (typeof cookie.options.expire === 'number') { 70 | options.expires = dayjs().add(cookie.options.expire, 'millisecond').toDate() 71 | delete cookie.options.expire 72 | } else if (typeof cookie.options.expire === 'string') { 73 | options.expires = dayjs().add(ms(cookie.options.expire), 'millisecond').toDate() 74 | delete cookie.options.expire 75 | } 76 | 77 | try { 78 | cookies.push(serialize(key, JSON.stringify(cookie.value), options)) 79 | } catch (e) { 80 | // silent 81 | } 82 | } 83 | 84 | return cookies.length ? cookies.join('; ') : '' 85 | } 86 | private getCookieOptions(options?: Except): CookieOptions { 87 | let cookieOptions = {} 88 | 89 | if (typeof options !== 'undefined') { 90 | if (config.web?.cookie?.strategy === 'merge') { 91 | cookieOptions = Object.assign({}, config.web?.cookie, options) 92 | } else { 93 | cookieOptions = options 94 | } 95 | } else if (config.web?.cookie) { 96 | cookieOptions = config.web?.cookie 97 | } 98 | 99 | return cookieOptions 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/security/SecurityProviderLoader.ts: -------------------------------------------------------------------------------- 1 | import type { Entities, SecurityProviders } from '../types/types' 2 | import type { 3 | SecurityProviderOption, 4 | SecurityProviderOptionEntities, 5 | SecurityStrategy, 6 | } from '../types/interfaces' 7 | 8 | import { Class } from 'type-fest' 9 | import { CookieSecurityStrategy } from './strategies/CookieSecurityStrategy' 10 | import type { DatabaseContainer } from '../database/DatabaseContainer' 11 | import { HeaderSecurityStrategy } from './strategies/HeaderSecurityStrategy' 12 | import { HybridSecurityStrategy } from './strategies/HybridSecurityStrategy' 13 | import { SecurityProvider } from './SecurityProvider' 14 | import { SecurityProviderOptions } from './SecurityProviderOptions' 15 | import { SecurityResponse } from './SecurityResponse' 16 | import { SessionStoreAdapterFactory } from './SessionStoreAdapterFactory' 17 | import { config } from '../config/config' 18 | import { isObject } from '../utils/isObject' 19 | import { log } from '../log/logger' 20 | 21 | export class SecurityProviderLoader { 22 | public load(entities: Entities, databaseContainer: DatabaseContainer): SecurityProviders { 23 | const adapterFactory = new SessionStoreAdapterFactory(databaseContainer) 24 | const providers = new Map() as SecurityProviders 25 | 26 | if (!isObject(config.security) || !config.security.enable) { 27 | return providers 28 | } 29 | 30 | for (const providerConfig of config.security.providers) { 31 | const options = new SecurityProviderOptions( 32 | providerConfig, 33 | this.getEntities(entities, providerConfig), 34 | ) 35 | 36 | if (providers.has(options.name)) { 37 | log.warn(`Security provider "${options.name}" is already registered!`) 38 | 39 | continue 40 | } 41 | 42 | const response = new SecurityResponse(options) 43 | const adapter = adapterFactory.build(options) 44 | const provider = new SecurityProvider( 45 | options, 46 | response, 47 | adapter, 48 | this.getSecurityStrategy(), 49 | databaseContainer, 50 | ) 51 | 52 | providers.set(options.name, provider) 53 | } 54 | 55 | return providers 56 | } 57 | 58 | protected getEntities( 59 | entities: Entities, 60 | providerConfig: SecurityProviderOption, 61 | ): SecurityProviderOptionEntities { 62 | const user = 63 | typeof providerConfig.entity === 'string' 64 | ? entities.get(providerConfig.entity.toLowerCase()) 65 | : null 66 | 67 | let dbStore: Class 68 | 69 | if (providerConfig.store?.type === 'database') { 70 | dbStore = entities.get(providerConfig.store?.entity.toLowerCase()) 71 | } 72 | 73 | return { 74 | user, 75 | dbStore, 76 | } 77 | } 78 | 79 | protected getSecurityStrategy(): SecurityStrategy { 80 | let securityStrategy: SecurityStrategy 81 | 82 | if (config.security?.strategy === 'header') { 83 | securityStrategy = new HeaderSecurityStrategy() 84 | } else if (config.security?.strategy === 'hybrid') { 85 | securityStrategy = new HybridSecurityStrategy() 86 | } else { 87 | securityStrategy = new CookieSecurityStrategy() 88 | } 89 | 90 | return securityStrategy 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/http/requesthandlers/ControllerRequestHandler.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ControllerMethodReturnType, 3 | GenericControllerInstance, 4 | RequestConfigControllerUser, 5 | Route, 6 | } from '../../types' 7 | 8 | import { Context } from '../Context' 9 | import { ControllerFactory } from '../../controller/ControllerFactory' 10 | import { Injector } from '../../dependencies/Injector' 11 | import type { JsonObject } from 'type-fest' 12 | import type { Session } from '../../security/Session' 13 | import { TemplateResponse } from '../../template/TemplateResponse' 14 | import { isObject } from '../../utils/isObject' 15 | 16 | export class ControllerRequestHandler { 17 | protected controllerInstance: GenericControllerInstance 18 | protected controllerMethod: string 19 | protected injector: Injector 20 | 21 | private didRun: boolean = false 22 | 23 | constructor( 24 | protected readonly context: Context, 25 | controllerFactory: ControllerFactory, 26 | controllerKey: string, 27 | protected readonly loadedUser: RequestConfigControllerUser, 28 | { controllerMethod }: Route, 29 | ) { 30 | this.controllerInstance = controllerFactory.build(controllerKey) 31 | this.controllerMethod = controllerMethod 32 | this.injector = controllerFactory.getInjector() 33 | } 34 | 35 | public async run(): Promise { 36 | if (this.didRun) { 37 | return 38 | } else if (typeof this.controllerInstance[this.controllerMethod] !== 'function') { 39 | throw new Error(`Fatal Error: ${this.controllerMethod} isn't a function.`) 40 | } 41 | 42 | this.didRun = true 43 | const injectedSessions: Session[] = [] 44 | const injectedParameters = await this.injector.injectFunctionParameters( 45 | this.controllerInstance, 46 | this.controllerMethod, 47 | this.context, 48 | this.loadedUser, 49 | injectedSessions, 50 | ) 51 | const result = await this.controllerInstance[this.controllerMethod](...injectedParameters) 52 | 53 | if (!this.context.res.isSend) { 54 | this.handleResult(result) 55 | } 56 | 57 | await this.saveSessionStores(injectedSessions) 58 | } 59 | 60 | protected handleResult(result: ControllerMethodReturnType): void { 61 | if (!result) { 62 | return 63 | } 64 | 65 | if (this.context.req.httpMethod === 'post' && !this.context.res.isStatuscodeSetManual) { 66 | this.context.res.setStatusCode(201) 67 | } 68 | 69 | if (result instanceof TemplateResponse) { 70 | return this.context.res.html(result.html).send() 71 | } else if (isObject(result)) { 72 | return this.context.res.json(result as JsonObject).send() 73 | } else if (Array.isArray(result)) { 74 | return this.context.res.json(result).send() 75 | } else if (typeof result === 'string') { 76 | return this.context.res.text(result).send() 77 | } else { 78 | return this.context.error.internal( 79 | 'Controller returned an unsupported value. Please return an object, an array or a string.', 80 | ) 81 | } 82 | } 83 | 84 | protected async saveSessionStores(injectedSessions: Session[]): Promise { 85 | for (const session of injectedSessions) { 86 | await session.data.save() 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/dependencies/Injector.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConnectionAction, 3 | DependenciesAction, 4 | EntityManagerAction, 5 | RedisAction, 6 | RepositoryAction, 7 | SecurityProviderAction, 8 | SessionAction, 9 | } from './InjectorAction' 10 | import type { 11 | GenericControllerInstance, 12 | InjectModuleInstance, 13 | InjectorFunctionParameter, 14 | RequestConfigControllerUser, 15 | } from '../types/interfaces' 16 | 17 | import { AllContextAction } from './InjectorAction/AllContextAction' 18 | import { BodyContextAction } from './InjectorAction/BodyContextAction' 19 | import type { Class } from 'type-fest' 20 | import type { Context } from '../http/Context' 21 | import { CookieContextAction } from './InjectorAction/CookieContextAction' 22 | import { EmailAction } from './InjectorAction/EmailAction' 23 | import { ErrorContextAction } from './InjectorAction/ErrorContextAction' 24 | import type { ModuleContext } from './ModuleContext' 25 | import { ParamsContextAction } from './InjectorAction/ParamsContextAction' 26 | import { QueryContextAction } from './InjectorAction/QueryContextAction' 27 | import { RequestContextAction } from './InjectorAction/RequestContextAction' 28 | import { ResponseContextAction } from './InjectorAction/ResponseContextAction' 29 | import type { Session } from '../security/Session' 30 | 31 | export class Injector { 32 | constructor(public context: ModuleContext) {} 33 | 34 | public inject(module: Class, ctorArgs: unknown[]): T { 35 | const instance: InjectModuleInstance = new module(...ctorArgs) 36 | const actions = [ 37 | new DependenciesAction(this), 38 | new ConnectionAction(this), 39 | new RedisAction(this), 40 | new EntityManagerAction(this), 41 | ] 42 | 43 | for (const action of actions) { 44 | action.run(instance) 45 | } 46 | 47 | return instance as T 48 | } 49 | 50 | public async injectFunctionParameters( 51 | instance: GenericControllerInstance, 52 | method: string, 53 | context: Context, 54 | loadedUser: RequestConfigControllerUser, 55 | injectedSessions: Session[], 56 | ): Promise { 57 | const actions = [ 58 | new RepositoryAction(this), 59 | new SecurityProviderAction(this), 60 | new SessionAction(this, context, loadedUser, injectedSessions), 61 | new BodyContextAction(this, context), 62 | new QueryContextAction(this, context), 63 | new ParamsContextAction(this, context), 64 | new CookieContextAction(this, context), 65 | new RequestContextAction(this, context), 66 | new ResponseContextAction(this, context), 67 | new EmailAction(this), 68 | new ErrorContextAction(this, context), 69 | new AllContextAction(this, context), 70 | ] 71 | let params: InjectorFunctionParameter[] = [] 72 | 73 | for (const action of actions) { 74 | const result = await action.run(instance, method) 75 | 76 | if (Array.isArray(result)) { 77 | if (result.length) { 78 | params = [...params, ...result.filter((val) => val !== null)] 79 | } 80 | } else if (result) { 81 | params.push(result) 82 | } 83 | } 84 | 85 | if (!params.length) { 86 | return [] 87 | } 88 | 89 | params.sort((a, b) => a.index - b.index) 90 | 91 | return params.map((param) => param.value as unknown) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/http/Context.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from 'http' 2 | import type { 3 | IncomingParams, 4 | ParsedBody, 5 | QueryString, 6 | RequestValidationError, 7 | Route, 8 | } from '../types/interfaces' 9 | 10 | import { BodyParser } from './BodyParser' 11 | import { Cookie } from './Cookie' 12 | import type { JsonObject } from 'type-fest' 13 | import { Request } from './Request' 14 | import { Response } from './Response' 15 | import { ResponseError } from './ResponseError' 16 | import { config } from '../config/config' 17 | 18 | export class Context { 19 | private container!: { 20 | body: ParsedBody 21 | cookie: Cookie 22 | req: Request 23 | res: Response 24 | error: ResponseError 25 | } 26 | private requestBodyValidationErrors: RequestValidationError[] = [] 27 | private isBuild: boolean = false 28 | private isReqBodyValid: boolean = true 29 | 30 | public async build( 31 | request: IncomingMessage, 32 | response: ServerResponse, 33 | params: IncomingParams, 34 | route: Route, 35 | ): Promise { 36 | if (this.isBuild) { 37 | return 38 | } 39 | 40 | this.isBuild = true 41 | 42 | const method = request.method.toLowerCase() 43 | let body: ParsedBody = null 44 | 45 | if (method === 'post' || method === 'put') { 46 | const bodyParser = new BodyParser() 47 | 48 | body = await bodyParser.parse(request) 49 | } 50 | 51 | if (typeof route.validationSchema !== 'undefined') { 52 | const validationResult = route.validationSchema.validate(body.fields) 53 | 54 | if (validationResult.error) { 55 | this.isReqBodyValid = false 56 | this.requestBodyValidationErrors = validationResult.error.details.map((error) => { 57 | return { 58 | message: error.message, 59 | path: error.path, 60 | } 61 | }) 62 | } else { 63 | body.fields = validationResult.value as JsonObject 64 | } 65 | } 66 | 67 | const cookie = config.web?.cookie?.enable ? new Cookie(request.headers) : null 68 | const req = new Request(request, body, params) 69 | const res = new Response(response, req, cookie) 70 | const error = new ResponseError(res) 71 | 72 | this.container = { 73 | body, 74 | cookie, 75 | req, 76 | res, 77 | error, 78 | } 79 | } 80 | 81 | get req(): Request { 82 | return this.container.req 83 | } 84 | 85 | get request(): Request { 86 | return this.container.req 87 | } 88 | 89 | get res(): Response { 90 | return this.container.res 91 | } 92 | 93 | get response(): Response { 94 | return this.container.res 95 | } 96 | 97 | get body(): JsonObject { 98 | return this.container.body?.fields ?? null 99 | } 100 | 101 | get query(): QueryString { 102 | return this.container.req.query 103 | } 104 | 105 | get params(): IncomingParams { 106 | return this.container.req.params 107 | } 108 | 109 | get cookie(): Cookie | null { 110 | return this.container.cookie 111 | } 112 | 113 | get error(): ResponseError { 114 | return this.container.error 115 | } 116 | 117 | get isValid(): boolean { 118 | return this.isReqBodyValid 119 | } 120 | 121 | get validationErrors(): RequestValidationError[] { 122 | return this.requestBodyValidationErrors 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/filesystem/FS.ts: -------------------------------------------------------------------------------- 1 | import { join, resolve } from 'path' 2 | 3 | import { path as appDir } from 'app-root-path' 4 | import { config } from '../config/config' 5 | import { log } from '../log/logger' 6 | import { promises } from 'fs' 7 | import { readDirRecursive } from './readDirRecursiveGenerator' 8 | 9 | const illegalRegEx = /[/?<>\\:*|"]/g 10 | // eslint-disable-next-line no-control-regex 11 | const controlRegEx = /[\x00-\x1f\x80-\x9f]/g 12 | const reservedRegEx = /^\.+$/ 13 | const windowsReservedRegEx = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i 14 | const windowsTrailingRegEx = /[. ]+$/ 15 | 16 | export abstract class fs { 17 | // @ts-ignore 18 | public static isTsNode = !!process[Symbol.for('ts-node.register.instance')] 19 | 20 | public static async exists(pathToDirOrFile: string): Promise { 21 | let success = true 22 | 23 | try { 24 | await promises.access(pathToDirOrFile) 25 | } catch (error) { 26 | success = false 27 | } 28 | 29 | return success 30 | } 31 | 32 | public static async readDir(dir: string, recursive: boolean = true): Promise { 33 | const files = [] 34 | 35 | if (recursive) { 36 | for await (const file of readDirRecursive(dir)) { 37 | files.push(file) 38 | } 39 | } else { 40 | const dirContent = await promises.readdir(dir, { 41 | withFileTypes: true, 42 | }) 43 | 44 | for (const content of dirContent) { 45 | if (content.isFile()) { 46 | files.push(resolve(dir, content.name)) 47 | } 48 | } 49 | } 50 | 51 | return files 52 | } 53 | 54 | public static async writeJson(filePath: string, json: Record): Promise { 55 | let success = true 56 | 57 | try { 58 | await promises.writeFile(filePath, JSON.stringify(json)) 59 | } catch (e) { 60 | success = false 61 | log.error(e) 62 | } 63 | 64 | return success 65 | } 66 | 67 | public static async readJson(filePath: string): Promise { 68 | let json = null 69 | 70 | try { 71 | const content = await promises.readFile(filePath, { 72 | encoding: 'utf-8', 73 | }) 74 | json = JSON.parse(content) as T 75 | } catch (e) { 76 | log.error(e) 77 | } 78 | 79 | return json 80 | } 81 | 82 | public static resolveZenPath(key: string): string { 83 | const basePath = !this.isDev() ? config.paths.base.dist : config.paths.base.src 84 | const paths = config.paths as { [key: string]: string } 85 | 86 | return join(basePath, paths[key]) 87 | } 88 | 89 | public static resolveZenFileExtension(filename?: string): string { 90 | if (!filename) { 91 | return !this.isDev() ? '.js' : '.ts' 92 | } 93 | 94 | return !this.isDev() ? `${filename}.js` : `${filename}.ts` 95 | } 96 | 97 | public static sanitizeFilename(filename: string): string { 98 | const sanitized = filename 99 | .replace(illegalRegEx, '') 100 | .replace(controlRegEx, '') 101 | .replace(reservedRegEx, '') 102 | .replace(windowsReservedRegEx, '') 103 | .replace(windowsTrailingRegEx, '') 104 | 105 | return sanitized.substring(0, 255) 106 | } 107 | 108 | public static appDir(): string { 109 | return appDir 110 | } 111 | 112 | public static isDev(): boolean { 113 | return process.env.NODE_ENV === 'development' || this.isTsNode 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/security/SecurityProviderOptions.ts: -------------------------------------------------------------------------------- 1 | import type { SecurityProviderOption, SecurityProviderOptionEntities } from '../types/interfaces' 2 | 3 | import type { Class } from 'type-fest' 4 | import SecurePassword from 'secure-password' 5 | import { fs } from '../filesystem/FS' 6 | import { join } from 'path' 7 | import ms from 'ms' 8 | 9 | export class SecurityProviderOptions { 10 | constructor( 11 | public options: SecurityProviderOption, 12 | protected entities: SecurityProviderOptionEntities, 13 | ) {} 14 | 15 | get name(): string { 16 | return this.options.name ?? 'default' 17 | } 18 | 19 | get algorithm(): 'bcrypt' | 'argon2id' { 20 | return this.options.algorithm ?? 'argon2id' 21 | } 22 | 23 | get userEntity(): Class { 24 | return this.entities.user 25 | } 26 | 27 | get expireInMS(): number { 28 | const value = this.options.expire 29 | 30 | if (typeof value === 'undefined') { 31 | return -1 32 | } else if (typeof value === 'number') { 33 | return value 34 | } 35 | 36 | return ms(value) 37 | } 38 | 39 | get loginUrl(): string { 40 | return this.options.url?.login ?? '/login' 41 | } 42 | 43 | get logoutUrl(): string { 44 | return this.options.url?.logout ?? '/logout' 45 | } 46 | 47 | get loginRedirectUrl(): string { 48 | return this.options.redirect?.login ?? '/' 49 | } 50 | 51 | get logoutRedirectUrl(): string { 52 | return this.options.redirect?.logout ?? '/' 53 | } 54 | 55 | get failedRedirectUrl(): string { 56 | return this.options.redirect?.failed ?? '/' 57 | } 58 | 59 | get forbiddenRedirectUrl(): string { 60 | return this.options.redirect?.forbidden ?? '/' 61 | } 62 | 63 | get memLimit(): number { 64 | return this.options.argon?.memLimit ?? SecurePassword.MEMLIMIT_DEFAULT 65 | } 66 | 67 | get opsLimit(): number { 68 | return this.options.argon?.opsLimit ?? SecurePassword.OPSLIMIT_DEFAULT 69 | } 70 | 71 | get saltRounds(): number { 72 | return this.options.bcrypt?.saltRounds ?? 12 73 | } 74 | 75 | get identifierColumn(): string { 76 | return this.options.table?.identifierColumn ?? 'id' 77 | } 78 | 79 | get passwordColumn(): string { 80 | return this.options.table?.passwordColumn ?? 'password' 81 | } 82 | 83 | get usernameField(): string { 84 | return this.options.fields?.username ?? 'username' 85 | } 86 | 87 | get passwordField(): string { 88 | return this.options.fields?.password ?? 'password' 89 | } 90 | 91 | get storeType(): 'redis' | 'database' | 'file' { 92 | return this.options.store?.type 93 | } 94 | 95 | get responseType(): 'json' | 'redirect' { 96 | return this.options.responseType ?? 'redirect' 97 | } 98 | 99 | get storePrefix(): string { 100 | const defaultPrefix = 'zen_' 101 | 102 | return this.options.store?.type === 'redis' 103 | ? this.options.store?.prefix ?? defaultPrefix 104 | : defaultPrefix 105 | } 106 | 107 | get redisKeepTTL(): boolean { 108 | const defaultValue = false 109 | 110 | return this.options.store?.type === 'redis' 111 | ? this.options.store?.keepTTL ?? defaultValue 112 | : defaultValue 113 | } 114 | 115 | get dbStoreEntity(): Class { 116 | return this.entities.dbStore ?? null 117 | } 118 | 119 | get fileStoreFolder(): string { 120 | const defaultValue = join(fs.appDir(), 'session') 121 | 122 | return this.options.store?.type === 'file' 123 | ? this.options.store?.folder ?? defaultValue 124 | : defaultValue 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/controller/ControllerLoader.ts: -------------------------------------------------------------------------------- 1 | import type { Controllers, HTTPMethod, Route, ValidationSchema } from '../types/' 2 | 3 | import { AbstractZenFileLoader } from '../filesystem/AbstractZenFileLoader' 4 | import type { Class } from 'type-fest' 5 | import Joi from 'joi' 6 | import { REFLECT_METADATA } from '../types/enums' 7 | import { fs } from '../filesystem/FS' 8 | import { log } from '../log/logger' 9 | 10 | export class ControllerLoader extends AbstractZenFileLoader { 11 | public async load(): Promise { 12 | const controllers = new Map() as Controllers 13 | const filePaths = ( 14 | await fs.readDir(fs.resolveZenPath('controller')) 15 | ).filter((filePath: string) => 16 | filePath.toLowerCase().endsWith(fs.resolveZenFileExtension('controller')), 17 | ) 18 | 19 | for (const filePath of filePaths) { 20 | const { key, module } = await this.loadModule(filePath) 21 | const keyMetadata = Reflect.getMetadata(REFLECT_METADATA.CONTROLLER_KEY, module) as 22 | | string 23 | | undefined 24 | const controllerKey = 25 | typeof keyMetadata !== 'string' ? key : `${keyMetadata}Controller`.toLowerCase() 26 | 27 | if (controllers.has(controllerKey)) { 28 | log.warn(`Controller with key "${controllerKey}" is already registered!`) 29 | 30 | continue 31 | } 32 | 33 | controllers.set(controllerKey, { 34 | module, 35 | routes: this.loadControllerRoutes(module), 36 | }) 37 | } 38 | 39 | return controllers 40 | } 41 | 42 | protected loadControllerRoutes(classModule: Class): Route[] { 43 | const routes: Route[] = [] 44 | const methods = this.getClassMethods(classModule.prototype) 45 | let prefix = Reflect.getMetadata(REFLECT_METADATA.URL_PREFIX, classModule) as string 46 | 47 | if (!prefix) { 48 | prefix = '' 49 | } else if (prefix.endsWith('/')) { 50 | prefix = prefix.slice(0, -1) 51 | } 52 | 53 | for (const method of methods) { 54 | if (method === 'constructor') { 55 | continue 56 | } 57 | 58 | const httpMethod = Reflect.getMetadata( 59 | REFLECT_METADATA.HTTP_METHOD, 60 | classModule.prototype, 61 | method, 62 | ) as HTTPMethod 63 | 64 | if (typeof httpMethod !== 'string') { 65 | continue 66 | } 67 | 68 | const route: Route = { 69 | method: httpMethod.toUpperCase() as HTTPMethod, 70 | path: '', 71 | controllerMethod: method, 72 | } 73 | 74 | let urlPath = Reflect.getMetadata( 75 | REFLECT_METADATA.URL_PATH, 76 | classModule.prototype, 77 | method, 78 | ) as string 79 | 80 | if (prefix.length && !urlPath.startsWith('/')) { 81 | urlPath = `/${urlPath}` 82 | } 83 | 84 | route.path = `${prefix}${urlPath}` 85 | 86 | const authProvider = Reflect.getMetadata( 87 | REFLECT_METADATA.AUTH_PROVIDER, 88 | classModule.prototype, 89 | method, 90 | ) as string 91 | 92 | if (typeof authProvider === 'string') { 93 | route.authProvider = authProvider 94 | } 95 | 96 | const validationSchema = Reflect.getMetadata( 97 | REFLECT_METADATA.VALIDATION_SCHEMA, 98 | classModule.prototype, 99 | method, 100 | ) as ValidationSchema 101 | 102 | if (Joi.isSchema(validationSchema)) { 103 | route.validationSchema = validationSchema 104 | } 105 | 106 | routes.push(route) 107 | } 108 | 109 | return routes 110 | } 111 | } 112 | --------------------------------------------------------------------------------