├── test ├── setup-file.ts ├── template.test.ts ├── type-validator.test.ts ├── validation-error.test.ts ├── path-resolver.test.ts ├── functional-validator.test.ts └── type-check.test.ts ├── .prettierignore ├── .gitignore ├── assets ├── define-api-demo.gif ├── logo.svg └── doc │ └── en │ ├── response-caching.md │ ├── middleware.md │ ├── dynamic-type-annotation.md │ ├── schema-api.md │ └── validation-engine.md ├── demo ├── vanilla │ ├── jsconfig.json │ ├── src │ │ ├── utils │ │ │ ├── constant.js │ │ │ ├── imageBase64.d.ts │ │ │ ├── constant.d.ts │ │ │ └── imageBase64.js │ │ └── fake-store │ │ │ ├── schema │ │ │ ├── id-schema.js │ │ │ ├── category-schema.js │ │ │ ├── date-range-schema.js │ │ │ ├── limit-and-sort-schema.js │ │ │ ├── cart-schema.js │ │ │ ├── product-info-schema.js │ │ │ └── user-schema.js │ │ │ ├── user │ │ │ └── index.js │ │ │ ├── index.js │ │ │ ├── cart │ │ │ └── index.js │ │ │ └── product │ │ │ └── index.js │ ├── .gitignore │ ├── package.json │ ├── vite.config.js │ ├── main.js │ ├── index.html │ ├── javascript.svg │ └── public │ │ └── vite.svg ├── iife │ ├── dom │ │ └── index.js │ ├── fake-store │ │ ├── schema │ │ │ ├── id-schema.js │ │ │ ├── date-range-schema.js │ │ │ ├── limit-and-sort-schema.js │ │ │ ├── cart-schema.js │ │ │ ├── product-info-schema.js │ │ │ └── user-schema.js │ │ ├── index.js │ │ └── route │ │ │ ├── user.js │ │ │ ├── cart.js │ │ │ └── product.js │ ├── index.html │ └── main.js └── api-spec │ └── postman.postman_collection.json ├── .husky └── pre-commit ├── .prettierrc ├── .vscode └── settings.json ├── lib ├── core │ ├── out-of-paradigm │ │ ├── get-type.ts │ │ └── config-inherit.ts │ ├── validation-engine │ │ ├── rule-set │ │ │ ├── union-rules.ts │ │ │ ├── intersection-rules.ts │ │ │ └── rule-set.ts │ │ ├── validators │ │ │ ├── functional-validator.injectable.ts │ │ │ ├── type-validator.injectable.ts │ │ │ ├── regexp-validator.provider.ts │ │ │ ├── parameter-descriptor-validator.injectable.ts │ │ │ └── array-validator.injectable.ts │ │ ├── validation-error │ │ │ └── validation-error.ts │ │ ├── validation-engine.injectable.ts │ │ └── schema-type │ │ │ └── schema-type.ts │ ├── api-factory │ │ └── request-pipe │ │ │ ├── cache-strategy │ │ │ ├── memory-cache.provider.ts │ │ │ ├── local-storage-cache.provider.ts │ │ │ └── session-storage-cache.provider.ts │ │ │ └── cache-pipe.injectable.ts │ ├── index.ts │ ├── scheduled-task │ │ └── scheduled-task.injectable.ts │ ├── layer-builder │ │ └── layer-builder.injectable.ts │ ├── karman │ │ └── karman.ts │ └── request-strategy │ │ └── fetch.injectable.ts ├── assets │ └── METADATA.ts ├── types │ ├── scheduled-task.type.ts │ ├── ioc.type.ts │ ├── decorator.type.ts │ ├── payload-def.type.ts │ ├── final-api.type.ts │ ├── common.type.ts │ ├── rules.type.ts │ ├── hooks.type.ts │ ├── karman.type.ts │ └── http.type.ts ├── abstract │ ├── parameter-validator.abstract.ts │ ├── request-strategy.abstract.ts │ ├── request-pipe.abstract.ts │ └── cache-strategy.abstract.ts ├── decorator │ ├── Expose.decorator.ts │ ├── Injectable.decorator.ts │ └── IOCContainer.decorator.ts ├── utils │ ├── template.provider.ts │ ├── type-check.provider.ts │ └── path-resolver.provider.ts └── index.ts ├── .npmignore ├── scripts ├── utils │ ├── delay.js │ ├── copy-file.js │ ├── time-log.js │ ├── ansi.js │ └── empty-directory.js ├── emit-declaration.js ├── rollup │ ├── rollup-bundle.js │ └── rollup-config.js └── browser-server.js ├── babel.config.js ├── COPYRIGHT.txt ├── .eslintrc.cjs ├── package.json └── jest.config.js /test/setup-file.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .husky 4 | docs 5 | test/_coverage 6 | # README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .env.* 4 | 5 | dist 6 | test/_coverage 7 | 8 | .DS_Store -------------------------------------------------------------------------------- /assets/define-api-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vic0627/karman/HEAD/assets/define-api-demo.gif -------------------------------------------------------------------------------- /demo/vanilla/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | # npm run lint:ts 5 | # npm run lint 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "printWidth": 120, 5 | "arrowParens": "always" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "markdown-preview-github-styles.colorTheme": "dark", 3 | "liveServer.settings.port": 5501 4 | } -------------------------------------------------------------------------------- /lib/core/out-of-paradigm/get-type.ts: -------------------------------------------------------------------------------- 1 | export default function getType(...types: T[]) { 2 | return null as unknown as T; 3 | } 4 | -------------------------------------------------------------------------------- /demo/iife/dom/index.js: -------------------------------------------------------------------------------- 1 | const $id = (id) => document.getElementById(id); 2 | 3 | export const send = $id("send"); 4 | export const set = $id("set"); 5 | -------------------------------------------------------------------------------- /demo/vanilla/src/utils/constant.js: -------------------------------------------------------------------------------- 1 | export default { 2 | min: 1000 * 60, 3 | /** @private */ 4 | install(k) { 5 | Object.defineProperty(k, "_constant", { value: this }); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /demo/vanilla/src/utils/imageBase64.d.ts: -------------------------------------------------------------------------------- 1 | export default function convertToBase64(file: File): Promise; 2 | 3 | declare module "@vic0627/karman" { 4 | interface KarmanDependencies { 5 | _convertToBase64: typeof convertToBase64; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/assets/METADATA.ts: -------------------------------------------------------------------------------- 1 | export const META_PARAMTYPES = "design:paramtypes"; 2 | 3 | export const META_EXPOSE = "design:expose-module"; 4 | 5 | export const META_UNION = "design:union-type"; 6 | 7 | export const META_INTERSECTION = "design:intersection-type"; 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # dir 2 | demo 3 | .vscode 4 | .husky 5 | lib 6 | declarations 7 | scripts 8 | node_modules 9 | test 10 | assets 11 | 12 | # files 13 | .eslintrc.cjs 14 | .gitignore 15 | .prettierignore 16 | .prettierrc 17 | babel.config.js 18 | jest.config.js 19 | tsconfig.json -------------------------------------------------------------------------------- /demo/vanilla/src/utils/constant.d.ts: -------------------------------------------------------------------------------- 1 | interface Constant { 2 | min: number; 3 | } 4 | 5 | declare const _constant: Constant; 6 | 7 | export default _constant; 8 | 9 | declare module "@vic0627/karman" { 10 | interface KarmanDependencies { 11 | _constant: Constant; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/types/scheduled-task.type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * - `true` - 可清除的任務 3 | * - `false` - 不可清除的任務 4 | */ 5 | type PopSignal = boolean; 6 | 7 | /** 8 | * 排程任務 9 | * @param now 當前時間,由排程管理器注入 10 | * @returns 返回 `true` 時,排程管理器將剃除此任務 11 | */ 12 | export type Task = (now: number) => PopSignal; 13 | -------------------------------------------------------------------------------- /lib/core/out-of-paradigm/config-inherit.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep, merge } from "lodash-es"; 2 | 3 | export function configInherit(baseObj: O, ...objs: O[]): O { 4 | const copy = cloneDeep(baseObj); 5 | const combination = merge(copy, ...objs); 6 | 7 | return combination; 8 | } 9 | -------------------------------------------------------------------------------- /lib/types/ioc.type.ts: -------------------------------------------------------------------------------- 1 | import type { ClassSignature } from "./common.type"; 2 | 3 | export type Provider = [token: symbol, instance: {}]; 4 | 5 | export type Importer = [ 6 | token: symbol, 7 | injectableInfo: { 8 | constructor: ClassSignature; 9 | requirements: symbol[]; 10 | }, 11 | ]; 12 | -------------------------------------------------------------------------------- /lib/abstract/parameter-validator.abstract.ts: -------------------------------------------------------------------------------- 1 | import { ParamRules } from "@/types/rules.type"; 2 | 3 | export type ValidateOption = { required: boolean; rule: ParamRules; param: string; value: any }; 4 | 5 | export default abstract class Validator { 6 | public abstract validate(option: ValidateOption): void; 7 | } 8 | -------------------------------------------------------------------------------- /demo/iife/fake-store/schema/id-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType } from "../../../../dist/karman.js"; 2 | 3 | export default defineSchemaType("Id", { 4 | /** 5 | * identifier number 6 | * @min 1 7 | */ 8 | id: { 9 | required: true, 10 | rules: ["int", { min: 1 }], 11 | type: 1, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/schema/id-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType } from "@vic0627/karman"; 2 | 3 | export default defineSchemaType("Id", { 4 | /** 5 | * identifier number 6 | * @min 1 7 | * @type {number} 8 | */ 9 | id: { 10 | required: true, 11 | rules: ["int", { min: 1 }], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /scripts/utils/delay.js: -------------------------------------------------------------------------------- 1 | module.exports = async function (delay, callback) { 2 | return await new Promise((resolve, reject) => { 3 | const t = setTimeout(() => { 4 | if (typeof callback === "function") callback(resolve, reject); 5 | else resolve(); 6 | 7 | clearTimeout(t); 8 | }, delay ?? 10); 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [["@babel/preset-env", { targets: { node: "current" } }, "@babel/preset-typescript"]], 3 | plugins: [ 4 | [ 5 | "import", 6 | { 7 | libraryName: "lodash-es", 8 | libraryDirectory: "", 9 | camel2DashComponentName: false, 10 | }, 11 | ], 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /demo/vanilla/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /lib/types/decorator.type.ts: -------------------------------------------------------------------------------- 1 | import { ClassSignature } from "./common.type"; 2 | 3 | export interface IOCOptions { 4 | /** 5 | * Providers does not contain any dependencies required. 6 | */ 7 | provides?: ClassSignature[]; 8 | /** 9 | * An import moudle can also be a provider of another moudule. 10 | */ 11 | imports?: ClassSignature[]; 12 | exports?: E[]; 13 | } 14 | -------------------------------------------------------------------------------- /scripts/utils/copy-file.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const timeLog = require("./time-log.js") 3 | // const path = require("path"); 4 | 5 | module.exports = (sourcePath, destinationPath) => { 6 | fs.copyFile(sourcePath, destinationPath, (err) => { 7 | if (err) { 8 | console.error(`copy ${sourcePath} failed`, err); 9 | } else { 10 | timeLog(`${sourcePath} has copied`); 11 | } 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /scripts/emit-declaration.js: -------------------------------------------------------------------------------- 1 | const copyFile = require("./utils/copy-file.js"); 2 | const { resolve } = require("path"); 3 | 4 | const getPath = (...paths) => resolve(__dirname, "../", ...paths); 5 | const declarationPath = getPath("./declarations/index.d.ts"); 6 | const copy = () => copyFile(declarationPath, getPath("./dist/karman.d.ts")); 7 | 8 | const EMIT = process.argv[2] === "--emit"; 9 | 10 | if (EMIT) copy(); 11 | else module.exports = copy; 12 | -------------------------------------------------------------------------------- /lib/core/validation-engine/rule-set/union-rules.ts: -------------------------------------------------------------------------------- 1 | import RuleSet from "@/core/validation-engine/rule-set/rule-set"; 2 | import { ParamRules } from "@/types/rules.type"; 3 | 4 | export default class UnionRules extends RuleSet { 5 | protected readonly errorType: string = "UnionRules"; 6 | get valid(): boolean { 7 | return this.rules.length > this.errors.length; 8 | } 9 | 10 | constructor(...rules: ParamRules[]) { 11 | super(...rules); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /demo/vanilla/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanilla", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@rollup/plugin-alias": "^5.1.0", 13 | "@rollup/plugin-node-resolve": "^15.2.3", 14 | "vite": "^5.1.4" 15 | }, 16 | "dependencies": { 17 | "@vic0627/karman": "^1.2.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/core/validation-engine/rule-set/intersection-rules.ts: -------------------------------------------------------------------------------- 1 | import RuleSet from "@/core/validation-engine/rule-set/rule-set"; 2 | import { ParamRules } from "@/types/rules.type"; 3 | 4 | export default class IntersectionRules extends RuleSet { 5 | protected readonly errorType: string = "IntersectionRules"; 6 | public get valid(): boolean { 7 | return !this.errors.length; 8 | } 9 | 10 | constructor(...rules: ParamRules[]) { 11 | super(...rules); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lib/decorator/Expose.decorator.ts: -------------------------------------------------------------------------------- 1 | import { META_EXPOSE } from "@/assets/METADATA"; 2 | import { ClassDecorator } from "@/types/common.type"; 3 | 4 | /** 5 | * @deprecated Define metadata that represents which targets should be exposed to the IoC. 6 | * @param name The module name that needs to be registered. 7 | */ 8 | export default function Expose(name?: string): ClassDecorator { 9 | return (target) => { 10 | Reflect.defineMetadata(META_EXPOSE, name ?? target.name, target); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /demo/iife/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 10 |
11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /lib/abstract/request-strategy.abstract.ts: -------------------------------------------------------------------------------- 1 | import RequestDetail, { HttpBody, HttpConfig, ReqStrategyTypes, XhrResponse, FetchResponse } from "@/types/http.type"; 2 | 3 | export type SelectRequestStrategy = T extends "xhr" 4 | ? XhrResponse 5 | : FetchResponse; 6 | 7 | export default abstract class RequestStrategy { 8 | abstract request( 9 | payload: HttpBody, 10 | config: HttpConfig, 11 | ): RequestDetail, T>; 12 | } 13 | -------------------------------------------------------------------------------- /demo/vanilla/vite.config.js: -------------------------------------------------------------------------------- 1 | import alias from "@rollup/plugin-alias"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import path from "path"; 4 | 5 | const getPath = (...paths) => path.resolve(__dirname, ...paths); 6 | 7 | const customResolver = resolve({ 8 | extensions: [".js"], 9 | }); 10 | 11 | /** @type {import('vite').UserConfig} */ 12 | export default { 13 | // root: "./", 14 | optimizeDeps: { 15 | exclude: ["@vic0627/karman"], 16 | }, 17 | plugins: [ 18 | alias({ 19 | entries: [], 20 | }), 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /lib/types/payload-def.type.ts: -------------------------------------------------------------------------------- 1 | import RuleSet from "@/core/validation-engine/rule-set/rule-set"; 2 | import { ParamRules } from "./rules.type"; 3 | 4 | export type ParamPosition = "path" | "query" | "body"; 5 | 6 | export interface ParamDef { 7 | rules?: ParamRules | ParamRules[] | RuleSet; 8 | required?: boolean; 9 | position?: ParamPosition | ParamPosition[]; 10 | defaultValue?: () => any; 11 | } 12 | 13 | export type ParamName = string; 14 | 15 | export type Schema = Record; 16 | 17 | export type PayloadDef = Schema | string[]; 18 | -------------------------------------------------------------------------------- /demo/vanilla/src/utils/imageBase64.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {File} file 3 | * @returns {Promise} 4 | */ 5 | export default function convertToBase64(file) { 6 | return new Promise((resolve, reject) => { 7 | const reader = new FileReader(); 8 | reader.readAsDataURL(file); 9 | reader.onload = () => resolve(reader.result); 10 | reader.onerror = (error) => reject(error); 11 | }); 12 | } 13 | 14 | Object.defineProperty(convertToBase64, "install", { 15 | value: (karman) => { 16 | Object.defineProperty(karman, "_convertToBase64", { value: convertToBase64 }); 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /lib/decorator/Injectable.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ClassSignature, ClassDecorator } from "@/types/common.type"; 2 | import { META_PARAMTYPES } from "@/assets/METADATA"; 3 | 4 | /** 5 | * Record all the dependencies required by the target. 6 | * Only the module which is decorated by this function can be injected correctly. 7 | */ 8 | export default function Injectable(): ClassDecorator { 9 | return (target) => { 10 | const dependencies = (Reflect.getMetadata(META_PARAMTYPES, target) ?? []) as ClassSignature[]; 11 | 12 | Reflect.defineMetadata(META_PARAMTYPES, dependencies, target); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /lib/utils/template.provider.ts: -------------------------------------------------------------------------------- 1 | export default class Template { 2 | public withPrefix(options: { type?: "warn" | "error"; messages: (string | number)[] }) { 3 | const { type = "warn", messages } = options; 4 | let t = `[karman ${type}] `; 5 | 6 | for (const item of messages) { 7 | t += item; 8 | } 9 | 10 | return t; 11 | } 12 | 13 | warn(...messages: (string | number)[]) { 14 | console.warn(this.withPrefix({ messages })); 15 | } 16 | 17 | throw(...messages: (string | number)[]) { 18 | throw new Error(this.withPrefix({ type: "error", messages })); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /scripts/utils/time-log.js: -------------------------------------------------------------------------------- 1 | module.exports = function (...str) { 2 | const currentDate = new Date(); 3 | 4 | const year = currentDate.getFullYear(); 5 | const month = String(currentDate.getMonth() + 1).padStart(2, "0"); 6 | const day = String(currentDate.getDate()).padStart(2, "0"); 7 | const hours = String(currentDate.getHours()).padStart(2, "0"); 8 | const minutes = String(currentDate.getMinutes()).padStart(2, "0"); 9 | const seconds = String(currentDate.getSeconds()).padStart(2, "0"); 10 | 11 | const formattedDateTime = `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}]`; 12 | 13 | console.log(formattedDateTime, ...str); 14 | }; 15 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/schema/category-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType, defineCustomValidator, ValidationError } from "@vic0627/karman"; 2 | 3 | export default defineSchemaType("Category", { 4 | /** 5 | * category of products 6 | * @type {"electronics" | "jewelery" | "men's clothing" | "women's clothing"} 7 | */ 8 | category: { 9 | required: true, 10 | rules: [ 11 | "string", 12 | defineCustomValidator((_, value) => { 13 | if (!["electronics", "jewelery", "men's clothing", "women's clothing"].includes(value)) 14 | throw new ValidationError("invalid category"); 15 | }), 16 | ], 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /demo/iife/fake-store/schema/date-range-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType } from "../../../../dist/karman.js"; 2 | 3 | const required = true; 4 | export const dateRegexp = /^\d{4}-\d{2}-\d{2}$/; 5 | export const dateRule = { regexp: dateRegexp, errorMessage: "invalid date format" }; 6 | 7 | export default defineSchemaType("DateRange", { 8 | /** 9 | * start date 10 | * @format "YYYY-MM-DD" 11 | */ 12 | startdate: { 13 | required, 14 | rules: ["string", dateRule], 15 | type: "", 16 | }, 17 | /** 18 | * end date 19 | * @format "YYYY-MM-DD" 20 | */ 21 | enddate: { 22 | required, 23 | rules: ["string", dateRule], 24 | type: "", 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/schema/date-range-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType } from "@vic0627/karman"; 2 | 3 | const required = true; 4 | export const dateRegexp = /^\d{4}-\d{2}-\d{2}$/; 5 | export const dateRule = { regexp: dateRegexp, errorMessage: "invalid date format" }; 6 | 7 | export default defineSchemaType("DateRange", { 8 | /** 9 | * start date 10 | * @format "YYYY-MM-DD" 11 | * @type {string} 12 | */ 13 | startdate: { 14 | required, 15 | rules: ["string", dateRule], 16 | }, 17 | /** 18 | * end date 19 | * @format "YYYY-MM-DD" 20 | * @type {string} 21 | */ 22 | enddate: { 23 | required, 24 | rules: ["string", dateRule], 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /lib/abstract/request-pipe.abstract.ts: -------------------------------------------------------------------------------- 1 | import RequestDetail, { ReqStrategyTypes, RequestExecutor } from "@/types/http.type"; 2 | import { SelectRequestStrategy } from "./request-strategy.abstract"; 3 | 4 | export interface PipeDetail extends RequestDetail, T> { 5 | payload: any; 6 | } 7 | 8 | export default abstract class RequestPipe { 9 | /** 10 | * 串接的主要函式 11 | * @param requestDetail 第一個參數必為請求的詳細配置、Promise、操作方法等... 12 | * @param args 第二個參數開始可以自己決定要帶什麼需要的參數 13 | */ 14 | abstract chain( 15 | requestDetail: PipeDetail, 16 | ...args: any[] 17 | ): RequestExecutor>; 18 | } 19 | -------------------------------------------------------------------------------- /lib/types/final-api.type.ts: -------------------------------------------------------------------------------- 1 | import Karman from "@/core/karman/karman"; 2 | import { AsyncHooks, SyncHooks } from "./hooks.type"; 3 | import { ReqStrategyTypes, RequestConfig, RequestExecutor } from "./http.type"; 4 | import { CacheConfig, UtilConfig } from "./karman.type"; 5 | 6 | export interface RuntimeOptions 7 | extends SyncHooks, 8 | AsyncHooks, 9 | RequestConfig, 10 | CacheConfig, 11 | Omit { 12 | cancelToken?: AbortSignal; 13 | } 14 | 15 | export type FinalAPI = ( 16 | this: Karman, 17 | payload: Record, 18 | runtimeOptions?: RuntimeOptions, 19 | ) => ReturnType>; 20 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/schema/limit-and-sort-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType, defineCustomValidator, ValidationError } from "@vic0627/karman"; 2 | 3 | const required = true; 4 | 5 | export default defineSchemaType("LimitAndSort", { 6 | /** 7 | * number of return transactions 8 | * @type {number} 9 | */ 10 | limit: { required, rules: ["int", { min: 1 }] }, 11 | /** 12 | * sorting strategy 13 | * @type {"asc" | "desc"} 14 | */ 15 | sort: { 16 | required, 17 | rules: [ 18 | "string", 19 | defineCustomValidator((prop, value) => { 20 | if (!["asc", "desc"].includes(value)) throw new ValidationError(`parameter "${prop}" must be "asc" or "desc"`); 21 | }), 22 | ], 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /demo/iife/fake-store/schema/limit-and-sort-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType, defineCustomValidator, ValidationError, getType } from "../../../../dist/karman.js"; 2 | 3 | const required = true; 4 | 5 | /** @type {("asc" | "desc")[]} */ 6 | const sorting = ["asc", "desc"]; 7 | 8 | export default defineSchemaType("LimitAndSort", { 9 | /** 10 | * number of return transactions 11 | */ 12 | limit: { required, rules: ["int", { min: 1 }], type: 1 }, 13 | /** 14 | * sorting strategy 15 | */ 16 | sort: { 17 | required, 18 | rules: [ 19 | "string", 20 | defineCustomValidator((prop, value) => { 21 | if (!sorting.includes(value)) throw new ValidationError(`parameter "${prop}" must be "asc" or "desc"`); 22 | }), 23 | ], 24 | type: getType(...sorting), 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /lib/types/common.type.ts: -------------------------------------------------------------------------------- 1 | export interface ConstructorOf { 2 | new (...args: K): T; 3 | } 4 | 5 | export type NumOrString = number | string; 6 | 7 | export type SelectRequired = Required>; 8 | 9 | export type ClassSignature = { new (...args: any[]): {} }; 10 | 11 | export type ClassDecorator = (target: T) => T | void; 12 | 13 | export type Primitive = string | number | boolean | bigint | symbol | undefined | object; 14 | 15 | export type SelectPrimitive = T extends Primitive ? T : D; 16 | 17 | export type SelectPrimitive2 = P extends Primitive ? P : S extends Primitive ? S : D; 18 | 19 | export type SelectPrimitive3 = F extends Primitive 20 | ? F 21 | : S extends Primitive 22 | ? S 23 | : T extends Primitive 24 | ? T 25 | : D; -------------------------------------------------------------------------------- /demo/vanilla/main.js: -------------------------------------------------------------------------------- 1 | import fakeStore from "./src/fake-store"; 2 | import { isValidationError } from "@vic0627/karman"; 3 | 4 | const n = document.getElementById("number"); 5 | const t = document.getElementById("text"); 6 | const btnSend = document.getElementById("btn-send"); 7 | const btnAbort = document.getElementById("btn-abort"); 8 | 9 | let abortFn = () => {}; 10 | 11 | const request = async () => { 12 | try { 13 | const [resPromise, abort] = fakeStore.product.getAll({ 14 | limit: -1, 15 | }); 16 | abortFn = abort; 17 | const res = await resPromise; 18 | console.log(res.data); 19 | } catch (error) { 20 | console.error(error); 21 | if (isValidationError(error)) alert(error.message); 22 | } 23 | }; 24 | 25 | btnSend.addEventListener("click", () => { 26 | request(); 27 | }); 28 | btnAbort.addEventListener("click", () => { 29 | abortFn(); 30 | }); 31 | -------------------------------------------------------------------------------- /demo/vanilla/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 |
12 | 16 | 20 |
21 |
22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /scripts/utils/ansi.js: -------------------------------------------------------------------------------- 1 | const ansi = (str) => `\x1b[${str}m`; 2 | 3 | const reset = ansi(0); 4 | 5 | const red = ansi("0;31"); 6 | const green = ansi("0;32"); 7 | const yellow = ansi("0;33"); 8 | const blue = ansi("0;34"); 9 | const purple = ansi("0;35"); 10 | const cyanBlue = ansi("0;36"); 11 | const white = ansi("0;37"); 12 | 13 | const color = { 14 | red, 15 | green, 16 | yellow, 17 | blue, 18 | purple, 19 | cyanBlue, 20 | white, 21 | }; 22 | 23 | module.exports = { 24 | error(str) { 25 | return red + str + reset; 26 | }, 27 | success(str) { 28 | return green + str + reset; 29 | }, 30 | warn(str) { 31 | return yellow + str + reset; 32 | }, 33 | /** 34 | * 35 | * @param {"red" | "green" | "yellow" | "blue" | "purple" | "cyanBlue" | "white"} colorName 36 | * @param {string} str 37 | */ 38 | color(colorName, str) { 39 | return color[colorName] + str + reset; 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /COPYRIGHT.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Victor Hsu and Microsoft Corporation. All rights reserved. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 4 | this file except in compliance with the License. You may obtain a copy of the 5 | License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 8 | KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED 9 | WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, 10 | MERCHANTABLITY OR NON-INFRINGEMENT. 11 | 12 | See the Apache Version 2.0 License for specific language governing permissions 13 | and limitations under the License. 14 | 15 | ********************************************************************************** 16 | 17 | Bundle of <%= pkg.name %> 18 | Generated: <%= moment().format('YYYY-MM-DD') %> 19 | Version: <%= pkg.version %> -------------------------------------------------------------------------------- /demo/vanilla/javascript.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/abstract/cache-strategy.abstract.ts: -------------------------------------------------------------------------------- 1 | import { ReqStrategyTypes } from "@/types/http.type"; 2 | import { SelectRequestStrategy } from "./request-strategy.abstract"; 3 | 4 | type PopSignal = boolean; 5 | 6 | export type Task = (now: number) => PopSignal; 7 | 8 | export interface CacheData { 9 | res: SelectRequestStrategy; 10 | payload: Record; 11 | expiration: number; 12 | } 13 | 14 | export default abstract class CacheStrategy { 15 | abstract name: string; 16 | /** 設置快取 */ 17 | abstract set(requestKey: string, cacheData: CacheData): void; 18 | /** 刪除快取 */ 19 | abstract delete(requestKey: string): void; 20 | /** 是否有該筆快取 */ 21 | abstract has(requestKey: string): boolean; 22 | /** 取得快取 */ 23 | abstract get(requestKey: string): CacheData | undefined; 24 | /** 清除所有快取 */ 25 | abstract clear(): void; 26 | /** 快取排程任務 */ 27 | abstract scheduledTask: Task; 28 | } 29 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/schema/cart-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType } from "@vic0627/karman"; 2 | import idSchema from "./id-schema"; 3 | 4 | const required = true; 5 | 6 | export const productsInCarSchema = defineSchemaType("ProductsInCar", { 7 | /** 8 | * @min 1 9 | * @type {number} 10 | */ 11 | productId: { 12 | required, 13 | rules: ["int", { min: 1 }], 14 | }, 15 | /** 16 | * @min 1 17 | * @type {number} 18 | */ 19 | quantity: { 20 | required, 21 | rules: ["int", { min: 1 }], 22 | }, 23 | }); 24 | 25 | export default defineSchemaType("Cart", { 26 | ...idSchema.def, 27 | /** 28 | * @min 1 29 | * @type {number} 30 | */ 31 | userId: { 32 | required, 33 | rules: ["int", { min: 1 }], 34 | }, 35 | /** 36 | * @type {string} 37 | */ 38 | date: { 39 | required, 40 | rules: "string", 41 | }, 42 | /** 43 | * @type {typeof productsInCarSchema.def[]} 44 | */ 45 | products: { 46 | required, 47 | rules: "ProductsInCar[]", 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /lib/types/rules.type.ts: -------------------------------------------------------------------------------- 1 | export type Type = 2 | | "char" 3 | | "string" 4 | | "number" 5 | | "int" 6 | | "nan" 7 | | "boolean" 8 | | "object" 9 | | "null" 10 | | "function" 11 | | "array" 12 | | "object-literal" 13 | | "undefined" 14 | | "bigint" 15 | | "symbol" 16 | | string; 17 | 18 | export type ObjectLiteral = { [x: string | number | symbol]: any }; 19 | 20 | export type Prototype = { new (...args: any[]): any }; 21 | 22 | export type RegExpWithMessage = { regexp: RegExp; errorMessage?: string }; 23 | 24 | export type RegularExpression = RegExp | RegExpWithMessage; 25 | 26 | export type CustomValidator = ((param: string, value: any) => void) & { _karman: true }; 27 | 28 | export interface ParameterDescriptor { 29 | min?: number; 30 | max?: number; 31 | equality?: number; 32 | /** 33 | * - `"self"`: test the value itself 34 | * @default "length" 35 | */ 36 | measurement?: "self" | "length" | "size" | string; 37 | } 38 | 39 | export type ParamRules = Type | Prototype | RegularExpression | CustomValidator | ParameterDescriptor; 40 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/schema/product-info-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType } from "@vic0627/karman"; 2 | import categorySchema from "./category-schema"; 3 | 4 | const required = true; 5 | 6 | export default defineSchemaType("ProductInfo", { 7 | ...categorySchema.def, 8 | /** 9 | * product name 10 | * @min 1 11 | * @max 20 12 | * @type {string} 13 | */ 14 | title: { 15 | required, 16 | rules: ["string", { min: 1, max: 20, measurement: "length" }], 17 | }, 18 | /** 19 | * pricing 20 | * @min 1 21 | * @type {number} 22 | */ 23 | price: { 24 | required, 25 | rules: ["number", { min: 1 }], 26 | }, 27 | /** 28 | * description of product 29 | * @min 1 30 | * @max 100 31 | * @type {string} 32 | */ 33 | description: { 34 | required, 35 | rules: ["string", { min: 1, max: 100, measurement: "length" }], 36 | }, 37 | /** 38 | * product image 39 | * @max 5mb 40 | * @type {File} 41 | */ 42 | image: { 43 | required, 44 | rules: [File, { measurement: "size", max: 1024 * 1024 * 5 }], 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /demo/iife/fake-store/schema/cart-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType, getType } from "../../../../dist/karman.js"; 2 | import idSchema from "./id-schema.js"; 3 | 4 | const required = true; 5 | 6 | export const productsInCarSchema = defineSchemaType("ProductsInCar", { 7 | /** 8 | * @min 1 9 | */ 10 | productId: { 11 | required, 12 | rules: ["int", { min: 1 }], 13 | type: 1 14 | }, 15 | /** 16 | * @min 1 17 | */ 18 | quantity: { 19 | required, 20 | rules: ["int", { min: 1 }], 21 | type: 1 22 | }, 23 | }); 24 | 25 | export default defineSchemaType("Cart", { 26 | ...idSchema.def, 27 | /** 28 | * @min 1 29 | */ 30 | userId: { 31 | required, 32 | rules: ["int", { min: 1 }], 33 | type: 1 34 | }, 35 | /** 36 | * date that the product had been added to cart 37 | */ 38 | date: { 39 | required, 40 | rules: "string", 41 | type: "" 42 | }, 43 | /** 44 | * products in cart 45 | */ 46 | products: { 47 | required, 48 | rules: "ProductsInCar[]", 49 | type: getType([productsInCarSchema.def]) 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /lib/types/hooks.type.ts: -------------------------------------------------------------------------------- 1 | import Karman from "@/core/karman/karman"; 2 | import { HttpBody, HttpConfig, ReqStrategyTypes } from "./http.type"; 3 | import { PayloadDef } from "./payload-def.type"; 4 | import { SelectRequestStrategy } from "@/abstract/request-strategy.abstract"; 5 | 6 | export interface ValidationHooks { 7 | onBeforeValidate?(this: Karman, payloadDef: PayloadDef, payload: Record): void; 8 | } 9 | 10 | export interface SyncHooks extends ValidationHooks { 11 | onRebuildPayload?>(this: Karman, payload: T): T | void; 12 | onBeforeRequest?(this: Karman, requestURL: string, payload: Record): HttpBody | void; 13 | } 14 | 15 | export interface AsyncHooks { 16 | onSuccess?(this: Karman, res: SelectRequestStrategy): any; 17 | onError?(this: Karman, err: Error): unknown; 18 | onFinally?(this: Karman): void; 19 | } 20 | 21 | export interface KarmanInterceptors { 22 | onRequest?(this: Karman, req: HttpConfig): void; 23 | onResponse?(this: Karman, res: SelectRequestStrategy): boolean | void; 24 | } 25 | -------------------------------------------------------------------------------- /lib/types/karman.type.ts: -------------------------------------------------------------------------------- 1 | import Karman from "@/core/karman/karman"; 2 | import { KarmanInterceptors } from "./hooks.type"; 3 | import { ReqStrategyTypes, RequestConfig } from "./http.type"; 4 | import { SelectPrimitive } from "./common.type"; 5 | import SchemaType from "@/core/validation-engine/schema-type/schema-type"; 6 | 7 | export type CacheStrategyTypes = "sessionStorage" | "localStorage" | "memory"; 8 | 9 | export interface CacheConfig { 10 | cache?: boolean; 11 | cacheExpireTime?: number; 12 | cacheStrategy?: CacheStrategyTypes; 13 | } 14 | 15 | export interface UtilConfig { 16 | validation?: boolean; 17 | scheduleInterval?: number; 18 | } 19 | 20 | export interface KarmanConfig 21 | extends KarmanInterceptors, 22 | CacheConfig, 23 | Omit, "requestStrategy">, 24 | UtilConfig { 25 | root?: boolean; 26 | url?: string; 27 | schema?: SchemaType[]; 28 | route?: R; 29 | api?: A; 30 | } 31 | 32 | export type KarmanInstanceConfig = Omit, "route" | "api">; 33 | 34 | export type FinalKarman = Karman | SelectPrimitive | SelectPrimitive; 35 | -------------------------------------------------------------------------------- /demo/iife/main.js: -------------------------------------------------------------------------------- 1 | import fakeStore from "./fake-store/index.js"; 2 | import { send, set } from "./dom/index.js"; 3 | import { defineAPI, defineIntersectionRules, defineUnionRules, getType } from "../../dist/karman.js"; 4 | 5 | fakeStore.user.add( 6 | { 7 | email: "god@karman.com", 8 | username: "karman", 9 | password: "hello", 10 | name: { 11 | firstname: "karman", 12 | lastname: "hello", 13 | }, 14 | address: { 15 | city: "karman", 16 | street: "string", 17 | number: 1324, 18 | zipcode: "111", 19 | geolocation: { 20 | lat: "123", 21 | long: "123", 22 | }, 23 | }, 24 | phone: "123", 25 | }, 26 | { 27 | onSuccess(res) { 28 | console.log(res.data.id); 29 | return res.data; 30 | }, 31 | onError(err) { 32 | console.error(err); 33 | return 1; 34 | }, 35 | }, 36 | ); 37 | 38 | // let delegate = request1; 39 | 40 | send.addEventListener("click", () => { 41 | // delegate(); 42 | }); 43 | 44 | set.addEventListener("click", () => { 45 | // if (delegate === request1) delegate = request2; 46 | // else delegate = request1; 47 | }); 48 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | import core from "@/core"; 3 | import ValidationError from "./core/validation-engine/validation-error/validation-error"; 4 | import getType from "./core/out-of-paradigm/get-type"; 5 | 6 | const facade = new core(); 7 | 8 | const defineKarman = facade.LayerBuilder.configure.bind(facade.LayerBuilder); 9 | const defineAPI = facade.ApiFactory.createAPI.bind(facade.ApiFactory); 10 | const isValidationError = facade.ValidationEngine.isValidationError.bind(facade.ValidationEngine); 11 | const defineCustomValidator = facade.ValidationEngine.defineCustomValidator.bind(facade.ValidationEngine); 12 | const defineIntersectionRules = facade.ValidationEngine.defineIntersectionRules.bind(facade.ValidationEngine); 13 | const defineSchemaType = facade.ValidationEngine.defineSchemaType.bind(facade.ValidationEngine); 14 | const defineUnionRules = facade.ValidationEngine.defineUnionRules.bind(facade.ValidationEngine); 15 | 16 | export { 17 | defineKarman, 18 | defineAPI, 19 | isValidationError, 20 | defineCustomValidator, 21 | defineIntersectionRules, 22 | defineUnionRules, 23 | defineSchemaType, 24 | ValidationError, 25 | getType, 26 | }; 27 | -------------------------------------------------------------------------------- /lib/core/validation-engine/rule-set/rule-set.ts: -------------------------------------------------------------------------------- 1 | import { ParamRules } from "@/types/rules.type"; 2 | import ValidationError from "../validation-error/validation-error"; 3 | 4 | export default class RuleSet { 5 | protected readonly rules: ParamRules[]; 6 | protected errors: string[] = []; 7 | protected readonly errorType: string = "RuleSet"; 8 | 9 | public get valid(): boolean { 10 | return true; 11 | } 12 | 13 | constructor(...rules: ParamRules[]) { 14 | this.rules = rules; 15 | } 16 | 17 | public getStringRules() { 18 | const rules: string[] = []; 19 | this.rules.forEach((r) => { 20 | if (typeof r === "string") rules.push(r); 21 | }); 22 | 23 | return rules; 24 | } 25 | 26 | public execute(callbackfn: (value: ParamRules, index: number, array: ParamRules[]) => void) { 27 | this.rules.forEach((value, index, array) => { 28 | try { 29 | callbackfn(value, index, array); 30 | } catch (error) { 31 | if (error instanceof Error) this.errors.push(`[${index}] ${error.message}`); 32 | } 33 | }); 34 | 35 | if (!this.valid) throw new ValidationError(`${this.errorType}\n${this.errors.join("\n")}`); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /demo/vanilla/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/iife/fake-store/schema/product-info-schema.js: -------------------------------------------------------------------------------- 1 | import { defineSchemaType, defineCustomValidator, ValidationError, getType } from "../../../../dist/karman.js"; 2 | 3 | const required = true; 4 | /** @type {("electronics" | "jewelery" | "men's clothing" | "women's clothing")[]} */ 5 | const categories = ["electronics", "jewelery", "men's clothing", "women's clothing"]; 6 | 7 | export default defineSchemaType("ProductInfo", { 8 | /** 9 | * product name 10 | * @min 1 11 | * @max 20 12 | */ 13 | title: { 14 | required, 15 | rules: ["string", { min: 1, max: 20, measurement: "length" }], 16 | type: "", 17 | }, 18 | /** 19 | * pricing 20 | * @min 1 21 | */ 22 | price: { 23 | required, 24 | rules: ["number", { min: 1 }], 25 | type: 1, 26 | }, 27 | /** 28 | * description of product 29 | * @min 1 30 | * @max 100 31 | */ 32 | description: { 33 | required, 34 | rules: ["string", { min: 1, max: 100, measurement: "length" }], 35 | type: "", 36 | }, 37 | /** 38 | * product image 39 | */ 40 | image: { 41 | required, 42 | rules: "string", 43 | type: "", 44 | }, 45 | /** 46 | * category of products 47 | */ 48 | category: { 49 | required: true, 50 | rules: [ 51 | "string", 52 | defineCustomValidator((_, value) => { 53 | if (!categories.includes(value)) throw new ValidationError("invalid category"); 54 | }), 55 | ], 56 | type: getType(...categories), 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /lib/core/validation-engine/validators/functional-validator.injectable.ts: -------------------------------------------------------------------------------- 1 | import Validator, { ValidateOption } from "@/abstract/parameter-validator.abstract"; 2 | import Injectable from "@/decorator/Injectable.decorator"; 3 | import { CustomValidator, ParamRules, Prototype } from "@/types/rules.type"; 4 | import TypeCheck from "@/utils/type-check.provider"; 5 | import ValidationError from "../validation-error/validation-error"; 6 | 7 | @Injectable() 8 | export default class FunctionalValidator implements Validator { 9 | constructor(private readonly typeCheck: TypeCheck) {} 10 | 11 | public validate(option: ValidateOption): void { 12 | const { rule, param, value } = option; 13 | 14 | if (this.isCustomValidator(rule)) { 15 | rule(param, value); 16 | } else if (this.isPrototype(rule)) { 17 | this.instanceValidator(option); 18 | } 19 | } 20 | 21 | private isPrototype(rule: ParamRules): rule is Prototype { 22 | return this.typeCheck.isFunction(rule) && !(rule as CustomValidator)?._karman; 23 | } 24 | 25 | private isCustomValidator(rule: ParamRules): rule is CustomValidator { 26 | return this.typeCheck.isFunction(rule) && !!(rule as CustomValidator)?._karman; 27 | } 28 | 29 | private instanceValidator(option: ValidateOption) { 30 | const { param, value } = option; 31 | const rule = option.rule as Prototype; 32 | 33 | if (!(value instanceof rule)) { 34 | throw new ValidationError({ prop: param, value, instance: rule }); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/template.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, it, expect, jest } from "@jest/globals"; 2 | import Template from "@/utils/template.provider"; 3 | 4 | describe("Template", () => { 5 | let template: Template; 6 | 7 | beforeEach(() => { 8 | template = new Template(); 9 | }); 10 | 11 | describe("withPrefix", () => { 12 | // eslint-disable-next-line quotes 13 | it('should prefix messages with default type "warn"', () => { 14 | const result = template.withPrefix({ messages: ["Test message"] }); 15 | expect(result).toBe("[karman warn] Test message"); 16 | }); 17 | 18 | it("should prefix messages with specified type", () => { 19 | const result = template.withPrefix({ type: "error", messages: ["Test message"] }); 20 | expect(result).toBe("[karman error] Test message"); 21 | }); 22 | }); 23 | 24 | describe("warn", () => { 25 | it("should call console.warn with prefixed message", () => { 26 | const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); 27 | template.warn("Test message"); 28 | expect(consoleWarnSpy).toHaveBeenCalledWith("[karman warn] Test message"); 29 | consoleWarnSpy.mockRestore(); 30 | }); 31 | }); 32 | 33 | describe("throw", () => { 34 | it("should throw an Error with prefixed message", () => { 35 | const errorMessage = "[karman error] Test message"; 36 | expect(() => template.throw("Test message")).toThrowError(errorMessage); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /lib/core/api-factory/request-pipe/cache-strategy/memory-cache.provider.ts: -------------------------------------------------------------------------------- 1 | import CacheStrategy, { CacheData } from "@/abstract/cache-strategy.abstract"; 2 | import { ReqStrategyTypes } from "@/types/http.type"; 3 | 4 | export default class MemoryCache implements CacheStrategy { 5 | public readonly name = "memory"; 6 | private readonly store: Map = new Map(); 7 | 8 | set(requestKey: string, cacheData: CacheData): void { 9 | this.store.set(requestKey, cacheData); 10 | } 11 | 12 | delete(requestKey: string): void { 13 | this.store.delete(requestKey); 14 | } 15 | 16 | has(requestKey: string): boolean { 17 | return this.store.has(requestKey); 18 | } 19 | 20 | get(requestKey: string): CacheData | undefined { 21 | const data = this.store.get(requestKey) as CacheData; 22 | const existed = this.checkExpiration(requestKey, data); 23 | 24 | if (existed) return data; 25 | } 26 | 27 | clear(): void { 28 | this.store.clear(); 29 | } 30 | 31 | scheduledTask(now: number): boolean { 32 | this.store.forEach((cache, key, map) => { 33 | if (now > cache.expiration) map.delete(key); 34 | }); 35 | 36 | return !this.store.size; 37 | } 38 | 39 | private checkExpiration( 40 | requestKey: string, 41 | cacheData?: CacheData, 42 | ): cacheData is CacheData { 43 | if (!cacheData) return false; 44 | 45 | if (Date.now() > cacheData.expiration) { 46 | return !this.store.delete(requestKey); 47 | } 48 | 49 | return true; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint", "@stylistic"], 5 | root: true, 6 | env: { 7 | browser: true, 8 | }, 9 | ignorePatterns: [ 10 | ".eslintrc.cjs", 11 | "babel.config.js", 12 | "jest.config.js", 13 | "rollup.config.cjs", 14 | "rollup.config.js", 15 | "dist", 16 | "demo", 17 | "public", 18 | "example", 19 | "scripts", 20 | "declarations" 21 | ], 22 | rules: { 23 | semi: 2, 24 | // curly: 2, 25 | quotes: [2, "double"], 26 | "no-console": [2, { allow: ["warn", "error", "table", "group", "groupEnd"] }], 27 | "no-var": 2, 28 | "padding-line-between-statements": [ 29 | 2, 30 | { 31 | blankLine: "always", 32 | prev: ["let", "const", "expression"], 33 | next: ["block-like", "block"], 34 | }, 35 | { 36 | blankLine: "always", 37 | prev: ["block-like", "block"], 38 | next: "*", 39 | }, 40 | { 41 | blankLine: "always", 42 | prev: "expression", 43 | next: ["return", "throw", "break", "continue"], 44 | }, 45 | ], 46 | "no-fallthrough": 1, 47 | "spaced-comment": 2, 48 | "@stylistic/max-len": [2, 120], 49 | // "@stylistic/indent": [2, 2], 50 | "@stylistic/no-multiple-empty-lines": [2, { max: 1 }], 51 | "@typescript-eslint/ban-types": 0, 52 | "@typescript-eslint/no-explicit-any": 1, 53 | "@typescript-eslint/no-unused-vars": 1, 54 | "@typescript-eslint/no-unnecessary-type-constraint": 1, 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /scripts/rollup/rollup-bundle.js: -------------------------------------------------------------------------------- 1 | const { rollup } = require("rollup"); 2 | // const { resolve } = require("path"); 3 | const emptyDirectory = require("../utils/empty-directory.js"); 4 | const timeLog = require("../utils/time-log.js"); 5 | const emitDeclaration = require("../emit-declaration.js"); 6 | 7 | const MANUAL_BUILD = process.argv[2] === "--manual"; 8 | 9 | // const relativePathToRoot = "../../"; 10 | 11 | // const getPath = (...paths) => resolve(__dirname, relativePathToRoot, ...paths); 12 | 13 | const build = async (callback) => { 14 | /** @type {import('rollup').RollupBuild | undefined} */ 15 | let bundle; 16 | let buildFailed = false; 17 | 18 | try { 19 | timeLog("start cleaning 'dist' dir..."); 20 | 21 | const clean = await emptyDirectory(__dirname, "../../dist"); 22 | 23 | if (!clean) throw new Error('failed to clean up "dist" dir'); 24 | 25 | const { input, output, plugins } = require("./rollup-config.js"); 26 | 27 | /** @param {import('rollup').RollupBuild} bundle */ 28 | const generateOutputs = async (bundle) => { 29 | for (const outputOptions of output) { 30 | await bundle.write(outputOptions); 31 | } 32 | }; 33 | 34 | timeLog("start rollup..."); 35 | 36 | bundle = await rollup({ input, plugins }); 37 | 38 | await generateOutputs(bundle); 39 | 40 | if (typeof callback === "function") callback(); 41 | } catch (error) { 42 | buildFailed = true; 43 | console.error(error); 44 | } 45 | 46 | if (bundle) { 47 | emitDeclaration(); 48 | await bundle.close(); 49 | } 50 | 51 | if (MANUAL_BUILD) process.exit(buildFailed ? 1 : 0); 52 | }; 53 | 54 | if (MANUAL_BUILD) build(); 55 | else module.exports = build; 56 | -------------------------------------------------------------------------------- /lib/core/validation-engine/validators/type-validator.injectable.ts: -------------------------------------------------------------------------------- 1 | import Validator, { ValidateOption } from "@/abstract/parameter-validator.abstract"; 2 | import Injectable from "@/decorator/Injectable.decorator"; 3 | import { Type } from "@/types/rules.type"; 4 | import TypeCheck from "@/utils/type-check.provider"; 5 | import ValidationError from "../validation-error/validation-error"; 6 | import Template from "@/utils/template.provider"; 7 | import Karman from "@/core/karman/karman"; 8 | 9 | @Injectable() 10 | export default class TypeValidator implements Validator { 11 | constructor( 12 | private readonly typeCheck: TypeCheck, 13 | private readonly template: Template, 14 | ) {} 15 | 16 | public validate(option: ValidateOption, karman?: Karman): void { 17 | const { rule, param, value } = option; 18 | 19 | if (!this.typeCheck.isString(rule)) { 20 | return; 21 | } 22 | 23 | if (karman?.$schema.has(rule)) { 24 | const schema = karman.$schema.get(rule); 25 | schema?.validate(option); 26 | 27 | return; 28 | } 29 | 30 | const type = rule.toLowerCase(); 31 | 32 | const legal = this.legalType(type); 33 | 34 | if (!legal) { 35 | this.template.warn(`invalid type "${type}" was provided in rules for parameter "${param}"`); 36 | 37 | return; 38 | } 39 | 40 | const validator = this.getValidator(type); 41 | const valid = validator(value); 42 | 43 | if (!valid) { 44 | throw new ValidationError({ prop: param, value, type }); 45 | } 46 | } 47 | 48 | private legalType(type: string): type is Type { 49 | if (this.typeCheck.TypeSet.includes(type as Type)) { 50 | return true; 51 | } 52 | 53 | return false; 54 | } 55 | 56 | private getValidator(type: string) { 57 | const methodName = this.typeCheck.CorrespondingMap[type as Type]; 58 | const validator = this.typeCheck[methodName]; 59 | 60 | return validator as (value: any) => boolean; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /scripts/utils/empty-directory.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const { promisify } = require("util"); 4 | const timeLog = require("./time-log.js"); 5 | const readdir = promisify(fs.readdir); 6 | const unlink = promisify(fs.unlink); 7 | const rmdir = promisify(fs.rmdir); 8 | 9 | const protectedPaths = ["src", ".husky", ".git", "docs", "example", "scripts"]; 10 | const protectedFiles = [ 11 | ".eslintrc.cjs", 12 | ".gitignore", 13 | ".prettierignore", 14 | ".prettierrc", 15 | "babel.config.js", 16 | "jest.config.js", 17 | "package-lock.json", 18 | "package.json", 19 | "README.md", 20 | "tsconfig.json", 21 | ]; 22 | 23 | /** 24 | * @param {string[]} targetList 25 | * @param {string} target 26 | */ 27 | function hasProtectedTarget(targetList, target) { 28 | return targetList.some((forbid) => target.includes(forbid)); 29 | } 30 | 31 | module.exports = async function emptyDirectory(...resolvePath) { 32 | if (!resolvePath?.length) throw new Error("paths required"); 33 | 34 | try { 35 | const fullPath = path.resolve(...resolvePath); 36 | 37 | if (hasProtectedTarget(protectedPaths, fullPath)) 38 | throw new Error(`Path '${fullPath}' contains forbidden directory`); 39 | 40 | const files = await readdir(fullPath); 41 | 42 | for (const file of files) { 43 | if (protectedFiles.includes(file)) throw new Error(`failed to delete protected file '${file}'`); 44 | 45 | const filePath = path.join(fullPath, file); 46 | 47 | const isDirectory = fs.statSync(filePath).isDirectory(); 48 | 49 | if (isDirectory) await emptyDirectory(filePath); 50 | else await unlink(filePath); 51 | } 52 | 53 | // 刪除完畢後,刪除原始目錄 54 | await rmdir(fullPath); 55 | 56 | return true; 57 | } catch (error) { 58 | timeLog(error.message); 59 | 60 | const distNotExisit = error.message?.includes( 61 | "ENOENT: no such file or directory, scandir", 62 | ) && error.message?.includes("karman"); 63 | 64 | return !!distNotExisit; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /lib/core/validation-engine/validation-error/validation-error.ts: -------------------------------------------------------------------------------- 1 | import { ClassSignature } from "@/types/common.type"; 2 | import { ParameterDescriptor } from "@/types/rules.type"; 3 | import { isUndefined, isNull, isString } from "lodash-es"; 4 | 5 | const existValue = (value: unknown) => !isUndefined(value) && !isNull(value); 6 | 7 | export interface ValidationErrorOptions extends ParameterDescriptor { 8 | prop: string; 9 | value: any; 10 | message?: string; 11 | type?: string; 12 | instance?: ClassSignature; 13 | required?: boolean; 14 | } 15 | 16 | export default class ValidationError extends Error { 17 | name: string = "ValidationError"; 18 | option?: ValidationErrorOptions; 19 | 20 | constructor(options: ValidationErrorOptions | string) { 21 | let message: string = ""; 22 | 23 | if (isString(options)) message = options; 24 | else { 25 | const { value, min, max, equality, measurement, required, type, instance } = options; 26 | let { prop } = options; 27 | message = options?.message ?? ""; 28 | 29 | if (measurement && measurement !== "self") prop += `.${measurement}`; 30 | 31 | if (!message) { 32 | message = `Parameter '${prop}' `; 33 | 34 | if (required) message += "is required"; 35 | else if (existValue(type)) message += `should be '${type}' type`; 36 | else if (existValue(instance)) message += `should be instance of '${instance?.name ?? instance}'`; 37 | else if (existValue(equality)) message += `should be equal to '${equality}'`; 38 | else if (existValue(min) && !existValue(max)) message += `should be greater than or equal to '${min}'`; 39 | else if (existValue(max) && !existValue(min)) message += `should be less than or equal to '${max}'`; 40 | else if (existValue(min) && existValue(max)) message += `should be within the range of '${min}' and '${max}'`; 41 | else message += "validation failed"; 42 | 43 | message += `, but received '${value}'.`; 44 | } 45 | } 46 | 47 | super(message); 48 | if (typeof options !== "string") this.option = options; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /demo/iife/fake-store/index.js: -------------------------------------------------------------------------------- 1 | import { defineAPI, defineKarman } from "../../../dist/karman.js"; 2 | import product from "./route/product.js"; 3 | import cart from "./route/cart.js"; 4 | import user from "./route/user.js"; 5 | import userSchema, { addressSchema, geoSchema, nameSchema } from "./schema/user-schema.js"; 6 | import productInfoSchema from "./schema/product-info-schema.js"; 7 | import limitAndSortSchema from "./schema/limit-and-sort-schema.js"; 8 | import idSchema from "./schema/id-schema.js"; 9 | import dateRangeSchema from "./schema/date-range-schema.js"; 10 | import cartSchema, { productsInCarSchema } from "./schema/cart-schema.js"; 11 | 12 | const fakeStore = defineKarman({ 13 | root: true, 14 | headerMap: true, 15 | validation: true, 16 | scheduleInterval: 1000 * 10, 17 | schema: [ 18 | userSchema, 19 | geoSchema, 20 | addressSchema, 21 | nameSchema, 22 | productInfoSchema, 23 | limitAndSortSchema, 24 | idSchema, 25 | dateRangeSchema, 26 | productsInCarSchema, 27 | cartSchema, 28 | ], 29 | // cache: true, 30 | cacheExpireTime: 5000, 31 | // timeout: 100, 32 | // timeoutErrorMessage: "error~~~", 33 | url: "https://fakestoreapi.com", 34 | headers: { 35 | "Content-Type": "application/json; charset=utf-8", 36 | from: "parent", 37 | }, 38 | api: { 39 | /** 40 | * ### user login 41 | */ 42 | login: defineAPI({ 43 | url: "auth/login", 44 | method: "POST", 45 | requestStrategy: "fetch", 46 | payloadDef: ["username", "password"], 47 | // payloadDef: userSchema.mutate().pick("username", "password").def, 48 | /** 49 | * @typedef {object} LoginRes 50 | * @prop {string} LoginRes.token token of user account 51 | */ 52 | /** 53 | * @type {LoginRes} 54 | */ 55 | dto: null, 56 | }), 57 | }, 58 | route: { 59 | /** 60 | * ## product management 61 | */ 62 | product, 63 | /** 64 | * ## product cart management 65 | */ 66 | cart, 67 | /** 68 | * ## user management 69 | */ 70 | user, 71 | }, 72 | }); 73 | 74 | export default fakeStore; 75 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/user/index.js: -------------------------------------------------------------------------------- 1 | import { defineAPI, defineKarman } from "@vic0627/karman"; 2 | import limitAndSortSchema from "../schema/limit-and-sort-schema"; 3 | import idSchema from "../schema/id-schema"; 4 | import userSchema from "../schema/user-schema"; 5 | 6 | /** 7 | * @typedef {typeof userSchema.def & typeof idSchema.def} User 8 | */ 9 | 10 | export default defineKarman({ 11 | url: "users", 12 | api: { 13 | /** 14 | * ### get all user info 15 | */ 16 | getAll: defineAPI({ 17 | payloadDef: limitAndSortSchema 18 | .mutate() 19 | .setOptional() 20 | .setPosition("query") 21 | .setDefault("limit", () => 10).def, 22 | /** @type {User[]} */ 23 | dto: null, 24 | }), 25 | /** 26 | * ### get a user info by id 27 | */ 28 | getById: defineAPI({ 29 | url: ":id", 30 | payloadDef: idSchema.mutate().setPosition("path").def, 31 | /** @type {User} */ 32 | dto: null, 33 | }), 34 | /** 35 | * ### create a new user 36 | */ 37 | add: defineAPI({ 38 | method: "POST", 39 | payloadDef: userSchema.def, 40 | /** @type {User} */ 41 | dto: null, 42 | }), 43 | /** 44 | * ### update a user 45 | */ 46 | update: defineAPI({ 47 | url: ":id", 48 | method: "PUT", 49 | payloadDef: { 50 | ...idSchema.mutate().setPosition("path").def, 51 | ...userSchema.def, 52 | }, 53 | /** @type {User} */ 54 | dto: null, 55 | }), 56 | /** 57 | * ### modify a user 58 | */ 59 | modify: defineAPI({ 60 | url: ":id", 61 | method: "PATCH", 62 | payloadDef: { 63 | ...idSchema.mutate().setPosition("path").def, 64 | ...userSchema.mutate().setOptional().def, 65 | }, 66 | /** @type {User} */ 67 | dto: null, 68 | }), 69 | /** 70 | * ### delete a user 71 | */ 72 | delete: defineAPI({ 73 | url: ":id", 74 | method: "DELETE", 75 | payloadDef: idSchema.mutate().setPosition("path").def, 76 | /** @type {User} */ 77 | dto: null, 78 | }), 79 | }, 80 | }); 81 | -------------------------------------------------------------------------------- /demo/iife/fake-store/route/user.js: -------------------------------------------------------------------------------- 1 | import { defineAPI, defineKarman, getType } from "../../../../dist/karman.js"; 2 | import limitAndSortSchema from "../schema/limit-and-sort-schema.js"; 3 | import idSchema from "../schema/id-schema.js"; 4 | import userSchema from "../schema/user-schema.js"; 5 | 6 | /** 7 | * @typedef {typeof userSchema.def & typeof idSchema.def} User 8 | */ 9 | 10 | export default defineKarman({ 11 | url: "users", 12 | api: { 13 | /** 14 | * ### get all user info 15 | */ 16 | getAll: defineAPI({ 17 | payloadDef: limitAndSortSchema 18 | .mutate() 19 | .setOptional() 20 | .setPosition("query") 21 | .setDefault("limit", () => 10).def, 22 | /** @type {User[]} */ 23 | dto: null, 24 | }), 25 | /** 26 | * ### get a user info by id 27 | */ 28 | getById: defineAPI({ 29 | url: ":id", 30 | payloadDef: idSchema.mutate().setPosition("path").def, 31 | /** @type {User} */ 32 | dto: null, 33 | }), 34 | /** 35 | * ### create a new user 36 | */ 37 | add: defineAPI({ 38 | method: "POST", 39 | payloadDef: userSchema.def, 40 | dto: getType(idSchema.def), 41 | }), 42 | /** 43 | * ### update a user 44 | */ 45 | update: defineAPI({ 46 | url: ":id", 47 | method: "PUT", 48 | payloadDef: { 49 | ...idSchema.mutate().setPosition("path").def, 50 | ...userSchema.def, 51 | }, 52 | /** @type {User} */ 53 | dto: null, 54 | }), 55 | /** 56 | * ### modify a user 57 | */ 58 | modify: defineAPI({ 59 | url: ":id", 60 | method: "PATCH", 61 | payloadDef: { 62 | ...idSchema.mutate().setPosition("path").def, 63 | ...userSchema.mutate().setOptional().def, 64 | }, 65 | /** @type {User} */ 66 | dto: null, 67 | }), 68 | /** 69 | * ### delete a user 70 | */ 71 | delete: defineAPI({ 72 | url: ":id", 73 | method: "DELETE", 74 | payloadDef: idSchema.mutate().setPosition("path").def, 75 | /** @type {User} */ 76 | dto: null, 77 | }), 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /lib/core/index.ts: -------------------------------------------------------------------------------- 1 | import IOCContainer from "@/decorator/IOCContainer.decorator"; 2 | import MemoryCache from "./api-factory/request-pipe/cache-strategy/memory-cache.provider"; 3 | import CachePipe from "./api-factory/request-pipe/cache-pipe.injectable"; 4 | import ApiFactory from "./api-factory/api-factory.injectable"; 5 | import LayerBuilder from "./layer-builder/layer-builder.injectable"; 6 | import Xhr from "./request-strategy/xhr.injectable"; 7 | import ScheduledTask from "./scheduled-task/scheduled-task.injectable"; 8 | import FunctionalValidator from "./validation-engine/validators/functional-validator.injectable"; 9 | import ParameterDescriptorValidator from "./validation-engine/validators/parameter-descriptor-validator.injectable"; 10 | import RegExpValidator from "./validation-engine/validators/regexp-validator.provider"; 11 | import TypeValidator from "./validation-engine/validators/type-validator.injectable"; 12 | import ValidationEngine from "./validation-engine/validation-engine.injectable"; 13 | import PathResolver from "@/utils/path-resolver.provider"; 14 | import TypeCheck from "@/utils/type-check.provider"; 15 | import Template from "@/utils/template.provider"; 16 | import LocalStorageCache from "./api-factory/request-pipe/cache-strategy/local-storage-cache.provider"; 17 | import SessionStorageCache from "./api-factory/request-pipe/cache-strategy/session-storage-cache.provider"; 18 | import Fetch from "./request-strategy/fetch.injectable"; 19 | import ArrayValidator from "./validation-engine/validators/array-validator.injectable"; 20 | 21 | @IOCContainer({ 22 | imports: [ 23 | CachePipe, 24 | ApiFactory, 25 | LayerBuilder, 26 | Xhr, 27 | Fetch, 28 | ScheduledTask, 29 | FunctionalValidator, 30 | ParameterDescriptorValidator, 31 | RegExpValidator, 32 | TypeValidator, 33 | ValidationEngine, 34 | ArrayValidator 35 | ], 36 | provides: [MemoryCache, LocalStorageCache, SessionStorageCache, PathResolver, TypeCheck, Template], 37 | exports: [ApiFactory, LayerBuilder, ValidationEngine], 38 | }) 39 | export default class { 40 | ApiFactory!: ApiFactory; 41 | LayerBuilder!: LayerBuilder; 42 | ValidationEngine!: ValidationEngine; 43 | } 44 | -------------------------------------------------------------------------------- /lib/core/api-factory/request-pipe/cache-strategy/local-storage-cache.provider.ts: -------------------------------------------------------------------------------- 1 | import CacheStrategy, { CacheData } from "@/abstract/cache-strategy.abstract"; 2 | import { ReqStrategyTypes } from "@/types/http.type"; 3 | 4 | export default class LocalStorageCache implements CacheStrategy { 5 | public readonly name = "localStorage"; 6 | private readonly keyStore: Set = new Set(); 7 | 8 | set(requestKey: string, cacheData: CacheData): void { 9 | this.keyStore.add(requestKey); 10 | localStorage.setItem(requestKey, JSON.stringify(cacheData)); 11 | } 12 | 13 | delete(requestKey: string): void { 14 | this.keyStore.delete(requestKey); 15 | localStorage.removeItem(requestKey); 16 | } 17 | 18 | has(requestKey: string): boolean { 19 | return this.keyStore.has(requestKey); 20 | } 21 | 22 | get(requestKey: string): CacheData | undefined { 23 | let data: string | null | CacheData = localStorage.getItem(requestKey); 24 | 25 | if (!data) return; 26 | 27 | data = JSON.parse(data) as CacheData; 28 | const existed = this.checkExpiration(requestKey, data); 29 | 30 | if (existed) return data; 31 | } 32 | 33 | clear(): void { 34 | this.keyStore.forEach((key) => { 35 | localStorage.removeItem(key); 36 | }); 37 | this.keyStore.clear(); 38 | } 39 | 40 | scheduledTask(now: number): boolean { 41 | this.keyStore.forEach((key) => { 42 | const data = this.get(key); 43 | 44 | if (!data) { 45 | this.keyStore.delete(key); 46 | 47 | return; 48 | } 49 | 50 | if (now > data.expiration) { 51 | this.delete(key); 52 | this.keyStore.delete(key); 53 | } 54 | }); 55 | 56 | return !this.keyStore.size; 57 | } 58 | 59 | private checkExpiration( 60 | requestKey: string, 61 | cacheData?: CacheData, 62 | ): cacheData is CacheData { 63 | if (!cacheData) return false; 64 | 65 | if (Date.now() > cacheData.expiration) { 66 | this.delete(requestKey); 67 | 68 | return false; 69 | } 70 | 71 | return true; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/core/api-factory/request-pipe/cache-strategy/session-storage-cache.provider.ts: -------------------------------------------------------------------------------- 1 | import CacheStrategy, { CacheData } from "@/abstract/cache-strategy.abstract"; 2 | import { ReqStrategyTypes } from "@/types/http.type"; 3 | 4 | export default class SessionStorageCache implements CacheStrategy { 5 | public readonly name = "sessionStorage"; 6 | private readonly keyStore: Set = new Set(); 7 | 8 | set(requestKey: string, cacheData: CacheData): void { 9 | this.keyStore.add(requestKey); 10 | sessionStorage.setItem(requestKey, JSON.stringify(cacheData)); 11 | } 12 | 13 | delete(requestKey: string): void { 14 | this.keyStore.delete(requestKey); 15 | sessionStorage.removeItem(requestKey); 16 | } 17 | 18 | has(requestKey: string): boolean { 19 | return this.keyStore.has(requestKey); 20 | } 21 | 22 | get(requestKey: string): CacheData | undefined { 23 | let data: string | null | CacheData = sessionStorage.getItem(requestKey); 24 | 25 | if (!data) return; 26 | 27 | data = JSON.parse(data) as CacheData; 28 | const existed = this.checkExpiration(requestKey, data); 29 | 30 | if (existed) return data; 31 | } 32 | 33 | clear(): void { 34 | this.keyStore.forEach((key) => { 35 | sessionStorage.removeItem(key); 36 | }); 37 | this.keyStore.clear(); 38 | } 39 | 40 | scheduledTask(now: number): boolean { 41 | this.keyStore.forEach((key) => { 42 | const data = this.get(key); 43 | 44 | if (!data) { 45 | this.keyStore.delete(key); 46 | 47 | return; 48 | } 49 | 50 | if (now > data.expiration) { 51 | this.delete(key); 52 | this.keyStore.delete(key); 53 | } 54 | }); 55 | 56 | return !this.keyStore.size; 57 | } 58 | 59 | private checkExpiration( 60 | requestKey: string, 61 | cacheData?: CacheData, 62 | ): cacheData is CacheData { 63 | if (!cacheData) return false; 64 | 65 | if (Date.now() > cacheData.expiration) { 66 | this.delete(requestKey); 67 | 68 | return false; 69 | } 70 | 71 | return true; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /assets/doc/en/response-caching.md: -------------------------------------------------------------------------------- 1 | # Response Caching 2 | 3 | Settings related to caching functionality can be configured on `defineKarman`, `defineAPI`, and final API `config`. Setting `cache` to `true` enables caching, while `cacheExpireTime` determines the duration of cached data. The storage policy includes `memory`, `localStorage`, and `sessionStorage`, configured using the `cacheStrategy` attribute. 4 | 5 | > [!CAUTION] 6 | > When using WebStorage as the caching strategy, please note that WebStorage can only store values convertible to strings. Therefore, if caching is required for response results that cannot be represented as strings, consider using the `memory` strategy. 7 | 8 | When the caching functionality of a final API is enabled, it records the request parameters and response results upon the first request. From the second request onwards, if the request parameters are the same as the previous one, it returns the cached data directly until the request parameters change or the cache expires, triggering a new request. 9 | 10 | > [!WARNING] 11 | > Final APIs that return cached data cannot use the abort method to cancel requests! 12 | 13 | ```js 14 | import { defineKarman, defineAPI } from "@vic0627/karman" 15 | 16 | const min = 1000 * 60 17 | 18 | const cacheKarman = defineKarman({ 19 | root: true, 20 | scheduleInterval: min * 30, // Root node can set schedule task execution interval 21 | // ... 22 | cache: true, // Enable caching globally 23 | cacheExpireTime: min * 5, // Set cache lifetime globally 24 | api: { 25 | getA: defineAPI(), // Default to using the memory strategy 26 | getB: defineAPI({ 27 | cacheStrategy: 'localStorage' // Use the localStorage strategy 28 | }), 29 | } 30 | }) 31 | 32 | const cacheTesting = async () => { 33 | const res01 = await cacheKarman.getA()[0] // First request, record request parameters and response results 34 | console.log(res01) 35 | const res02 = await cacheKarman.getA()[0] // Second request, parameters unchanged, return cached data directly 36 | console.log(res02) 37 | } 38 | 39 | cacheTesting() 40 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vic0627/karman", 3 | "version": "1.3.0", 4 | "description": "API abstract layer / API centralized management builder", 5 | "main": "dist/karman", 6 | "scripts": { 7 | "test": "jest", 8 | "lint": "eslint lib", 9 | "lint:ts": "tsc --strict --noEmit", 10 | "lint:fix": "eslint lib --fix", 11 | "format": "prettier . --write", 12 | "dev": "rollup -c scripts/rollup/rollup-config.js --watch", 13 | "dev:browser": "node scripts/browser-server.js", 14 | "build": "node scripts/rollup/rollup-bundle.js --manual", 15 | "emit": "node scripts/emit-declaration.js --emit", 16 | "prepare": "husky install" 17 | }, 18 | "keywords": [ 19 | "api", 20 | "abstract", 21 | "framework", 22 | "centralized management" 23 | ], 24 | "author": "VICTOR HSU", 25 | "license": "Apache-2.0", 26 | "devDependencies": { 27 | "@babel/core": "^7.23.2", 28 | "@babel/preset-env": "^7.23.2", 29 | "@babel/preset-typescript": "^7.23.2", 30 | "@jest/globals": "^29.7.0", 31 | "@rollup/plugin-babel": "^6.0.4", 32 | "@rollup/plugin-node-resolve": "^15.2.3", 33 | "@rollup/plugin-terser": "^0.4.4", 34 | "@rollup/plugin-typescript": "^11.1.5", 35 | "@stylistic/eslint-plugin": "^1.4.1", 36 | "@types/jest": "^29.5.7", 37 | "@types/lodash-es": "^4.17.12", 38 | "@types/node": "^20.10.0", 39 | "@typescript-eslint/eslint-plugin": "^6.9.1", 40 | "@typescript-eslint/parser": "^6.9.1", 41 | "babel-jest": "^29.7.0", 42 | "babel-plugin-import": "^1.13.8", 43 | "eslint": "^8.53.0", 44 | "eslint-plugin-jsdoc": "^48.1.0", 45 | "husky": "^8.0.0", 46 | "jest": "^29.7.0", 47 | "jest-environment-jsdom": "^29.7.0", 48 | "prettier": "3.1.0", 49 | "rollup": "^4.3.0", 50 | "rollup-plugin-cleanup": "^3.2.1", 51 | "rollup-plugin-license": "^3.3.1", 52 | "ts-jest": "^29.1.1", 53 | "typescript": "^5.2.2" 54 | }, 55 | "dependencies": { 56 | "chokidar": "^3.5.3", 57 | "form-data": "^4.0.0", 58 | "lodash": "^4.17.21", 59 | "lodash-es": "^4.17.21", 60 | "reflect-metadata": "^0.1.13", 61 | "socket.io": "^4.7.2", 62 | "tslib": "^2.6.2" 63 | }, 64 | "engines": { 65 | "node": "20.9.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/index.js: -------------------------------------------------------------------------------- 1 | import { defineAPI, defineKarman } from "@vic0627/karman"; 2 | import product from "./product"; 3 | import cart from "./cart"; 4 | import user from "./user"; 5 | import _constant from "../utils/constant"; 6 | import convertToBase64 from "../utils/imageBase64"; 7 | import userSchema, { addressSchema, geoSchema, nameSchema } from "./schema/user-schema"; 8 | import productInfoSchema from "./schema/product-info-schema"; 9 | import limitAndSortSchema from "./schema/limit-and-sort-schema"; 10 | import idSchema from "./schema/id-schema"; 11 | import dateRangeSchema from "./schema/date-range-schema"; 12 | import categorySchema from "./schema/category-schema"; 13 | import cartSchema, { productsInCarSchema } from "./schema/cart-schema"; 14 | 15 | const fakeStore = defineKarman({ 16 | root: true, 17 | headerMap: true, 18 | validation: true, 19 | scheduleInterval: 1000 * 10, 20 | schema: [ 21 | userSchema, 22 | geoSchema, 23 | addressSchema, 24 | nameSchema, 25 | productInfoSchema, 26 | limitAndSortSchema, 27 | idSchema, 28 | dateRangeSchema, 29 | categorySchema, 30 | productsInCarSchema, 31 | cartSchema, 32 | ], 33 | // cache: true, 34 | cacheExpireTime: 5000, 35 | // timeout: 100, 36 | // timeoutErrorMessage: "error~~~", 37 | url: "https://fakestoreapi.com", 38 | headers: { 39 | "Content-Type": "application/json; charset=utf-8", 40 | from: "parent" 41 | }, 42 | api: { 43 | /** 44 | * ### user login 45 | */ 46 | login: defineAPI({ 47 | url: "auth/login", 48 | method: "POST", 49 | requestStrategy: "fetch", 50 | // payloadDef: ["username", "password"], 51 | payloadDef: userSchema.mutate().pick("username", "password").def, 52 | /** 53 | * @typedef {object} LoginRes 54 | * @prop {string} LoginRes.token token of user account 55 | */ 56 | /** 57 | * @type {LoginRes} 58 | */ 59 | dto: null, 60 | }), 61 | }, 62 | route: { 63 | /** 64 | * ## product management 65 | */ 66 | product, 67 | /** 68 | * ## product cart management 69 | */ 70 | cart, 71 | /** 72 | * ## user management 73 | */ 74 | user, 75 | }, 76 | }); 77 | 78 | fakeStore.$use(_constant); 79 | fakeStore.$use(convertToBase64); 80 | 81 | export default fakeStore; 82 | -------------------------------------------------------------------------------- /demo/iife/fake-store/route/cart.js: -------------------------------------------------------------------------------- 1 | import { defineKarman, defineAPI, getType } from "../../../../dist/karman.js"; 2 | import limitAndSortSchema from "../schema/limit-and-sort-schema.js"; 3 | import idSchema from "../schema/id-schema.js"; 4 | import cartSchema from "../schema/cart-schema.js"; 5 | import dateRangeSchema from "../schema/date-range-schema.js"; 6 | 7 | export default defineKarman({ 8 | url: "carts", 9 | api: { 10 | /** 11 | * ### Get all carts 12 | */ 13 | getAll: defineAPI({ 14 | payloadDef: { 15 | ...limitAndSortSchema 16 | .mutate() 17 | .setPosition("query") 18 | .setOptional() 19 | .setDefault("limit", () => 10).def, 20 | ...dateRangeSchema.mutate().setPosition("query").setOptional().def, 21 | }, 22 | dto: getType([cartSchema.def]), 23 | }), 24 | /** 25 | * ### get single cart by id 26 | */ 27 | getById: defineAPI({ 28 | url: ":id", 29 | payloadDef: idSchema.mutate().setPosition("path").def, 30 | dto: getType(cartSchema.def), 31 | }), 32 | /** 33 | * ### get single cart by user id 34 | */ 35 | getUserCarts: defineAPI({ 36 | url: "user/:id", 37 | payloadDef: idSchema.mutate().setPosition("path").def, 38 | dto: getType([cartSchema.def]), 39 | }), 40 | /** 41 | * ### add a new cart 42 | */ 43 | add: defineAPI({ 44 | method: "POST", 45 | payloadDef: cartSchema.mutate().omit("id").def, 46 | dto: getType(cartSchema.def), 47 | }), 48 | /** 49 | * ### update a cart 50 | */ 51 | update: defineAPI({ 52 | url: ":id", 53 | method: "PUT", 54 | payloadDef: cartSchema.mutate().setPosition("path", "id").def, 55 | dto: getType(cartSchema.def), 56 | }), 57 | /** 58 | * ### modify a cart 59 | */ 60 | modify: defineAPI({ 61 | url: ":id", 62 | method: "PATCH", 63 | payloadDef: cartSchema.mutate().setPosition("path", "id").setOptional("date", "products", "userId").def, 64 | dto: getType(cartSchema.def), 65 | }), 66 | /** 67 | * delete a cart by id 68 | */ 69 | delete: defineAPI({ 70 | url: ":id", 71 | method: "DELETE", 72 | payloadDef: idSchema.mutate().setPosition("path").def, 73 | dto: getType(cartSchema.def), 74 | }), 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /lib/core/validation-engine/validators/regexp-validator.provider.ts: -------------------------------------------------------------------------------- 1 | import Validator, { ValidateOption } from "@/abstract/parameter-validator.abstract"; 2 | import { ParamRules, RegExpWithMessage, RegularExpression } from "@/types/rules.type"; 3 | import ValidationError from "../validation-error/validation-error"; 4 | 5 | export interface RegExpValidateOption extends Omit { 6 | rule: RegularExpression; 7 | } 8 | 9 | export type RegExpValidateStrategy = (option: RegExpValidateOption) => void; 10 | 11 | export default class RegExpValidator implements Validator { 12 | public validate(option: ValidateOption): void { 13 | const { rule, param, value } = option; 14 | 15 | const legal = this.isLegalRegExp(rule); 16 | 17 | if (!legal) { 18 | return; 19 | } 20 | 21 | const validateStrategy = this.getStrategy(rule); 22 | 23 | validateStrategy({ rule, param, value }); 24 | } 25 | 26 | private isPureRegExp(rule: ParamRules): rule is RegExp { 27 | return rule instanceof RegExp; 28 | } 29 | 30 | private isRegExpWithMessage(rule: ParamRules): rule is RegExpWithMessage { 31 | return (rule as RegExpWithMessage)?.regexp instanceof RegExp; 32 | } 33 | 34 | private isLegalRegExp(rule: ParamRules): rule is RegularExpression { 35 | return this.isPureRegExp(rule) || this.isRegExpWithMessage(rule); 36 | } 37 | 38 | private getStrategy(rule: RegularExpression): RegExpValidateStrategy { 39 | if (this.isPureRegExp(rule)) { 40 | return this.pureRegExp.bind(this); 41 | } else if (this.isRegExpWithMessage(rule)) { 42 | return this.regExpWithMessage.bind(this); 43 | } else { 44 | throw new Error("no matched validate strategy"); 45 | } 46 | } 47 | 48 | private pureRegExp(option: RegExpValidateOption) { 49 | const { param, value } = option; 50 | const valid = (option.rule as RegExp).test(value); 51 | 52 | if (!valid) { 53 | throw new ValidationError({ prop: param, value }); 54 | } 55 | } 56 | 57 | private regExpWithMessage(option: RegExpValidateOption) { 58 | const { param, value } = option; 59 | const { errorMessage, regexp } = option.rule as RegExpWithMessage; 60 | const valid = regexp.test(value); 61 | 62 | if (!valid) { 63 | throw new ValidationError({ prop: param, value, message: errorMessage }); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/type-validator.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, beforeEach, afterEach, test, expect } from "@jest/globals"; 2 | import TypeValidator from "@/core/validation-engine/validators/type-validator.injectable"; 3 | import TypeCheck from "@/utils/type-check.provider"; 4 | import Template from "@/utils/template.provider"; // Import Template implementation 5 | import ValidationError from "@/core/validation-engine/validation-error/validation-error"; 6 | 7 | describe("TypeValidator", () => { 8 | let typeValidator: TypeValidator; 9 | let typeCheckMock: TypeCheck; 10 | let templateMock: Template; 11 | 12 | beforeEach(() => { 13 | typeCheckMock = new TypeCheck(); 14 | templateMock = new Template(); 15 | 16 | typeValidator = new TypeValidator(typeCheckMock, templateMock); 17 | }); 18 | 19 | describe("validate method", () => { 20 | test("should not throw error for valid type", () => { 21 | const option = { rule: "string", param: "param", value: "value", required: false }; 22 | 23 | expect(() => typeValidator.validate(option)).not.toThrow(); 24 | }); 25 | 26 | test("should warn for invalid type", () => { 27 | const option = { rule: "sting", param: "param", value: "value", required: false }; 28 | typeValidator.validate(option); 29 | expect(templateMock.warn).toHaveBeenCalledWith( 30 | // eslint-disable-next-line quotes 31 | '[karman warn] invalid type "sting" was provided in rules for parameter "param"', 32 | ); 33 | }); 34 | 35 | test("should throw ValidationError for invalid value", () => { 36 | const option = { rule: "string", param: "param", value: 1, required: false }; 37 | 38 | expect(() => typeValidator.validate(option)).toThrowError(ValidationError); 39 | }); 40 | }); 41 | 42 | describe("legalType method", () => { 43 | test("should return true for legal type", () => { 44 | expect(typeValidator["legalType"]("string")).toBe(true); 45 | }); 46 | 47 | test("should return false for illegal type", () => { 48 | expect(typeValidator["legalType"]("strng")).toBe(false); 49 | }); 50 | }); 51 | 52 | describe("getValidator method", () => { 53 | test("should return validator function", () => { 54 | const validator = typeValidator["getValidator"]("string"); 55 | 56 | expect(typeof validator).toBe("function"); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/cart/index.js: -------------------------------------------------------------------------------- 1 | import { defineKarman, defineAPI } from "@vic0627/karman"; 2 | import limitAndSortSchema from "../schema/limit-and-sort-schema"; 3 | import dateRangeSchema from "../schema/date-range-schema"; 4 | import idSchema from "../schema/id-schema"; 5 | import cartSchema from "../schema/cart-schema"; 6 | 7 | export default defineKarman({ 8 | url: "carts", 9 | api: { 10 | /** 11 | * ### Get all carts 12 | */ 13 | getAll: defineAPI({ 14 | payloadDef: { 15 | ...limitAndSortSchema 16 | .mutate() 17 | .setPosition("query") 18 | .setOptional() 19 | .setDefault("limit", () => 10).def, 20 | ...dateRangeSchema.mutate().setPosition("query").setOptional().def, 21 | }, 22 | /** @type {typeof cartSchema.def[]} */ 23 | dto: null, 24 | }), 25 | /** 26 | * ### get single cart by id 27 | */ 28 | getById: defineAPI({ 29 | url: ":id", 30 | payloadDef: idSchema.mutate().setPosition("path").def, 31 | /** @type {typeof cartSchema.def} */ 32 | dto: null, 33 | }), 34 | /** 35 | * ### get single cart by user id 36 | */ 37 | getUserCarts: defineAPI({ 38 | url: "user/:id", 39 | payloadDef: idSchema.mutate().setPosition("path").def, 40 | /** @type {typeof cartSchema.def[]} */ 41 | dto: null, 42 | }), 43 | /** 44 | * ### add a new cart 45 | */ 46 | add: defineAPI({ 47 | method: "POST", 48 | payloadDef: cartSchema.mutate().omit("id").def, 49 | /** @type {typeof cartSchema.def} */ 50 | dto: null, 51 | }), 52 | /** 53 | * ### update a cart 54 | */ 55 | update: defineAPI({ 56 | url: ":id", 57 | method: "PUT", 58 | payloadDef: cartSchema.mutate().setPosition("path", "id").def, 59 | /** @type {typeof cartSchema.def} */ 60 | dto: null, 61 | }), 62 | /** 63 | * ### modify a cart 64 | */ 65 | modify: defineAPI({ 66 | url: ":id", 67 | method: "PATCH", 68 | payloadDef: cartSchema.mutate().setPosition("path", "id").setOptional("date", "products", "userId").def, 69 | /** @type {typeof cartSchema.def} */ 70 | dto: null, 71 | }), 72 | /** 73 | * delete a cart by id 74 | */ 75 | delete: defineAPI({ 76 | url: ":id", 77 | method: "DELETE", 78 | payloadDef: idSchema.mutate().setPosition("path").def, 79 | /** @type {typeof cartSchema.def} */ 80 | dto: null, 81 | }), 82 | }, 83 | }); 84 | -------------------------------------------------------------------------------- /test/validation-error.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "@jest/globals"; 2 | import ValidationError, { ValidationErrorOptions } from "@/core/validation-engine/validation-error/validation-error"; 3 | 4 | describe("ValidationError", () => { 5 | it("should create an instance with given message", () => { 6 | const errorMessage = "Custom error message"; 7 | const error = new ValidationError(errorMessage); 8 | 9 | expect(error instanceof ValidationError).toBeTruthy(); 10 | expect(error.message).toBe(errorMessage); 11 | }); 12 | 13 | it("should create an instance with given options", () => { 14 | const options: ValidationErrorOptions = { 15 | prop: "testProp", 16 | value: 42, 17 | type: "string", 18 | }; 19 | 20 | const error = new ValidationError(options); 21 | 22 | expect(error instanceof ValidationError).toBeTruthy(); 23 | expect(error.message).toContain(options.prop); 24 | expect(error.message).toContain(options.type); 25 | expect(error.message).toContain(options.value.toString()); 26 | }); 27 | 28 | it("should handle different validation scenarios", () => { 29 | const options: ValidationErrorOptions = { 30 | prop: "testProp", 31 | value: "hello", 32 | min: 5, 33 | max: 10, 34 | }; 35 | 36 | const error = new ValidationError(options); 37 | 38 | expect(error instanceof ValidationError).toBeTruthy(); 39 | expect(error.message).toContain(options.prop); 40 | expect(error.message).toContain("should be within the range of"); 41 | expect(error.message).toContain(options.min?.toString()); 42 | expect(error.message).toContain(options.max?.toString()); 43 | expect(error.message).toContain(options.value.toString()); 44 | }); 45 | 46 | it("should handle custom messages", () => { 47 | const options: ValidationErrorOptions = { 48 | prop: "testProp", 49 | value: null, 50 | message: "Custom validation message", 51 | }; 52 | 53 | const error = new ValidationError(options); 54 | 55 | expect(error instanceof ValidationError).toBeTruthy(); 56 | expect(error.message).toBe(options.message); 57 | }); 58 | 59 | it("should handle undefined and null values", () => { 60 | const options: ValidationErrorOptions = { 61 | prop: "testProp", 62 | value: undefined, 63 | required: true, 64 | }; 65 | 66 | const error = new ValidationError(options); 67 | 68 | expect(error instanceof ValidationError).toBeTruthy(); 69 | expect(error.message).toContain(options.prop); 70 | expect(error.message).toContain("is required"); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /demo/iife/fake-store/route/product.js: -------------------------------------------------------------------------------- 1 | import { defineKarman, defineAPI, getType } from "../../../../dist/karman.js"; 2 | import limitAndSortSchema from "../schema/limit-and-sort-schema.js"; 3 | import idSchema from "../schema/id-schema.js"; 4 | import productInfoSchema from "../schema/product-info-schema.js"; 5 | 6 | export default defineKarman({ 7 | url: "products", 8 | api: { 9 | /** 10 | * ### get all products 11 | */ 12 | getAll: defineAPI({ 13 | payloadDef: limitAndSortSchema 14 | .mutate() 15 | .setOptional() 16 | .setPosition("query") 17 | .setDefault("limit", () => 10).def, 18 | dto: getType([productInfoSchema.def]), 19 | requestStrategy: "fetch", 20 | }), 21 | /** 22 | * ### get single product by id 23 | */ 24 | getById: defineAPI({ 25 | url: ":id", 26 | payloadDef: idSchema.mutate().setPosition("path").def, 27 | dto: getType(productInfoSchema.def), 28 | }), 29 | /** 30 | * ### create a new product 31 | */ 32 | create: defineAPI({ 33 | method: "POST", 34 | payloadDef: productInfoSchema.def, 35 | dto: getType(productInfoSchema.def), 36 | }), 37 | /** 38 | * ### update single product 39 | */ 40 | update: defineAPI({ 41 | url: ":id", 42 | method: "PUT", 43 | payloadDef: { 44 | ...idSchema.mutate().setPosition("path").def, 45 | ...productInfoSchema.def, 46 | }, 47 | dto: getType(productInfoSchema.def), 48 | }), 49 | /** 50 | * ### modify single product 51 | */ 52 | modify: defineAPI({ 53 | url: ":id", 54 | method: "PATCH", 55 | payloadDef: { 56 | ...idSchema.mutate().setPosition("path").def, 57 | ...productInfoSchema.def, 58 | }, 59 | dto: getType(productInfoSchema.def), 60 | }), 61 | /** 62 | * ### delete a product 63 | */ 64 | delete: defineAPI({ 65 | url: ":id", 66 | method: "DELETE", 67 | payloadDef: idSchema.mutate().setPosition("path").def, 68 | dto: getType(productInfoSchema.def), 69 | }), 70 | /** 71 | * ### get all product's categories 72 | */ 73 | getCategories: defineAPI({ 74 | url: "categories", 75 | dto: getType([productInfoSchema.def.category.type]), 76 | }), 77 | /** 78 | * ### get products by category 79 | */ 80 | getProductsByCategory: defineAPI({ 81 | url: "category/:category", 82 | payloadDef: productInfoSchema.mutate().pick("category").setPosition("path").def, 83 | dto: getType([productInfoSchema.def]), 84 | }), 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /scripts/rollup/rollup-config.js: -------------------------------------------------------------------------------- 1 | const babel = require("@rollup/plugin-babel"); 2 | const typescript = require("@rollup/plugin-typescript"); 3 | const terser = require("@rollup/plugin-terser"); 4 | const { nodeResolve } = require("@rollup/plugin-node-resolve"); 5 | const cleanup = require("rollup-plugin-cleanup"); 6 | const { resolve } = require("path"); 7 | const license = require("rollup-plugin-license"); 8 | 9 | const relativePathToRoot = "../../"; 10 | 11 | const getPath = (...paths) => resolve(__dirname, relativePathToRoot, ...paths); 12 | 13 | const input = getPath("./lib/index.ts"); 14 | 15 | // const privateFieldIndentifier = /^#[^#]+$/i; 16 | 17 | /** @type {import('@rollup/plugin-terser').Options} */ 18 | const terserOptions = { 19 | mangle: false, 20 | }; 21 | 22 | /** @type {import("rollup-plugin-cleanup").Options} */ 23 | const cleanupOptions = { extensions: ["ts", "js", "cjs"], comments: [] }; 24 | 25 | const cleanupPlugin = cleanup(cleanupOptions); 26 | 27 | /** @type {import("@rollup/plugin-babel").RollupBabelOutputPluginOptions} */ 28 | const babelOptions = { 29 | extensions: [".js", ".jsx", ".es6", ".es", ".mjs", ".ts"], 30 | babelHelpers: "bundled", 31 | }; 32 | 33 | const babelPlugin = babel(babelOptions); 34 | 35 | const licensePlugin = license({ 36 | sourcemap: true, 37 | cwd: process.cwd(), 38 | 39 | banner: { 40 | commentStyle: "regular", 41 | content: { 42 | file: getPath("./COPYRIGHT.txt"), 43 | encoding: "utf-8", 44 | }, 45 | }, 46 | 47 | thirdParty: { 48 | includePrivate: false, 49 | multipleVersions: true, 50 | output: { 51 | file: getPath("./dist/dependencies.txt"), 52 | }, 53 | }, 54 | }); 55 | 56 | const baseFileName = "dist/karman"; 57 | const name = "karman"; 58 | 59 | /** @type {import("rollup").OutputOptions[]} */ 60 | const output = [ 61 | { 62 | name, 63 | file: getPath(`${baseFileName}.js`), 64 | format: "es", 65 | exports: "named", 66 | }, 67 | { 68 | name, 69 | file: getPath(`${baseFileName}.min.js`), 70 | format: "iife", 71 | exports: "named", 72 | /** @todo fix terser option */ 73 | plugins: [terser(terserOptions)], 74 | }, 75 | { 76 | name, 77 | file: getPath(`${baseFileName}.cjs`), 78 | format: "commonjs", 79 | exports: "named", 80 | }, 81 | ]; 82 | 83 | /** @type {import('@rollup/plugin-typescript').RollupTypescriptOptions} */ 84 | const tsOption = { 85 | tsconfig: getPath("tsconfig.json"), 86 | }; 87 | 88 | const plugins = [licensePlugin, babelPlugin, typescript(tsOption), nodeResolve(), cleanupPlugin]; 89 | 90 | module.exports = { input, output, plugins, treeshake: false }; 91 | -------------------------------------------------------------------------------- /demo/iife/fake-store/schema/user-schema.js: -------------------------------------------------------------------------------- 1 | import { defineCustomValidator, defineSchemaType, getType, ValidationError } from "../../../../dist/karman.js"; 2 | 3 | export const emailRegexp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 4 | const required = true; 5 | 6 | const numberLikeString = defineCustomValidator((param, value) => { 7 | if (isNaN(+value)) throw new ValidationError(`'${param}' can't convert into number with value '${value}'`); 8 | }); 9 | 10 | export const geoSchema = defineSchemaType("Geo", { 11 | /** 12 | */ 13 | lat: { 14 | required, 15 | rules: ["string", numberLikeString], 16 | type: "", 17 | }, 18 | /** 19 | */ 20 | long: { 21 | required, 22 | rules: ["string", numberLikeString], 23 | type: "", 24 | }, 25 | }); 26 | 27 | export const addressSchema = defineSchemaType("Address", { 28 | /** 29 | */ 30 | city: { 31 | required, 32 | rules: "string", 33 | type: "", 34 | }, 35 | /** 36 | */ 37 | street: { 38 | required, 39 | rules: "string", 40 | type: "", 41 | }, 42 | /** 43 | */ 44 | number: { 45 | required, 46 | rules: "int", 47 | type: 1, 48 | }, 49 | /** 50 | */ 51 | zipcode: { 52 | required, 53 | rules: "string", 54 | type: "", 55 | }, 56 | /** 57 | */ 58 | geolocation: { 59 | required, 60 | rules: "Geo", 61 | type: getType(geoSchema.def), 62 | }, 63 | }); 64 | 65 | export const nameSchema = defineSchemaType("Name", { 66 | /** 67 | */ 68 | firstname: { 69 | required, 70 | rules: "string", 71 | type: "", 72 | }, 73 | /** 74 | */ 75 | lastname: { 76 | required, 77 | rules: "string", 78 | type: "", 79 | }, 80 | }); 81 | 82 | export default defineSchemaType("User", { 83 | /** 84 | * email 85 | */ 86 | email: { 87 | required, 88 | rules: ["string", { regexp: emailRegexp, errorMessage: "wrong email format" }], 89 | type: "", 90 | }, 91 | /** 92 | * user nick name 93 | */ 94 | username: { 95 | required, 96 | rules: ["string", { min: 1, measurement: "length" }], 97 | type: "", 98 | }, 99 | /** 100 | * password 101 | */ 102 | password: { 103 | required, 104 | rules: ["string", { min: 1, measurement: "length" }], 105 | type: "", 106 | }, 107 | /** 108 | * user full name 109 | */ 110 | name: { 111 | required, 112 | rules: "Name", 113 | type: getType(nameSchema.def), 114 | }, 115 | /** 116 | * user address 117 | */ 118 | address: { 119 | required, 120 | rules: "Address", 121 | type: getType(addressSchema.def), 122 | }, 123 | /** 124 | * phone number 125 | */ 126 | phone: { 127 | required, 128 | rules: ["string", { min: 1, measurement: "length" }], 129 | type: "", 130 | }, 131 | }); 132 | -------------------------------------------------------------------------------- /scripts/browser-server.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | const chokidar = require("chokidar"); 5 | const socket = require("socket.io"); 6 | const rollupBuild = require("./rollup/rollup-bundle.js"); 7 | const ansi = require("./utils/ansi.js"); 8 | const timeLog = require("./utils/time-log.js"); 9 | 10 | const PORT = process.argv[2] || 5678; 11 | const URL = `http://localhost:${PORT}/`; 12 | 13 | const getPath = (...paths) => path.resolve(__dirname, ...paths); 14 | 15 | const init = async () => { 16 | try { 17 | timeLog("start dev:browser..."); 18 | 19 | await rollupBuild(); 20 | 21 | const server = http.createServer((req, res) => { 22 | const filePath = req.url === "/" ? "./index.html" : req.url.includes("/dist") ? "../.." + req.url : "." + req.url; 23 | const fullPath = getPath("../demo/iife", filePath); 24 | 25 | const contentType = { "Content-Type": "text/plain" }; 26 | const fileType = fullPath.split(".").at(-1); 27 | 28 | switch (fileType) { 29 | case "js": 30 | contentType["Content-Type"] = "application/javascript"; 31 | break; 32 | case "css": 33 | contentType["Content-Type"] = "text/css"; 34 | break; 35 | case "html": 36 | contentType["Content-Type"] = "text/html"; 37 | break; 38 | } 39 | 40 | fs.readFile(fullPath, (err, data) => { 41 | let statusCode = 200; 42 | let _data = data; 43 | 44 | if (err) { 45 | statusCode = 404; 46 | _data = "Not Found"; 47 | } 48 | 49 | res.writeHead(statusCode, contentType); 50 | res.end(data); 51 | }); 52 | }); 53 | 54 | server.listen(PORT, () => { 55 | timeLog("browser server is running at " + ansi.color("cyanBlue", URL)); 56 | }); 57 | 58 | const io = socket(server); 59 | 60 | const watcher = chokidar.watch([getPath("../demo/iife"), getPath("../lib")]); 61 | 62 | let connect, socketEvent; 63 | 64 | io.on("connection", (e) => { 65 | if (!connect) { 66 | timeLog(ansi.success("dev:browser HMR ready")); 67 | connect = true; 68 | } 69 | 70 | socketEvent = e; 71 | }); 72 | 73 | watcher.on("change", async (path) => { 74 | if (!socketEvent) return; 75 | 76 | timeLog(`file ${ansi.color("cyanBlue", path)} has changed. reloading...`); 77 | 78 | try { 79 | if (path.includes('lib')) await rollupBuild(); 80 | 81 | await socketEvent.emit("reload"); 82 | 83 | timeLog(ansi.success("dev:browser reloaded")); 84 | timeLog("waiting for changes..."); 85 | } catch (error) { 86 | console.error("reload failed", error); 87 | } 88 | }); 89 | } catch (error) { 90 | console.error("start dev:browser failed", error); 91 | } 92 | }; 93 | 94 | init(); 95 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/product/index.js: -------------------------------------------------------------------------------- 1 | import { defineKarman, defineAPI } from "@vic0627/karman"; 2 | import limitAndSortSchema from "../schema/limit-and-sort-schema"; 3 | import idSchema from "../schema/id-schema"; 4 | import productInfoSchema from "../schema/product-info-schema"; 5 | import categorySchema from "../schema/category-schema"; 6 | 7 | /** 8 | * @typedef {typeof idSchema.def & typeof productInfoSchema.def} Product 9 | */ 10 | 11 | export default defineKarman({ 12 | url: "products", 13 | api: { 14 | /** 15 | * ### get all products 16 | */ 17 | getAll: defineAPI({ 18 | payloadDef: limitAndSortSchema 19 | .mutate() 20 | .setOptional() 21 | .setPosition("query") 22 | .setDefault("limit", () => 10).def, 23 | /** @type {Product[]} */ 24 | dto: null, 25 | requestStrategy: "fetch", 26 | }), 27 | /** 28 | * ### get single product by id 29 | */ 30 | getById: defineAPI({ 31 | url: ":id", 32 | payloadDef: idSchema.mutate().setPosition("path").def, 33 | /** @type {Product} */ 34 | dto: null, 35 | }), 36 | /** 37 | * ### create a new product 38 | */ 39 | create: defineAPI({ 40 | method: "POST", 41 | payloadDef: productInfoSchema.def, 42 | /** @type {Product} */ 43 | dto: null, 44 | }), 45 | /** 46 | * ### update single product 47 | */ 48 | update: defineAPI({ 49 | url: ":id", 50 | method: "PUT", 51 | payloadDef: { 52 | ...idSchema.mutate().setPosition("path").def, 53 | ...productInfoSchema.def, 54 | }, 55 | /** @type {Product} */ 56 | dto: null, 57 | }), 58 | /** 59 | * ### modify single product 60 | */ 61 | modify: defineAPI({ 62 | url: ":id", 63 | method: "PATCH", 64 | payloadDef: { 65 | ...idSchema.mutate().setPosition("path").def, 66 | ...productInfoSchema.def, 67 | }, 68 | /** @type {Product} */ 69 | dto: null, 70 | }), 71 | /** 72 | * ### delete a product 73 | */ 74 | delete: defineAPI({ 75 | url: ":id", 76 | method: "DELETE", 77 | payloadDef: idSchema.mutate().setPosition("path").def, 78 | /** @type {Product} */ 79 | dto: null, 80 | }), 81 | /** 82 | * ### get all product's categories 83 | */ 84 | getCategories: defineAPI({ 85 | url: "categories", 86 | /** @type {Array} */ 87 | dto: null, 88 | }), 89 | /** 90 | * ### get products by category 91 | */ 92 | getProductsByCategory: defineAPI({ 93 | url: "category/:category", 94 | payloadDef: categorySchema.mutate().setPosition("path").def, 95 | /** @type {Product[]} */ 96 | dto: null, 97 | }), 98 | }, 99 | }); 100 | -------------------------------------------------------------------------------- /demo/vanilla/src/fake-store/schema/user-schema.js: -------------------------------------------------------------------------------- 1 | import { defineCustomValidator, defineSchemaType, ValidationError } from "@vic0627/karman"; 2 | 3 | export const emailRegexp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 4 | const required = true; 5 | 6 | const numberLikeString = defineCustomValidator((param, value) => { 7 | if (isNaN(+value)) throw new ValidationError(`'${param}' can't convert into number with value '${value}'`); 8 | }); 9 | 10 | export const geoSchema = defineSchemaType("Geo", { 11 | /** 12 | * @type {string} 13 | */ 14 | lat: { 15 | required, 16 | rules: ["string", numberLikeString], 17 | }, 18 | /** 19 | * @type {string} 20 | */ 21 | long: { 22 | required, 23 | rules: ["string", numberLikeString], 24 | }, 25 | }); 26 | 27 | export const addressSchema = defineSchemaType("Address", { 28 | /** 29 | * @type {string} 30 | */ 31 | city: { 32 | required, 33 | rules: "string", 34 | }, 35 | /** 36 | * @type {string} 37 | */ 38 | street: { 39 | required, 40 | rules: "string", 41 | }, 42 | /** 43 | * @type {number} 44 | */ 45 | number: { 46 | required, 47 | rules: "int", 48 | }, 49 | /** 50 | * @type {string} 51 | */ 52 | zipcode: { 53 | required, 54 | rules: "string", 55 | }, 56 | /** 57 | * @type {typeof geoSchema.def} 58 | */ 59 | geolocation: { 60 | required, 61 | rules: "Geo", 62 | }, 63 | }); 64 | 65 | export const nameSchema = defineSchemaType("Name", { 66 | /** 67 | * @type {string} 68 | */ 69 | firstname: { 70 | required, 71 | rules: "string", 72 | }, 73 | /** 74 | * @type {string} 75 | */ 76 | lastname: { 77 | required, 78 | rules: "string", 79 | }, 80 | }); 81 | 82 | export default defineSchemaType("User", { 83 | /** 84 | * email 85 | * @type {string} 86 | */ 87 | email: { 88 | required, 89 | rules: ["string", { regexp: emailRegexp, errorMessage: "wrong email format" }], 90 | }, 91 | /** 92 | * user nick name 93 | * @type {string} 94 | */ 95 | username: { 96 | required, 97 | rules: ["string", { min: 1, measurement: "length" }], 98 | }, 99 | /** 100 | * password 101 | * @type {string} 102 | */ 103 | password: { 104 | required, 105 | rules: ["string", { min: 1, measurement: "length" }], 106 | }, 107 | /** 108 | * user full name 109 | * @type {typeof nameSchema.def} 110 | */ 111 | name: { 112 | required, 113 | rules: "Name", 114 | }, 115 | /** 116 | * user address 117 | * @type {typeof addressSchema.def} 118 | */ 119 | address: { 120 | required, 121 | rules: "Address", 122 | }, 123 | /** 124 | * phone number 125 | * @type {string} 126 | */ 127 | phone: { 128 | required, 129 | rules: ["string", { min: 1, measurement: "length" }], 130 | }, 131 | }); 132 | -------------------------------------------------------------------------------- /lib/core/validation-engine/validators/parameter-descriptor-validator.injectable.ts: -------------------------------------------------------------------------------- 1 | import Validator, { ValidateOption } from "@/abstract/parameter-validator.abstract"; 2 | import Injectable from "@/decorator/Injectable.decorator"; 3 | import { ParamRules, ParameterDescriptor } from "@/types/rules.type"; 4 | import TypeCheck from "@/utils/type-check.provider"; 5 | import ValidationError from "../validation-error/validation-error"; 6 | import Template from "@/utils/template.provider"; 7 | 8 | export type RangeValidateOption = Pick & 9 | Pick; 10 | 11 | @Injectable() 12 | export default class ParameterDescriptorValidator implements Validator { 13 | constructor( 14 | private readonly typeCheck: TypeCheck, 15 | private readonly template: Template, 16 | ) {} 17 | 18 | public validate(option: ValidateOption): void { 19 | const { rule, param, value } = option; 20 | 21 | if (!this.isParameterDescriptor(rule)) { 22 | return; 23 | } 24 | 25 | const { measurement = "self", min, max, equality } = rule; 26 | 27 | const target = this.getMeasureTarget(value, measurement); 28 | 29 | this.rangeValidator({ min, max, equality, param, value: target, measurement }); 30 | } 31 | 32 | private isParameterDescriptor(rule: ParamRules): rule is ParameterDescriptor { 33 | const isObject = this.typeCheck.isObjectLiteral(rule); 34 | const _rule = rule as ParameterDescriptor; 35 | const hasDescriptorKeys = [_rule?.min, _rule?.max, _rule?.equality, _rule?.measurement].some( 36 | (des) => !this.typeCheck.isUndefinedOrNull(des), 37 | ); 38 | 39 | return isObject && hasDescriptorKeys; 40 | } 41 | 42 | private getMeasureTarget(value: any, measurement: string) { 43 | if (measurement === "self") { 44 | return value; 45 | } 46 | 47 | const target = value[measurement]; 48 | 49 | if (this.typeCheck.isUndefinedOrNull(target)) { 50 | this.template.warn(`Cannot find property "${measurement}" on "${value}".`); 51 | } 52 | 53 | return target; 54 | } 55 | 56 | private rangeValidator(option: RangeValidateOption) { 57 | const { equality, min, max, value, param, measurement } = option; 58 | 59 | let valid: boolean | null = null; 60 | 61 | const hasEqual = !this.typeCheck.isUndefinedOrNull(equality); 62 | const hasMin = !this.typeCheck.isUndefinedOrNull(min); 63 | const hasMax = !this.typeCheck.isUndefinedOrNull(max); 64 | 65 | if (hasEqual) { 66 | valid = equality === value; 67 | } else if (hasMin && hasMax) { 68 | valid = min <= value && max >= value; 69 | } else if (hasMin) { 70 | valid = min <= value; 71 | } else if (hasMax) { 72 | valid = max >= value; 73 | } 74 | 75 | const prop = measurement && measurement !== "self" ? `${param}.${measurement}` : param; 76 | 77 | if (!this.typeCheck.isNull(valid) && !valid) throw new ValidationError({ prop, value, equality, min, max }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/core/scheduled-task/scheduled-task.injectable.ts: -------------------------------------------------------------------------------- 1 | import type { Task } from "@/types/scheduled-task.type"; 2 | import Injectable from "@/decorator/Injectable.decorator"; 3 | import TypeCheck from "@/utils/type-check.provider"; 4 | 5 | /** 排程管理器 */ 6 | @Injectable() 7 | export default class ScheduledTask { 8 | /** 任務清單 */ 9 | #tasks = new Map(); 10 | /** 排程計時器的 id,當此參數為 `undefined` 時,結束排程任務 */ 11 | #timer?: number | NodeJS.Timeout; 12 | /** 排程任務的執行間隔 */ 13 | #interval: number = 1000 * 60 * 10; 14 | 15 | /** 16 | * 排程任務的執行間隔 17 | * @returns 18 | */ 19 | get interval() { 20 | return this.#interval; 21 | } 22 | 23 | constructor(private readonly typeCheck: TypeCheck) {} 24 | 25 | execute() { 26 | this.runTasks(); 27 | } 28 | 29 | /** 30 | * 設定排程任務的執行間隔時間 31 | * @param interval 間隔時間 32 | */ 33 | setInterval(interval?: number) { 34 | if (this.typeCheck.isUndefinedOrNull(interval) || (interval as number) <= 0) { 35 | return; 36 | } 37 | 38 | this.#interval = interval as number; 39 | } 40 | 41 | /** 42 | * 新增不記名排程任務 43 | * @param task 任務 44 | * @description 此方式新增之排程任務,不論其任務內容是否相同,都會新增置排程清單內等待執行。 45 | */ 46 | addTask(task: Task) { 47 | this.#tasks.set(Math.random().toString(), task); 48 | this.startSchedule(); 49 | } 50 | 51 | /** 52 | * 新增記名排程任務 53 | * @description 記名排程任務檢測到相同名稱的任務時,將不會再新增至排程清單。 54 | * @param key 排程任務名稱 55 | * @param task 排程任務 56 | */ 57 | addSingletonTask(key: string, task: Task) { 58 | if (this.#tasks.has(key)) { 59 | return; 60 | } 61 | 62 | this.#tasks.set(key, task); 63 | this.startSchedule(); 64 | } 65 | 66 | /** 清除所有排程任務 */ 67 | clearSchedule() { 68 | clearInterval(this.#timer); 69 | this.#timer = undefined; 70 | this.#tasks.clear(); 71 | } 72 | 73 | /** 開始執行排程任務 */ 74 | private startSchedule() { 75 | // 1. 當排程計時器運行中,就不初始化計時器 76 | if (!this.typeCheck.isUndefinedOrNull(this.#timer)) { 77 | return; 78 | } 79 | 80 | // console.log("排程開始"); 81 | 82 | // 2. 排程計時器初始化 83 | this.#timer = setInterval(() => { 84 | // 2-1. 執行排程任務 85 | const size = this.runTasks(); 86 | 87 | // 2-2. 當所有任務結束,清除計時器 88 | if (!size) { 89 | this.clearSchedule(); 90 | } 91 | }, this.interval); 92 | } 93 | 94 | /** 95 | * 運行排程任務 96 | * @returns 97 | */ 98 | private runTasks() { 99 | // 1. 取得當前時間戳 100 | const now = Date.now(); 101 | // console.log("啟動排程"); 102 | 103 | // 2. 遍歷排程清單 104 | this.#tasks.forEach((task, token) => { 105 | // 2-1. 執行任務並帶入時間戳後,取得取消任務信號 106 | const popSignal = task(now); 107 | // console.log("排程檢查中"); 108 | 109 | // 2-2. 收到信號時,將該任務從排程清單中剃除 110 | if (popSignal) { 111 | this.#tasks.delete(token); 112 | // console.log("排程結束"); 113 | } 114 | }); 115 | 116 | // 3. 返回清單大小 117 | return this.#tasks.size; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /lib/utils/type-check.provider.ts: -------------------------------------------------------------------------------- 1 | import { ObjectLiteral, Type } from "@/types/rules.type"; 2 | 3 | export default class TypeCheck { 4 | get CorrespondingMap(): Record { 5 | return { 6 | char: "isChar", 7 | string: "isString", 8 | number: "isNumber", 9 | int: "isInteger", 10 | nan: "isNaN", 11 | boolean: "isBoolean", 12 | object: "isObject", 13 | null: "isNull", 14 | function: "isFunction", 15 | array: "isArray", 16 | "object-literal": "isObjectLiteral", 17 | undefined: "isUndefined", 18 | bigint: "isBigInt", 19 | symbol: "isSymbol", 20 | }; 21 | } 22 | 23 | get TypeSet(): Type[] { 24 | return [ 25 | "char", 26 | "string", 27 | "number", 28 | "int", 29 | "nan", 30 | "boolean", 31 | "object", 32 | "null", 33 | "function", 34 | "array", 35 | "object-literal", 36 | "undefined", 37 | "bigint", 38 | "symbol", 39 | ]; 40 | } 41 | 42 | isChar(value: any): boolean { 43 | return typeof value === "string" && value.length === 1; 44 | } 45 | 46 | isString(value: any): value is string { 47 | return typeof value === "string"; 48 | } 49 | 50 | isNumber(value: any): value is number { 51 | return typeof value === "number" && !isNaN(value) && isFinite(value); 52 | } 53 | 54 | isInteger(value: any): boolean { 55 | return typeof value === "number" && !isNaN(value) && Number.isInteger(value); 56 | } 57 | 58 | isNaN(value: any): boolean { 59 | return isNaN(value) && value !== undefined; 60 | } 61 | 62 | isBoolean(value: any): value is boolean { 63 | return typeof value === "boolean"; 64 | } 65 | 66 | isObject(value: any): value is object { 67 | const isObj = typeof value === "object" && value !== null; 68 | 69 | return isObj || Array.isArray(value) || typeof value === "function"; 70 | } 71 | 72 | isNull(value: any): value is null { 73 | return value === null; 74 | } 75 | 76 | isFunction(value: any): value is Function { 77 | return typeof value === "function"; 78 | } 79 | 80 | isArray(value: any): value is any[] { 81 | return Array.isArray(value); 82 | } 83 | 84 | isObjectLiteral(value: any): value is ObjectLiteral { 85 | return typeof value === "object" && !Array.isArray(value) && value !== null; 86 | } 87 | 88 | isUndefined(value: any): value is undefined { 89 | return value === undefined; 90 | } 91 | 92 | isUndefinedOrNull(value: any): value is null | undefined { 93 | return value === undefined || value === null; 94 | } 95 | 96 | isBigInt(value: any): value is bigint { 97 | return typeof value === "bigint"; 98 | } 99 | 100 | isSymbol(value: any): value is symbol { 101 | return typeof value === "symbol"; 102 | } 103 | 104 | isValidName(value: any): value is string { 105 | return typeof value === "string" && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value); 106 | } 107 | } 108 | 109 | export const typeCheck = new TypeCheck(); 110 | -------------------------------------------------------------------------------- /test/path-resolver.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from "@jest/globals"; 2 | import PathResolver from "@/utils/path-resolver.provider"; 3 | 4 | describe("PathResolver", () => { 5 | let pathResolver: PathResolver; 6 | 7 | beforeEach(() => { 8 | pathResolver = new PathResolver(); 9 | }); 10 | 11 | describe("trimStart", () => { 12 | it("should trim leading slashes and dots", () => { 13 | expect(pathResolver.trimStart("//hello/world/")).toBe("hello/world/"); 14 | expect(pathResolver.trimStart("./hello/world/")).toBe("hello/world/"); 15 | expect(pathResolver.trimStart("/./hello/world/")).toBe("hello/world/"); 16 | }); 17 | }); 18 | 19 | describe("trimEnd", () => { 20 | it("should trim trailing slashes and dots", () => { 21 | expect(pathResolver.trimEnd("/hello/world//")).toBe("/hello/world"); 22 | expect(pathResolver.trimEnd("/hello/world/./")).toBe("/hello/world"); 23 | expect(pathResolver.trimEnd("/hello/world/../")).toBe("/hello/world"); 24 | }); 25 | }); 26 | 27 | describe("trim", () => { 28 | it("should trim both leading and trailing slashes and dots", () => { 29 | expect(pathResolver.trim("/hello/world//")).toBe("hello/world"); 30 | expect(pathResolver.trim("./hello/world/./")).toBe("hello/world"); 31 | expect(pathResolver.trim("/./hello/world/../")).toBe("hello/world"); 32 | }); 33 | }); 34 | 35 | describe("antiSlash", () => { 36 | it("should split the path into segments", () => { 37 | expect(pathResolver.antiSlash("/hello/world//")).toEqual(["hello", "world"]); 38 | }); 39 | }); 40 | 41 | describe("split", () => { 42 | it("should split paths correctly", () => { 43 | expect(pathResolver.split("https://wtf.com//projects/", "/srgeo/issues//")).toEqual([ 44 | "https://wtf.com", 45 | "projects", 46 | "srgeo", 47 | "issues", 48 | ]); 49 | }); 50 | }); 51 | 52 | describe("join", () => { 53 | it("should join paths correctly", () => { 54 | expect(pathResolver.join("https://wtf.com//projects/", "/srgeo/issues//")).toBe( 55 | "https://wtf.com/projects/srgeo/issues", 56 | ); 57 | }); 58 | }); 59 | 60 | describe("resolve", () => { 61 | it("should resolve relative paths correctly", () => { 62 | expect( 63 | pathResolver.resolve( 64 | "https://wtf.com/projects/", 65 | "../../srgeo//issues", 66 | "./hello/world/", 67 | "/how//../are/you///", 68 | ), 69 | ).toBe("https://wtf.com/srgeo/issues/hello/world/are/you"); 70 | }); 71 | }); 72 | 73 | describe("resolveURL", () => { 74 | it("should resolve URL correctly with query parameters", () => { 75 | const url = pathResolver.resolveURL({ 76 | paths: ["https://wtf.com/", "/hello", "../world/"], 77 | query: { 78 | foo: "bar", 79 | some: "how", 80 | }, 81 | }); 82 | 83 | expect(url).toBe("https://wtf.com/world?foo=bar&some=how"); 84 | }); 85 | 86 | it("should resolve URL correctly without query parameters", () => { 87 | const url = pathResolver.resolveURL({ 88 | paths: ["https://wtf.com/", "/hello", "../world/"], 89 | }); 90 | 91 | expect(url).toBe("https://wtf.com/world"); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /lib/core/api-factory/request-pipe/cache-pipe.injectable.ts: -------------------------------------------------------------------------------- 1 | import RequestPipe, { PipeDetail } from "@/abstract/request-pipe.abstract"; 2 | import { ReqStrategyTypes, RequestExecutor } from "@/types/http.type"; 3 | import { CacheStrategyTypes } from "@/types/karman.type"; 4 | import MemoryCache from "./cache-strategy/memory-cache.provider"; 5 | import CacheStrategy, { CacheData } from "@/abstract/cache-strategy.abstract"; 6 | import { SelectRequestStrategy } from "@/abstract/request-strategy.abstract"; 7 | import ScheduledTask from "@/core/scheduled-task/scheduled-task.injectable"; 8 | import Injectable from "@/decorator/Injectable.decorator"; 9 | import { isEqual } from "lodash-es"; 10 | import LocalStorageCache from "./cache-strategy/local-storage-cache.provider"; 11 | import SessionStorageCache from "./cache-strategy/session-storage-cache.provider"; 12 | 13 | @Injectable() 14 | export default class CachePipe implements RequestPipe { 15 | constructor( 16 | private readonly scheduledTask: ScheduledTask, 17 | private readonly memoryCache: MemoryCache, 18 | private readonly localStorageCache: LocalStorageCache, 19 | private readonly sessionStorageCache: SessionStorageCache, 20 | ) {} 21 | 22 | public chain( 23 | requestDetail: PipeDetail, 24 | options: { cacheStrategyType?: CacheStrategyTypes; expiration?: number }, 25 | ): RequestExecutor> { 26 | const { cacheStrategyType, expiration } = options; 27 | const cache = this.getCacheStrategy(cacheStrategyType ?? "memory"); 28 | const { promiseExecutor, requestExecutor, requestKey, payload } = requestDetail; 29 | const cacheData = cache.get(requestKey); 30 | const currentT = Date.now(); 31 | 32 | if (cacheData && cacheData.expiration > currentT) { 33 | const { res } = cacheData; 34 | const isSameRequest = isEqual(payload, cacheData.payload); 35 | 36 | if (isSameRequest) { 37 | const [reqPromise, abortControler] = requestExecutor(false); 38 | const reqExecutor: RequestExecutor> = () => [reqPromise, abortControler]; 39 | promiseExecutor.resolve(res as SelectRequestStrategy); 40 | 41 | return reqExecutor; 42 | } 43 | } 44 | 45 | const [reqPromise, abortControler] = requestExecutor(true); 46 | 47 | const newPromise = reqPromise.then( 48 | this.promiseCallbackFactory(requestKey, cache, { 49 | payload, 50 | expiration: (expiration ?? 1000 * 60 * 10) + currentT, 51 | }), 52 | ); 53 | 54 | return () => [newPromise, abortControler]; 55 | } 56 | 57 | private getCacheStrategy(type: CacheStrategyTypes): CacheStrategy { 58 | if (type === "memory") return this.memoryCache; 59 | else if (type === "localStorage") return this.localStorageCache; 60 | else if (type === "sessionStorage") return this.sessionStorageCache; 61 | else throw new Error(`failed to use "${type}" cache strategy.`); 62 | } 63 | 64 | private promiseCallbackFactory( 65 | requestKey: string, 66 | cache: CacheStrategy, 67 | cacheData: Omit, "res">, 68 | ) { 69 | return (res: SelectRequestStrategy) => { 70 | const data = { ...cacheData, res }; 71 | this.scheduledTask.addSingletonTask(cache.name, (now) => cache.scheduledTask(now)); 72 | cache.set(requestKey, data); 73 | 74 | return res; 75 | }; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/functional-validator.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, jest, it } from "@jest/globals"; 2 | import FunctionalValidator from "@/core/validation-engine/validators/functional-validator.injectable"; 3 | import TypeCheck from "@/utils/type-check.provider"; 4 | import ValidationError from "@/core/validation-engine/validation-error/validation-error"; 5 | import type { ParamRules } from "@/types/rules.type"; 6 | 7 | describe("FunctionalValidator", () => { 8 | let validator: FunctionalValidator; 9 | let typeCheck: TypeCheck; 10 | 11 | beforeEach(() => { 12 | typeCheck = new TypeCheck(); 13 | validator = new FunctionalValidator(typeCheck); 14 | }); 15 | 16 | describe("validate", () => { 17 | it("should call custom validator function if rule is a custom validator", () => { 18 | const mockCustomValidator = jest.fn(); 19 | Object.defineProperty(mockCustomValidator, "_karman", { value: true }); 20 | const option = { rule: mockCustomValidator, param: "param", value: "value", required: false }; 21 | 22 | validator.validate(option); 23 | 24 | expect(mockCustomValidator).toHaveBeenCalledWith("param", "value"); 25 | }); 26 | 27 | it("should call instanceValidator if rule is a prototype", () => { 28 | const rule = class {}; 29 | const value = new rule(); 30 | const option = { rule, param: "param", value, required: false }; 31 | const instanceValidatorSpy = jest.spyOn(validator, "instanceValidator" as keyof FunctionalValidator); 32 | 33 | validator.validate(option); 34 | 35 | expect(instanceValidatorSpy).toHaveBeenCalledWith(option); 36 | }); 37 | }); 38 | 39 | describe("isPrototype", () => { 40 | it("should return true if rule is a prototype", () => { 41 | const mockRule = class {}; 42 | 43 | const result = validator["isPrototype"](mockRule); 44 | 45 | expect(result).toBe(true); 46 | }); 47 | 48 | it("should return false if rule is not a prototype", () => { 49 | const mockRule = jest.fn(); 50 | Object.defineProperty(mockRule, "_karman", { value: true }); 51 | 52 | const result = validator["isPrototype"](mockRule); 53 | 54 | expect(result).toBe(false); 55 | }); 56 | }); 57 | 58 | describe("isCustomValidator", () => { 59 | it("should return true if rule is a custom validator", () => { 60 | const mockRule = jest.fn(); 61 | Object.defineProperty(mockRule, "_karman", { value: true }); 62 | 63 | const result = validator["isCustomValidator"](mockRule); 64 | 65 | expect(result).toBe(true); 66 | }); 67 | 68 | it("should return false if rule is not a custom validator", () => { 69 | const mockRule = (() => {}) as ParamRules; 70 | 71 | const result = validator["isCustomValidator"](mockRule); 72 | 73 | expect(result).toBe(false); 74 | }); 75 | }); 76 | 77 | describe("instanceValidator", () => { 78 | it("should throw ValidationError if value is not an instance of rule", () => { 79 | const mockRule = class {}; 80 | const option = { rule: mockRule, param: "param", value: {}, required: false }; 81 | 82 | expect(() => validator["instanceValidator"](option)).toThrow(ValidationError); 83 | }); 84 | 85 | it("should not throw ValidationError if value is an instance of rule", () => { 86 | const mockRule = class {}; 87 | const option = { rule: mockRule, param: "param", value: new mockRule(), required: false }; 88 | 89 | expect(() => validator["instanceValidator"](option)).not.toThrow(ValidationError); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /lib/core/layer-builder/layer-builder.injectable.ts: -------------------------------------------------------------------------------- 1 | import Injectable from "@/decorator/Injectable.decorator"; 2 | import { FinalKarman, KarmanConfig, KarmanInstanceConfig } from "@/types/karman.type"; 3 | import TypeCheck from "@/utils/type-check.provider"; 4 | import PathResolver from "@/utils/path-resolver.provider"; 5 | import ScheduledTask from "../scheduled-task/scheduled-task.injectable"; 6 | import Karman from "../karman/karman"; 7 | import Template from "@/utils/template.provider"; 8 | import SchemaType from "../validation-engine/schema-type/schema-type"; 9 | 10 | @Injectable() 11 | export default class LayerBuilder { 12 | constructor( 13 | private readonly typeCheck: TypeCheck, 14 | private readonly scheduledTask: ScheduledTask, 15 | private readonly pathResolver: PathResolver, 16 | private readonly template: Template, 17 | ) {} 18 | 19 | public configure(k: KarmanConfig) { 20 | const { 21 | root, 22 | url, 23 | schema, 24 | // util config 25 | validation, 26 | scheduleInterval, 27 | // cache config 28 | cache, 29 | cacheExpireTime, 30 | cacheStrategy, 31 | // request config 32 | headers, 33 | auth, 34 | timeout, 35 | timeoutErrorMessage, 36 | responseType, 37 | headerMap, 38 | withCredentials, 39 | credentials, 40 | integrity, 41 | keepalive, 42 | mode, 43 | redirect, 44 | referrer, 45 | referrerPolicy, 46 | requestCache, 47 | window, 48 | // hooks 49 | onRequest, 50 | onResponse, 51 | // dependent types 52 | api, 53 | route, 54 | } = k; 55 | 56 | const currentKarman = this.createKarman({ 57 | root, 58 | url, 59 | validation, 60 | cache, 61 | cacheExpireTime, 62 | cacheStrategy, 63 | headers, 64 | auth, 65 | timeout, 66 | timeoutErrorMessage, 67 | responseType, 68 | headerMap, 69 | withCredentials, 70 | credentials, 71 | integrity, 72 | keepalive, 73 | mode, 74 | redirect, 75 | referrer, 76 | referrerPolicy, 77 | requestCache, 78 | window, 79 | onRequest, 80 | onResponse, 81 | }); 82 | 83 | currentKarman.$setDependencies(this.typeCheck, this.pathResolver); 84 | 85 | if (this.typeCheck.isObjectLiteral(route)) 86 | Object.entries(route as Record).forEach(([key, karman]) => { 87 | if (karman.$root) 88 | this.template.throw("Detected that the 'root' property is set to 'true' on a non-root Karman node."); 89 | 90 | karman.$parent = currentKarman; 91 | Object.defineProperty(currentKarman, key, { value: karman, enumerable: true }); 92 | }); 93 | 94 | if (this.typeCheck.isObjectLiteral(api)) 95 | Object.entries(api as Record).forEach(([key, value]) => { 96 | Object.defineProperty(currentKarman, key, { 97 | value: currentKarman.$requestGuard(value.bind(currentKarman)), 98 | enumerable: true, 99 | }); 100 | }); 101 | 102 | if (root) { 103 | this.scheduledTask.setInterval(scheduleInterval); 104 | currentKarman.$inherit(); 105 | } 106 | 107 | this.setSchema(currentKarman, schema); 108 | 109 | return currentKarman as FinalKarman; 110 | } 111 | 112 | private createKarman(k: KarmanInstanceConfig): Karman { 113 | return new Karman(k); 114 | } 115 | 116 | private setSchema(k: Karman, schema?: SchemaType[]) { 117 | schema?.forEach((s) => k.$getRoot().$setSchema(s.name, s)); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /lib/types/http.type.ts: -------------------------------------------------------------------------------- 1 | import { Primitive } from "./common.type"; 2 | import { SyncHooks, AsyncHooks } from "./hooks.type"; 3 | import { CacheConfig, UtilConfig } from "./karman.type"; 4 | import { PayloadDef } from "./payload-def.type"; 5 | 6 | export type HttpMethod = 7 | | "get" 8 | | "GET" 9 | | "delete" 10 | | "DELETE" 11 | | "head" 12 | | "HEAD" 13 | | "options" 14 | | "OPTIONS" 15 | | "post" 16 | | "POST" 17 | | "put" 18 | | "PUT" 19 | | "patch" 20 | | "PATCH"; 21 | 22 | export type ResponseType = XMLHttpRequestResponseType | "stream"; 23 | 24 | export type MimeType = 25 | | "text/plain" 26 | | "application/javascript" 27 | | "application/json" 28 | | "text/html" 29 | | "application/xml" 30 | | "application/pdf" 31 | | "application/x-www-form-urlencoded" 32 | | "multipart/form-data" 33 | | "image/jpeg" 34 | | "image/png" 35 | | "image/gif" 36 | | "audio/mpeg" 37 | | "audio/wav" 38 | | "video/mp4" 39 | | "video/quicktime"; 40 | 41 | export interface HttpAuthentication { 42 | username: string; 43 | password: string; 44 | } 45 | 46 | export interface HeadersConfig { 47 | ["Content-Type"]?: MimeType | string; 48 | ["Authorization"]?: `Basic ${string}:${string}`; 49 | [x: string]: any; 50 | } 51 | 52 | export type ReqStrategyTypes = "xhr" | "fetch"; 53 | 54 | export interface RequestConfig 55 | extends Omit { 56 | headers?: HeadersConfig; 57 | auth?: Partial; 58 | timeout?: number; 59 | timeoutErrorMessage?: string; 60 | responseType?: ResponseType; 61 | headerMap?: boolean; 62 | withCredentials?: boolean; 63 | requestStrategy?: T; 64 | /** 65 | * Only available on fetch 66 | */ 67 | requestCache?: RequestCache; 68 | } 69 | 70 | export interface ApiConfig 71 | extends RequestConfig, 72 | SyncHooks, 73 | AsyncHooks, 74 | UtilConfig, 75 | CacheConfig { 76 | url?: string; 77 | method?: HttpMethod; 78 | payloadDef?: P; 79 | dto?: D; 80 | } 81 | 82 | export interface HttpConfig extends RequestConfig { 83 | url: string; 84 | method?: HttpMethod; 85 | } 86 | 87 | export type HttpBody = Document | XMLHttpRequestBodyInit | null; 88 | 89 | export interface PromiseExecutor { 90 | resolve(value: D): void; 91 | reject(reason?: unknown): void; 92 | } 93 | 94 | export type XhrHooksHandler = ( 95 | e: ProgressEvent | Event, 96 | config: HttpConfig, 97 | xhr: XMLHttpRequest, 98 | executer: PromiseExecutor, 99 | ) => void; 100 | 101 | export type RequestExecutor = (send?: boolean) => [requestPromise: Promise, abortController: () => void]; 102 | 103 | export interface XhrResponse { 104 | data: D; 105 | status: number; 106 | statusText: string; 107 | headers: string | Record; 108 | config: HttpConfig | undefined; 109 | request: XMLHttpRequest; 110 | [x: string]: any; 111 | } 112 | 113 | export interface FetchResponse { 114 | readonly headers: Headers; 115 | readonly ok: boolean; 116 | readonly redirected: boolean; 117 | readonly status: number; 118 | readonly statusText: string; 119 | readonly type: ResponseType; 120 | readonly url: string; 121 | readonly body: D extends Primitive ? D : ReadableStream | ArrayBuffer | Blob | null; 122 | readonly bodyUsed: boolean; 123 | clone?(): FetchResponse; 124 | arrayBuffer?(): Promise; 125 | blob?(): Promise; 126 | formData?(): Promise; 127 | text?(): Promise; 128 | json?(): Promise; 129 | } 130 | 131 | export default interface RequestDetail { 132 | requestKey: string; 133 | requestExecutor: RequestExecutor; 134 | promiseExecutor: PromiseExecutor; 135 | config: HttpConfig; 136 | // eslint-disable-next-line semi 137 | } 138 | -------------------------------------------------------------------------------- /lib/decorator/IOCContainer.decorator.ts: -------------------------------------------------------------------------------- 1 | import type { ClassDecorator, ClassSignature } from "@/types/common.type"; 2 | import type { Provider, Importer } from "@/types/ioc.type"; 3 | import type { IOCOptions } from "@/types/decorator.type"; 4 | import { META_PARAMTYPES, META_EXPOSE } from "@/assets/METADATA"; 5 | 6 | /** 7 | * Inversion of control container 8 | * @param options 9 | */ 10 | export default function IOCContainer(options: IOCOptions = {}): ClassDecorator { 11 | const { provides, imports, exports = [] } = options; 12 | 13 | /** 14 | * 需要直接暴露在 IoC 容器實例上的功能模組 15 | */ 16 | const exposeModules = new Map(); 17 | 18 | const getExport = (value: unknown) => { 19 | for (const prototype of exports) { 20 | if (value instanceof prototype) exposeModules.set(prototype.name, value as E); 21 | } 22 | }; 23 | 24 | return (target) => { 25 | /** 26 | * provides 的 token 與實例陣列 27 | */ 28 | const providers = (provides?.map((slice: ClassSignature) => { 29 | // const expose = (Reflect.getMetadata(META_EXPOSE, slice) ?? "") as string; 30 | const value = new slice(); 31 | getExport(value); 32 | // if (expose) { 33 | // exposeModules.set(expose, value); 34 | // } 35 | 36 | return [Symbol.for(slice.toString()), value]; 37 | }) ?? []) as Provider[]; 38 | 39 | /** 40 | * imports 的 token、建構函數與其所需依賴 41 | */ 42 | const importers = (imports?.map((slice) => { 43 | const token = Symbol.for(slice.toString()); 44 | 45 | const deps = (Reflect.getMetadata(META_PARAMTYPES, slice) ?? []) as ClassSignature[]; 46 | 47 | const requirements = deps.map((dep: ClassSignature) => Symbol.for(dep.toString())); 48 | 49 | return [ 50 | token, 51 | { 52 | constructor: slice, 53 | requirements, 54 | }, 55 | ]; 56 | }) ?? []) as Importer[]; 57 | 58 | /** 59 | * 作為 Ioc 容器的類本身所需的依賴 60 | */ 61 | const targetDep = (Reflect.getMetadata(META_PARAMTYPES, target) ?? []) as ClassSignature[]; 62 | 63 | const targetDepToken = (targetDep?.map((dep: ClassSignature) => Symbol.for(dep.toString())) ?? []) as symbol[]; 64 | 65 | /** 66 | * 已建立的實例 67 | */ 68 | const instances = new Map(providers); 69 | /** 70 | * 等待建立的實例 71 | */ 72 | const queue = new Map(importers); 73 | 74 | /** 75 | * 當還有未被建立實例的類,就持續遍歷 queue 76 | */ 77 | while (queue.size) { 78 | const cacheSize = queue.size; 79 | 80 | queue.forEach(({ constructor, requirements }, token) => { 81 | const deps: {}[] = []; 82 | 83 | let stop = false; 84 | 85 | for (const token of requirements) { 86 | const dep = instances.get(token) as {} | undefined; 87 | 88 | if (!dep) { 89 | stop = true; 90 | 91 | break; 92 | } 93 | 94 | deps.push(dep); 95 | } 96 | 97 | if (stop) { 98 | return; 99 | } 100 | 101 | const value = new constructor(...(deps || [])); 102 | getExport(value); 103 | // const expose = (Reflect.getMetadata(META_EXPOSE, constructor) ?? "") as string; 104 | 105 | // if (expose) { 106 | // exposeModules.set(expose, value); 107 | // } 108 | 109 | instances.set(token, value); 110 | 111 | queue.delete(token); 112 | }); 113 | 114 | if (cacheSize === queue.size) { 115 | /** 116 | * 跑到這裡代表有依賴沒被傳入 IoC 117 | */ 118 | console.warn("Missing dependency..."); 119 | 120 | break; 121 | } 122 | } 123 | 124 | /** 125 | * 裝飾器最終會返回原類別的繼承類 126 | */ 127 | return class IoC extends target { 128 | constructor(...args: any[]) { 129 | /** 130 | * 給 IoC 注入所需依賴 131 | */ 132 | const injections = targetDepToken.map((token: symbol) => { 133 | const dep = instances.get(token); 134 | 135 | if (dep) { 136 | return dep; 137 | } 138 | 139 | throw new Error("Missing dependency."); 140 | }); 141 | 142 | super(...injections); 143 | 144 | /** 145 | * 掛載要暴露的功能模組 146 | */ 147 | exposeModules.forEach((value, name) => { 148 | Object.defineProperty(this, name, { 149 | value, 150 | }); 151 | }); 152 | } 153 | }; 154 | }; 155 | } 156 | -------------------------------------------------------------------------------- /assets/doc/en/middleware.md: -------------------------------------------------------------------------------- 1 | # Middleware 2 | 3 | Middleware refers to functions executed at some point during the lifecycle of the final API execution. It mainly consists of two types: 4 | 5 | - `Interceptors`: Configured on the karman node, interceptors mainly intercept requests (req) and response (res) objects for all final APIs below that node. They can access object properties and conditionally perform side effects. Interceptors can only be defined as synchronous tasks. 6 | - `Hooks`: Configured when defining APIs or invoking final APIs, hooks defined apply only to that final API. Some hooks can be defined as asynchronous tasks or return values. The behavior or parameters can be changed through the return values. 7 | 8 | ```js 9 | import { defineKarman, defineAPI } from "@vic0627/karman"; 10 | 11 | const hooksKarman = defineKarman({ 12 | // ... 13 | validation: true, 14 | // 👇 Interceptors 15 | /** 16 | * Intercepts the request object, including the request url, method, headers, and other request configurations 17 | * @param req - Request object 18 | */ 19 | onRequest(req) { 20 | console.log("onRequest"); 21 | req.headers["Access-Token"] = localStorage["TOKEN"]; 22 | }, 23 | /** 24 | * Intercepts the response object, depending on the request strategy chosen for each final API, there may be different specifications. Caution should be exercised when accessing object properties. 25 | * @param res - Response object 26 | * @returns {boolean | undefined} You can judge the validity of the status code and return a boolean value. By default, it is in the range of >= 200 and < 300. 27 | */ 28 | onResponse(res) { 29 | console.log("onResponse"); 30 | const { status } = res; 31 | 32 | return status >= 200 && status < 300; 33 | }, 34 | api: { 35 | hookTest: defineAPI({ 36 | // ... 37 | // 👇 Hooks 38 | /** 39 | * Called before validation, but will be ignored if `validation === false` 40 | * Usually used to dynamically change validation rules, provide default parameter values, or manually validate more complex parameter types 41 | * @param payloadDef - Parameter definition object 42 | * @param payload - Actual parameters received by the final API 43 | */ 44 | onBeforeValidate(payloadDef, payload) { 45 | console.log("onBeforeValidate"); 46 | }, 47 | /** 48 | * Executed before constructing the final request URL and request body. Can be used to provide default parameter values or perform other data processing actions on the payload object 49 | * @param payload - Actual parameters received by the final API 50 | * @returns {Record | undefined} If the return value is an object, it will be used as the new payload to construct the URL and request body 51 | */ 52 | onRebuildPayload(payload) { 53 | console.log("onRebuildPayload"); 54 | }, 55 | /** 56 | * Called before making the request. Can be used to construct the request body, such as creating FormData, etc. 57 | * @param url - Request URL 58 | * @param payload - Actual parameters received by the final API 59 | * @returns {Document | BodyInit | null | undefined} If there is a return value, it will be used as the request body when sending the final request 60 | */ 61 | onBeforeRequest(url, payload) { 62 | console.log("onBeforeRequest"); 63 | }, 64 | /** 65 | * Called when the request is successful. Asynchronous tasks can be configured, usually used for preliminary data processing after receiving the response 66 | * @param res - Response object 67 | * @returns {Promise | undefined} If there is a return value, it will be the return value of the final API 68 | */ 69 | async onSuccess(res) { 70 | console.log("onSuccess"); 71 | 72 | return "get response"; 73 | }, 74 | /** 75 | * Called when the request fails. Asynchronous tasks can be configured, usually used for error handling 76 | * @param err - Error object 77 | * @returns {Promise | undefined} If there is a return value, the final API will not throw an error, and the return value of onError will be used as the return value when an error occurs in the final API 78 | */ 79 | async onError(err) { 80 | console.log("onError"); 81 | 82 | return "response from error"; 83 | }, 84 | /** 85 | * Hooks that will always be executed at the end of the final API. Asynchronous tasks can be configured, usually used for side effects 86 | */ 87 | async onFinally() { 88 | console.log("onFinally"); 89 | }, 90 | }), 91 | }, 92 | }); 93 | ``` 94 | 95 | > [!WARNING] 96 | > When configuring Middleware, it's preferable to declare using regular functions instead of arrow functions to avoid losing the this context if accessing the karman node within the Middleware. 97 | 98 | > [!WARNING] 99 | > If a timeout set actively or the abort method is invoked, onResponse will not be executed. 100 | -------------------------------------------------------------------------------- /demo/api-spec/postman.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "e2d6798b-4d75-4db1-9b33-2fb0fcaa7d99", 4 | "name": "fake-store", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_exporter_id": "26449722" 7 | }, 8 | "item": [ 9 | { 10 | "name": "product", 11 | "item": [ 12 | { 13 | "name": "get all products", 14 | "request": { 15 | "method": "GET", 16 | "header": [], 17 | "url": { 18 | "raw": "{{FAKE_STORE}}/products", 19 | "host": [ 20 | "{{FAKE_STORE}}" 21 | ], 22 | "path": [ 23 | "products" 24 | ], 25 | "query": [ 26 | { 27 | "key": "limit", 28 | "value": "10", 29 | "description": "回傳筆數", 30 | "disabled": true 31 | }, 32 | { 33 | "key": "sort", 34 | "value": "asc", 35 | "description": "排序策略", 36 | "disabled": true 37 | } 38 | ] 39 | } 40 | }, 41 | "response": [] 42 | }, 43 | { 44 | "name": "get single product by id", 45 | "request": { 46 | "method": "GET", 47 | "header": [], 48 | "url": { 49 | "raw": "{{FAKE_STORE}}/products/:id", 50 | "host": [ 51 | "{{FAKE_STORE}}" 52 | ], 53 | "path": [ 54 | "products", 55 | ":id" 56 | ], 57 | "variable": [ 58 | { 59 | "key": "id", 60 | "value": "10" 61 | } 62 | ] 63 | } 64 | }, 65 | "response": [] 66 | }, 67 | { 68 | "name": "create a new product", 69 | "request": { 70 | "method": "POST", 71 | "header": [], 72 | "body": { 73 | "mode": "raw", 74 | "raw": "{\r\n \"title\": \"\",\r\n \"price\": 0,\r\n \"description\": \"\",\r\n \"image\": \"\"\r\n}", 75 | "options": { 76 | "raw": { 77 | "language": "json" 78 | } 79 | } 80 | }, 81 | "url": { 82 | "raw": "{{FAKE_STORE}}/products", 83 | "host": [ 84 | "{{FAKE_STORE}}" 85 | ], 86 | "path": [ 87 | "products" 88 | ] 89 | } 90 | }, 91 | "response": [] 92 | }, 93 | { 94 | "name": "update single product", 95 | "request": { 96 | "method": "PUT", 97 | "header": [], 98 | "body": { 99 | "mode": "raw", 100 | "raw": "{\r\n \"title\": \"\",\r\n \"price\": 0,\r\n \"description\": \"\",\r\n \"image\": \"\"\r\n}", 101 | "options": { 102 | "raw": { 103 | "language": "json" 104 | } 105 | } 106 | }, 107 | "url": { 108 | "raw": "{{FAKE_STORE}}/products/:id", 109 | "host": [ 110 | "{{FAKE_STORE}}" 111 | ], 112 | "path": [ 113 | "products", 114 | ":id" 115 | ], 116 | "variable": [ 117 | { 118 | "key": "id", 119 | "value": "10" 120 | } 121 | ] 122 | } 123 | }, 124 | "response": [] 125 | }, 126 | { 127 | "name": "modify single product", 128 | "request": { 129 | "method": "PATCH", 130 | "header": [], 131 | "body": { 132 | "mode": "raw", 133 | "raw": "{\r\n \"title\": \"\",\r\n \"price\": 0,\r\n \"description\": \"\",\r\n \"image\": \"\"\r\n}", 134 | "options": { 135 | "raw": { 136 | "language": "json" 137 | } 138 | } 139 | }, 140 | "url": { 141 | "raw": "{{FAKE_STORE}}/products/:id", 142 | "host": [ 143 | "{{FAKE_STORE}}" 144 | ], 145 | "path": [ 146 | "products", 147 | ":id" 148 | ], 149 | "variable": [ 150 | { 151 | "key": "id", 152 | "value": "" 153 | } 154 | ] 155 | } 156 | }, 157 | "response": [] 158 | }, 159 | { 160 | "name": "delete a product", 161 | "request": { 162 | "method": "DELETE", 163 | "header": [], 164 | "url": { 165 | "raw": "{{FAKE_STORE}}/products/:id", 166 | "host": [ 167 | "{{FAKE_STORE}}" 168 | ], 169 | "path": [ 170 | "products", 171 | ":id" 172 | ], 173 | "variable": [ 174 | { 175 | "key": "id", 176 | "value": "" 177 | } 178 | ] 179 | } 180 | }, 181 | "response": [] 182 | }, 183 | { 184 | "name": "get all product's categories", 185 | "request": { 186 | "method": "GET", 187 | "header": [], 188 | "url": { 189 | "raw": "{{FAKE_STORE}}/products/categories", 190 | "host": [ 191 | "{{FAKE_STORE}}" 192 | ], 193 | "path": [ 194 | "products", 195 | "categories" 196 | ] 197 | } 198 | }, 199 | "response": [] 200 | }, 201 | { 202 | "name": "get products by category", 203 | "request": { 204 | "method": "GET", 205 | "header": [], 206 | "url": { 207 | "raw": "{{FAKE_STORE}}/products/:category", 208 | "host": [ 209 | "{{FAKE_STORE}}" 210 | ], 211 | "path": [ 212 | "products", 213 | ":category" 214 | ], 215 | "variable": [ 216 | { 217 | "key": "category", 218 | "value": "" 219 | } 220 | ] 221 | } 222 | }, 223 | "response": [] 224 | } 225 | ], 226 | "description": "**product management \nreference from** [fake-store](https://fakestoreapi.com/)" 227 | } 228 | ] 229 | } -------------------------------------------------------------------------------- /lib/utils/path-resolver.provider.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 路徑模組 3 | * @description 處理所有路徑相關操作,包括判斷、拼接、建構等。 4 | */ 5 | export default class PathResolver { 6 | get #dot() { 7 | return "."; 8 | } 9 | 10 | get #dbDot() { 11 | return ".."; 12 | } 13 | 14 | get #slash() { 15 | return "/"; 16 | } 17 | 18 | get #dbSlash() { 19 | return "//"; 20 | } 21 | 22 | get #http() { 23 | return "http:"; 24 | } 25 | 26 | get #https() { 27 | return "https:"; 28 | } 29 | 30 | /** 31 | * 路徑是否以 `.` 開頭 32 | * @param path 路徑字串 33 | * @param position 從哪個索引值開始判斷 34 | */ 35 | #dotStart(path: string, position?: number) { 36 | return path.startsWith(this.#dot, position); 37 | } 38 | 39 | /** 40 | * 路徑是否以 `.` 結尾 41 | * @param path 路徑字串 42 | * @param position 從哪個索引值開始判斷 43 | */ 44 | #dotEnd(path: string, endPosition?: number) { 45 | return path.endsWith(this.#dot, endPosition); 46 | } 47 | 48 | /** 49 | * 路徑是否以 `/` 開頭 50 | * @param path 路徑字串 51 | * @param position 從哪個索引值開始判斷 52 | */ 53 | #slashStart(path: string, position?: number) { 54 | return path.startsWith(this.#slash, position); 55 | } 56 | 57 | /** 58 | * 路徑是否以 `/` 結尾 59 | * @param path 路徑字串 60 | * @param position 從哪個索引值開始判斷 61 | */ 62 | 63 | #slashEnd(path: string, endPosition?: number) { 64 | return path.endsWith(this.#slash, endPosition); 65 | } 66 | 67 | /** 68 | * 修剪字串開頭的 `/` 69 | * @param path 路徑字串 70 | * @example 71 | * const str = DI.trimStart("//hello/world/"); 72 | * console.log(str); // => "hello/world/" 73 | */ 74 | trimStart(path: string) { 75 | while (this.#slashStart(path) || this.#dotStart(path)) { 76 | path = path.slice(1); 77 | } 78 | 79 | return path; 80 | } 81 | 82 | /** 83 | * 修剪字串結尾的 `/` 84 | * @param path 路徑字串 85 | * @example 86 | * const str = DI.trimEnd("/hello/world//"); 87 | * console.log(str); // => "/hello/world" 88 | */ 89 | trimEnd(path: string) { 90 | while (this.#slashEnd(path) || this.#dotEnd(path)) { 91 | path = path.slice(0, -1); 92 | } 93 | 94 | return path; 95 | } 96 | 97 | /** 98 | * 修剪字串兩端的 `/` 99 | * @param path 路徑字串 100 | * @example 101 | * const str = DI.trim("/hello/world//"); 102 | * console.log(str); // => "hello/world" 103 | */ 104 | trim(path: string) { 105 | path = this.trimStart(path); 106 | 107 | return this.trimEnd(path); 108 | } 109 | 110 | /** 111 | * 去除字串的 `/` 並返回所有路徑 112 | * @param path 路徑字串 113 | * @returns 114 | * @example 115 | * const arr = DI.antiSlash("/hello/world//"); 116 | * console.log(arr); // => ["hello", "world"] 117 | */ 118 | antiSlash(path: string) { 119 | return path.split(/\/+/).filter((segment) => !!segment); 120 | } 121 | 122 | /** 123 | * 將所有路徑切割乾淨 124 | * @param paths 路徑字串們 125 | * @example 126 | * const arr = DI.split("https://wtf.com//projects/", "/srgeo/issues//"); 127 | * console.log(arr); // => ["https://wtf.com", "projects", "srgeo", "issues"] 128 | */ 129 | split(...paths: string[]) { 130 | const splitPaths: string[] = paths.map(this.antiSlash).flat(); 131 | 132 | if (splitPaths[0] === this.#http || splitPaths[0] === this.#https) { 133 | splitPaths[0] = splitPaths[0] + this.#dbSlash + splitPaths[1]; 134 | splitPaths.splice(1, 1); 135 | } 136 | 137 | return splitPaths; 138 | } 139 | 140 | /** 141 | * 合併所有路徑,但不處理相對路徑 142 | * @param paths 路徑字串們 143 | * @example 144 | * const str = DI.split("https://wtf.com//projects/", "/srgeo/issues//"); 145 | * console.log(str); // => "https://wtf.com/projects/srgeo/issues" 146 | */ 147 | join(...paths: string[]) { 148 | return this.split(...paths).join(this.#slash); 149 | } 150 | 151 | /** 152 | * 處理、合併包含相對路徑的所有路徑 153 | * @param paths 路徑字串們 154 | * @example 155 | * const str = DI.resolve( 156 | * "https://wtf.com/projects/", 157 | * "../../srgeo//issues", 158 | * "./hello/world/", 159 | * "/how//../are/you///" 160 | * ); 161 | * console.log(str); // => "https://wtf.com/srgeo/issues/hello/world/are/you" 162 | */ 163 | resolve(...paths: string[]) { 164 | return this.split(...paths).reduce((pre, cur, i) => { 165 | if (cur === this.#dot) { 166 | return pre; 167 | } 168 | 169 | if (cur === this.#dbDot) { 170 | const lastSlash = pre.lastIndexOf(this.#slash); 171 | 172 | const noSlash = lastSlash === -1; 173 | 174 | const httpLeft = [5, 6, 7].includes(lastSlash) && (pre.startsWith(this.#http) || pre.startsWith(this.#https)); 175 | 176 | if (noSlash || httpLeft) { 177 | return pre; 178 | } 179 | 180 | return pre.slice(0, lastSlash); 181 | } 182 | 183 | if (!i) { 184 | return cur; 185 | } 186 | 187 | return (pre += this.#slash + cur); 188 | }, ""); 189 | } 190 | 191 | /** 192 | * 建構完整 url 字串 193 | * @example 194 | * const url = DI.resolveURL({ 195 | * paths: ["https://wtf.com/", "/hello", "../world/"], 196 | * query: { 197 | * foo: "bar", 198 | * some: "how" 199 | * } 200 | * }); 201 | * console.log(url); // => "https://wtf.com/world?foo=bar&some=how" 202 | */ 203 | resolveURL(options: { query?: Record; paths: string[] }) { 204 | const { query, paths } = options; 205 | 206 | let url = this.resolve(...paths); 207 | 208 | if (!query) { 209 | return url; 210 | } 211 | 212 | const queryParams = Object.entries(query); 213 | 214 | queryParams.length && 215 | queryParams.forEach(([key, value], i) => { 216 | if (!i) { 217 | url += "?"; 218 | } else { 219 | url += "&"; 220 | } 221 | 222 | url += key + "=" + value; 223 | }); 224 | 225 | return url; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /lib/core/validation-engine/validators/array-validator.injectable.ts: -------------------------------------------------------------------------------- 1 | import Validator, { ValidateOption } from "@/abstract/parameter-validator.abstract"; 2 | import Template from "@/utils/template.provider"; 3 | import TypeCheck from "@/utils/type-check.provider"; 4 | import ValidationError, { ValidationErrorOptions } from "../validation-error/validation-error"; 5 | import { ParamRules } from "@/types/rules.type"; 6 | import Injectable from "@/decorator/Injectable.decorator"; 7 | import Karman from "@/core/karman/karman"; 8 | import SchemaType from "../schema-type/schema-type"; 9 | 10 | interface ArrayInfo { 11 | min?: number; 12 | max?: number; 13 | equal?: number; 14 | type?: string; 15 | } 16 | 17 | interface TypeValidatorInArray { 18 | (param: string, index: number, value: any): void; 19 | } 20 | 21 | @Injectable() 22 | export default class ArrayValidator implements Validator { 23 | private readonly LEFT_BRACKET = "["; 24 | private readonly RIGHT_BRACKET = "]"; 25 | private readonly COLON = ":"; 26 | 27 | constructor( 28 | private readonly typeCheck: TypeCheck, 29 | private readonly template: Template, 30 | ) {} 31 | 32 | public maybeArraySyntax(rule: ParamRules) { 33 | if (!this.typeCheck.isString(rule)) return false; 34 | 35 | return rule.includes(this.LEFT_BRACKET) || rule.includes(this.RIGHT_BRACKET) || rule.includes(this.COLON); 36 | } 37 | 38 | public validate(option: ValidateOption, karman?: Karman): void { 39 | const { rule, param, value } = option; 40 | 41 | if (!this.typeCheck.isString(rule)) return; 42 | 43 | const { min, max, equal, type } = this.resolveArraySyntax(rule); 44 | 45 | if (!this.typeCheck.isArray(value)) 46 | throw new ValidationError({ 47 | prop: param, 48 | value, 49 | type: "array", 50 | }); 51 | 52 | this.validateRange(param, value, { min, max, equal }); 53 | 54 | const typeValidator = this.getTypeValidator(type, karman); 55 | 56 | value.forEach((val, idx) => typeValidator(param, idx, val)); 57 | } 58 | 59 | private resolveArraySyntax(rule: string): ArrayInfo { 60 | const maxIdx = rule.length - 1; 61 | const leftIdx = rule.indexOf(this.LEFT_BRACKET); 62 | const _leftIdx = rule.lastIndexOf(this.LEFT_BRACKET); 63 | const rightIdx = rule.indexOf(this.RIGHT_BRACKET); 64 | const _rightIdx = rule.lastIndexOf(this.RIGHT_BRACKET); 65 | const colonIdx = rule.indexOf(this.COLON); 66 | const _colonIdx = rule.lastIndexOf(this.COLON); 67 | 68 | const info: ArrayInfo = {}; 69 | 70 | const badArraySyntax = 71 | leftIdx === -1 || 72 | rightIdx === -1 || 73 | leftIdx !== _leftIdx || 74 | rightIdx !== _rightIdx || 75 | !(leftIdx < rightIdx && rightIdx === maxIdx); 76 | 77 | if (badArraySyntax) this.template.throw(`bad array syntax '${rule}' for rules`); 78 | 79 | if (colonIdx !== -1) { 80 | if (colonIdx !== _colonIdx || colonIdx < leftIdx || colonIdx > rightIdx) 81 | this.template.throw(`bad range syntax '${rule}' for rules`); 82 | 83 | info.min = this.getRangeValue(rule, leftIdx, colonIdx); 84 | info.max = this.getRangeValue(rule, colonIdx, rightIdx); 85 | } else info.equal = this.getRangeValue(rule, leftIdx, rightIdx); 86 | 87 | this.checkRange({ min: info.min, max: info.max, equal: info.equal }); 88 | 89 | info.type = this.getType(rule, leftIdx); 90 | 91 | return info; 92 | } 93 | 94 | private checkRange(range: Omit) { 95 | const { min, max, equal } = range; 96 | 97 | if (this.typeCheck.isNaN(min)) this.template.throw(`invalid minimum value '${min}'`); 98 | if (this.typeCheck.isNaN(max)) this.template.throw(`invalid maximum value '${max}'`); 99 | if (this.typeCheck.isNaN(min)) this.template.throw(`invalid equality value '${equal}'`); 100 | } 101 | 102 | private getRangeValue(rule: string, start: number, end: number): number | undefined { 103 | let value: string = ""; 104 | for (let i = start + 1; i < end; i++) value += rule[i]; 105 | return value === "" ? undefined : +value; 106 | } 107 | 108 | private validateRange(param: string, value: any[], range: Omit) { 109 | const { min, max, equal } = range; 110 | const l = value.length; 111 | const errorOption: ValidationErrorOptions = { 112 | prop: param, 113 | measurement: "length", 114 | value: l, 115 | }; 116 | 117 | const isEqual = this.typeCheck.isNumber(equal); 118 | const isMin = this.typeCheck.isNumber(min); 119 | const isMax = this.typeCheck.isNumber(max); 120 | 121 | if (isEqual) { 122 | if (l === equal) return; 123 | 124 | errorOption.equality = equal; 125 | } else if (isMin && isMax) { 126 | if (l >= min && l <= max) return; 127 | 128 | errorOption.min = min; 129 | errorOption.max = max; 130 | } else if (isMin && !isMax) { 131 | if (l >= min) return; 132 | 133 | errorOption.min = min; 134 | } else if (!isMin && isMax) { 135 | if (l <= max) return; 136 | 137 | errorOption.max = max; 138 | } else return; 139 | 140 | throw new ValidationError(errorOption); 141 | } 142 | 143 | private getType(rule: string, end: number): string { 144 | let value: string = ""; 145 | for (let i = 0; i < end; i++) value += rule[i]; 146 | return value; 147 | } 148 | 149 | private getTypeValidator(type?: string, karman?: Karman): TypeValidatorInArray { 150 | const key: keyof TypeCheck = this.typeCheck.CorrespondingMap[type ?? ""]; 151 | const schema: SchemaType | undefined = karman?.$getRoot()?.$schema.get(type ?? ""); 152 | 153 | if (!key && !schema) this.template.throw(`invalid type '${type}'`); 154 | 155 | let validator: TypeValidatorInArray = (param: string, index: number, value: any) => { 156 | if (!(this.typeCheck[key] as (value: any) => boolean)(value)) 157 | throw new ValidationError({ 158 | prop: `${param}[${index}]`, 159 | value, 160 | type, 161 | }); 162 | }; 163 | 164 | if (schema) { 165 | validator = (param: string, index: number, value: any) => { 166 | schema.validate.call(schema, { param: `${param}[${index}]`, value } as ValidateOption); 167 | }; 168 | } 169 | 170 | return validator; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /test/type-check.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, it, expect } from "@jest/globals"; 2 | import TypeCheck from "@/utils/type-check.provider"; 3 | 4 | describe("TypeCheck", () => { 5 | let typeCheck: TypeCheck; 6 | 7 | beforeEach(() => { 8 | typeCheck = new TypeCheck(); 9 | }); 10 | 11 | describe("CorrespondingMap", () => { 12 | it("should return correct map of types to methods", () => { 13 | const expectedMap = { 14 | char: "isChar", 15 | string: "isString", 16 | number: "isNumber", 17 | int: "isInteger", 18 | nan: "isNaN", 19 | boolean: "isBoolean", 20 | object: "isObject", 21 | null: "isNull", 22 | function: "isFunction", 23 | array: "isArray", 24 | "object-literal": "isObjectLiteral", 25 | undefined: "isUndefined", 26 | bigint: "isBigInt", 27 | symbol: "isSymbol", 28 | }; 29 | 30 | expect(typeCheck.CorrespondingMap).toEqual(expectedMap); 31 | }); 32 | }); 33 | 34 | describe("TypeSet", () => { 35 | it("should return correct array of types", () => { 36 | const expectedArray = [ 37 | "char", 38 | "string", 39 | "number", 40 | "int", 41 | "nan", 42 | "boolean", 43 | "object", 44 | "null", 45 | "function", 46 | "array", 47 | "object-literal", 48 | "undefined", 49 | "bigint", 50 | "symbol", 51 | ]; 52 | 53 | expect(typeCheck.TypeSet).toEqual(expectedArray); 54 | }); 55 | }); 56 | 57 | describe("isChar", () => { 58 | it("should return true if value is a single character string", () => { 59 | expect(typeCheck.isChar("a")).toBe(true); 60 | expect(typeCheck.isChar("")).toBe(false); 61 | expect(typeCheck.isChar("ab")).toBe(false); 62 | expect(typeCheck.isChar(1)).toBe(false); 63 | }); 64 | }); 65 | 66 | describe("isString", () => { 67 | it("should return true if value is a string", () => { 68 | expect(typeCheck.isString("")).toBe(true); 69 | expect(typeCheck.isString("hello")).toBe(true); 70 | expect(typeCheck.isString(123)).toBe(false); 71 | }); 72 | }); 73 | 74 | describe("isNumber", () => { 75 | it("should return true if value is a number", () => { 76 | expect(typeCheck.isNumber(123)).toBe(true); 77 | expect(typeCheck.isNumber(0)).toBe(true); 78 | expect(typeCheck.isNumber("123")).toBe(false); 79 | expect(typeCheck.isNumber(NaN)).toBe(false); 80 | }); 81 | }); 82 | 83 | describe("isInteger", () => { 84 | it("should return true if value is an integer", () => { 85 | expect(typeCheck.isInteger(123)).toBe(true); 86 | expect(typeCheck.isInteger(0)).toBe(true); 87 | expect(typeCheck.isInteger(123.45)).toBe(false); 88 | expect(typeCheck.isInteger("123")).toBe(false); 89 | }); 90 | }); 91 | 92 | describe("isNaN", () => { 93 | it("should return true if value is NaN", () => { 94 | expect(typeCheck.isNaN(NaN)).toBe(true); 95 | expect(typeCheck.isNaN(123)).toBe(false); 96 | }); 97 | }); 98 | 99 | describe("isBoolean", () => { 100 | it("should return true if value is a boolean", () => { 101 | expect(typeCheck.isBoolean(true)).toBe(true); 102 | expect(typeCheck.isBoolean(false)).toBe(true); 103 | expect(typeCheck.isBoolean(0)).toBe(false); 104 | }); 105 | }); 106 | 107 | describe("isObject", () => { 108 | it("should return true if value is an object", () => { 109 | expect(typeCheck.isObject({})).toBe(true); 110 | expect(typeCheck.isObject([])).toBe(true); 111 | expect(typeCheck.isObject(() => {})).toBe(true); 112 | expect(typeCheck.isObject(null)).toBe(true); 113 | expect(typeCheck.isObject(undefined)).toBe(false); 114 | expect(typeCheck.isObject(123)).toBe(false); 115 | }); 116 | }); 117 | 118 | describe("isNull", () => { 119 | it("should return true if value is null", () => { 120 | expect(typeCheck.isNull(null)).toBe(true); 121 | expect(typeCheck.isNull(undefined)).toBe(false); 122 | expect(typeCheck.isNull(0)).toBe(false); 123 | }); 124 | }); 125 | 126 | describe("isFunction", () => { 127 | it("should return true if value is a function", () => { 128 | expect(typeCheck.isFunction(() => {})).toBe(true); 129 | expect(typeCheck.isFunction(function () {})).toBe(true); 130 | expect(typeCheck.isFunction(123)).toBe(false); 131 | }); 132 | }); 133 | 134 | describe("isArray", () => { 135 | it("should return true if value is an array", () => { 136 | expect(typeCheck.isArray([])).toBe(true); 137 | expect(typeCheck.isArray([1, 2, 3])).toBe(true); 138 | expect(typeCheck.isArray({})).toBe(false); 139 | expect(typeCheck.isArray("string")).toBe(false); 140 | }); 141 | }); 142 | 143 | describe("isObjectLiteral", () => { 144 | it("should return true if value is an object literal", () => { 145 | expect(typeCheck.isObjectLiteral({})).toBe(true); 146 | expect(typeCheck.isObjectLiteral({ key: "value" })).toBe(true); 147 | expect(typeCheck.isObjectLiteral([])).toBe(false); 148 | expect(typeCheck.isObjectLiteral(() => {})).toBe(false); 149 | expect(typeCheck.isObjectLiteral(null)).toBe(false); 150 | }); 151 | }); 152 | 153 | describe("isUndefined", () => { 154 | it("should return true if value is undefined", () => { 155 | expect(typeCheck.isUndefined(undefined)).toBe(true); 156 | expect(typeCheck.isUndefined(null)).toBe(false); 157 | expect(typeCheck.isUndefined(0)).toBe(false); 158 | }); 159 | }); 160 | 161 | describe("isUndefinedOrNull", () => { 162 | it("should return true if value is undefined or null", () => { 163 | expect(typeCheck.isUndefinedOrNull(undefined)).toBe(true); 164 | expect(typeCheck.isUndefinedOrNull(null)).toBe(true); 165 | expect(typeCheck.isUndefinedOrNull(0)).toBe(false); 166 | expect(typeCheck.isUndefinedOrNull("")).toBe(false); 167 | }); 168 | }); 169 | 170 | describe("isBigInt", () => { 171 | it("should return true if value is a BigInt", () => { 172 | expect(typeCheck.isBigInt(BigInt(10))).toBe(true); 173 | expect(typeCheck.isBigInt(10)).toBe(false); 174 | }); 175 | }); 176 | 177 | describe("isSymbol", () => { 178 | it("should return true if value is a symbol", () => { 179 | expect(typeCheck.isSymbol(Symbol())).toBe(true); 180 | expect(typeCheck.isSymbol("symbol")).toBe(false); 181 | }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /lib/core/validation-engine/validation-engine.injectable.ts: -------------------------------------------------------------------------------- 1 | import Injectable from "@/decorator/Injectable.decorator"; 2 | import FunctionalValidator from "./validators/functional-validator.injectable"; 3 | import ParameterDescriptorValidator from "./validators/parameter-descriptor-validator.injectable"; 4 | import RegExpValidator from "./validators/regexp-validator.provider"; 5 | import TypeValidator from "./validators/type-validator.injectable"; 6 | import type { ValidateOption } from "@/abstract/parameter-validator.abstract"; 7 | import { ParamDef, PayloadDef, Schema } from "@/types/payload-def.type"; 8 | import { CustomValidator, ParamRules } from "@/types/rules.type"; 9 | import RuleSet from "./rule-set/rule-set"; 10 | import TypeCheck from "@/utils/type-check.provider"; 11 | import UnionRules from "./rule-set/union-rules"; 12 | import IntersectionRules from "./rule-set/intersection-rules"; 13 | import Template from "@/utils/template.provider"; 14 | import ValidationError from "./validation-error/validation-error"; 15 | import ArrayValidator from "./validators/array-validator.injectable"; 16 | import SchemaType from "./schema-type/schema-type"; 17 | import Karman from "../karman/karman"; 18 | 19 | type ValidateOptionInterface = Partial> & 20 | Omit & { karman: Karman | undefined }; 21 | 22 | @Injectable() 23 | export default class ValidationEngine { 24 | constructor( 25 | private readonly functionalValidator: FunctionalValidator, 26 | private readonly parameterDescriptorValidator: ParameterDescriptorValidator, 27 | private readonly regexpValidator: RegExpValidator, 28 | private readonly typeValidator: TypeValidator, 29 | private readonly arrayValidator: ArrayValidator, 30 | private readonly typeCheck: TypeCheck, 31 | private readonly template: Template, 32 | ) {} 33 | 34 | public isValidationError(error: unknown): error is ValidationError { 35 | return error instanceof ValidationError; 36 | } 37 | 38 | public defineCustomValidator(validateFn: (param: string, value: any) => void): CustomValidator { 39 | if (!this.typeCheck.isFunction(validateFn)) { 40 | throw new TypeError("Invalid validator type."); 41 | } 42 | 43 | Object.defineProperty(validateFn, "_karman", { value: true }); 44 | 45 | return validateFn as CustomValidator; 46 | } 47 | 48 | public defineUnionRules(...rules: ParamRules[]) { 49 | return new UnionRules(...rules); 50 | } 51 | 52 | public defineIntersectionRules(...rules: ParamRules[]) { 53 | return new IntersectionRules(...rules); 54 | } 55 | 56 | public defineSchemaType(name: string, def: Schema): SchemaType { 57 | const existedType = this.typeCheck.TypeSet.includes(name); 58 | if (!this.typeCheck.isValidName(name) || existedType) this.template.throw(`invalid name '${name}' for schema type`); 59 | 60 | const schema = new SchemaType(name, def); 61 | schema.setValidFn(this.getSchemaValidator(schema)); 62 | 63 | return schema; 64 | } 65 | 66 | private getSchemaValidator(schema: SchemaType) { 67 | return (value: any) => this.getMainValidator(schema.scope, value, schema.def)(); 68 | } 69 | 70 | public getMainValidator(karman: Karman | undefined, payload: Record, payloadDef: PayloadDef) { 71 | if (this.typeCheck.isArray(payloadDef)) return () => {}; 72 | 73 | // ensure karman to be a root node 74 | if (karman instanceof Karman) karman = karman.$getRoot(); 75 | 76 | const validatorQueue: (() => void)[] = []; 77 | Object.entries(payloadDef).forEach(([param, paramDef]) => { 78 | if (!paramDef) return; 79 | 80 | // Don't assign the default value to the payload here, 81 | // but use the default value to run the validation. 82 | const value = payload[param] ?? paramDef.defaultValue?.(); 83 | const { rules, required } = this.getRules(param, paramDef); 84 | const validator = this.getValidatorByRules(karman, rules, required); 85 | validatorQueue.push(() => validator(param, value)); 86 | }); 87 | const mainValidator = () => validatorQueue.forEach((validator) => validator()); 88 | 89 | return mainValidator; 90 | } 91 | 92 | private getRules(param: string, paramDef: ParamDef) { 93 | const { rules, required } = paramDef; 94 | 95 | if (!rules && this.typeCheck.isUndefined(required)) { 96 | this.template.warn(`Cannot find certain rules for parameter "${param}".`); 97 | 98 | return {}; 99 | } 100 | 101 | return { rules, required }; 102 | } 103 | 104 | private ruleSetAdapter(karman: Karman | undefined, rules: RuleSet, required: boolean) { 105 | const validator = (param: string, value: any) => { 106 | rules.execute((rule) => { 107 | this.validateInterface({ rule, param, value, required, karman }); 108 | }); 109 | }; 110 | 111 | return validator; 112 | } 113 | 114 | private getValidatorByRules( 115 | karman: Karman | undefined, 116 | rules?: ParamRules | ParamRules[] | RuleSet, 117 | required: boolean = false, 118 | ) { 119 | if (rules instanceof RuleSet) { 120 | return this.ruleSetAdapter(karman, rules, required); 121 | } else if (this.typeCheck.isArray(rules)) { 122 | const ruleSet = new IntersectionRules(...rules); 123 | 124 | return this.ruleSetAdapter(karman, ruleSet as RuleSet, required); 125 | } else { 126 | const validator = (param: string, value: any) => { 127 | this.validateInterface({ rule: rules, param, value, required, karman }); 128 | }; 129 | 130 | return validator; 131 | } 132 | } 133 | 134 | private requiredValidator(param: string, value: any, required: boolean): boolean { 135 | const empty = this.typeCheck.isUndefinedOrNull(value); 136 | 137 | if (!empty) return true; 138 | 139 | if (required && empty) throw new ValidationError({ prop: param, value, required }); 140 | 141 | return false; 142 | } 143 | 144 | private validateInterface(option: ValidateOptionInterface) { 145 | const { karman, param, value, required, rule } = option; 146 | 147 | const requiredValidation = this.requiredValidator(param, value, required); 148 | 149 | if (!requiredValidation || !rule) return; 150 | 151 | if (this.arrayValidator.maybeArraySyntax(rule)) this.arrayValidator.validate(option as ValidateOption, karman); 152 | else this.typeValidator.validate(option as ValidateOption, karman); 153 | this.regexpValidator.validate(option as ValidateOption); 154 | this.functionalValidator.validate(option as ValidateOption); 155 | this.parameterDescriptorValidator.validate(option as ValidateOption); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /lib/core/validation-engine/schema-type/schema-type.ts: -------------------------------------------------------------------------------- 1 | import { ValidateOption } from "@/abstract/parameter-validator.abstract"; 2 | import Karman from "@/core/karman/karman"; 3 | import { ParamDef, ParamPosition, Schema } from "@/types/payload-def.type"; 4 | import ValidationError from "../validation-error/validation-error"; 5 | import RuleSet from "../rule-set/rule-set"; 6 | import { cloneDeep } from "lodash-es"; 7 | 8 | type ValidateFn = (value: any) => void; 9 | 10 | export default class SchemaType { 11 | readonly #name: string; 12 | readonly #def: Schema; 13 | readonly #attachDef: Schema = {}; 14 | #pick?: (keyof Schema)[]; 15 | #omit?: (keyof Schema)[]; 16 | #scope?: Karman; 17 | #validFn?: ValidateFn; 18 | #onMutate = false; 19 | 20 | get name() { 21 | return this.#name; 22 | } 23 | 24 | get scope() { 25 | return this.#scope; 26 | } 27 | 28 | get def() { 29 | return this.#def; 30 | } 31 | 32 | get keys() { 33 | return Object.keys(this.def); 34 | } 35 | 36 | get values() { 37 | return Object.values(this.def); 38 | } 39 | 40 | constructor(name: string, def: Schema) { 41 | this.#name = name; 42 | this.#def = def; 43 | } 44 | 45 | private computeDef() { 46 | const copyDef = cloneDeep(this.#def); 47 | 48 | for (const key in copyDef) { 49 | if (!copyDef[key]) copyDef[key] = {}; 50 | 51 | const isPicked = this.#pick?.length ? this.#pick.includes(key) : true; 52 | const isOmitted = this.#omit?.length ? this.#omit.includes(key) : false; 53 | 54 | if (isPicked && !isOmitted) Object.assign(copyDef[key] as ParamDef, this.#attachDef[key]); 55 | else delete copyDef[key]; 56 | } 57 | 58 | this.#pick = undefined; 59 | this.#omit = undefined; 60 | 61 | return copyDef; 62 | } 63 | 64 | private chainScope() { 65 | if (!this.#onMutate) return; 66 | 67 | // eslint-disable-next-line @typescript-eslint/no-this-alias 68 | const self = this; 69 | const scope: this = new Proxy( 70 | { 71 | setRequired: self.setRequired.bind(self), 72 | setOptional: self.setOptional.bind(self), 73 | setPosition: self.setPosition.bind(self), 74 | setDefault: self.setDefault.bind(self), 75 | } as unknown as this, 76 | { 77 | get(target, p) { 78 | if (p === "def") return self.computeDef(); 79 | else if (p === "pick" && self.#pick?.length === 0) return self.pick.bind(self); 80 | else if (p === "omit" && self.#omit?.length === 0) return self.omit.bind(self); 81 | else return target[p as keyof typeof target]; 82 | }, 83 | set() { 84 | return false; 85 | }, 86 | }, 87 | ); 88 | 89 | return scope; 90 | } 91 | 92 | public mutate() { 93 | this.#onMutate = true; 94 | this.#pick = []; 95 | this.#omit = []; 96 | 97 | for (const prop in this.#def) this.#attachDef[prop] = {}; 98 | 99 | return this.chainScope(); 100 | } 101 | 102 | public setRequired(...names: (keyof Schema)[]) { 103 | return this.traverseDef(names, (_, prop) => (prop.required = true)); 104 | } 105 | 106 | public setOptional(...names: (keyof Schema)[]) { 107 | return this.traverseDef(names, (_, prop) => (prop.required = false)); 108 | } 109 | 110 | public setPosition(position: ParamPosition, ...names: (keyof Schema)[]) { 111 | if (!names.length) names = this.keys; 112 | 113 | return this.traverseDef(names, (_, prop) => { 114 | prop.position ??= []; 115 | if (Array.isArray(prop.position)) prop.position.push(position); 116 | }); 117 | } 118 | 119 | public setDefault(name: string, defaultValue: () => any) { 120 | (this.#attachDef[name] as ParamDef).defaultValue = defaultValue; 121 | 122 | return this.chainScope(); 123 | } 124 | 125 | public pick(...names: (keyof Schema)[]) { 126 | if (!this.#pick) return; 127 | 128 | this.#pick.push(...names); 129 | this.#omit = undefined; 130 | 131 | return this.chainScope(); 132 | } 133 | 134 | public omit(...names: (keyof Schema)[]) { 135 | if (!this.#omit) return; 136 | 137 | this.#omit.push(...names); 138 | this.#pick = undefined; 139 | 140 | return this.chainScope(); 141 | } 142 | 143 | private traverseDef(names: (keyof Schema)[], cb: (name: keyof Schema, prop: ParamDef) => void) { 144 | if (!names.length) names = this.keys; 145 | 146 | names.forEach((name) => { 147 | if (!(name in this.#def)) return; 148 | 149 | cb(name, this.#attachDef[name] as ParamDef); 150 | }); 151 | 152 | return this.chainScope(); 153 | } 154 | 155 | public $setScope(karman: Karman) { 156 | karman = karman.$getRoot(); 157 | this.#scope = karman; 158 | } 159 | 160 | public circularRefCheck() { 161 | this.traverseStringRules((rule: string) => this.checkRefByString(rule)); 162 | } 163 | 164 | private checkRefByString(rules: string) { 165 | if (rules.includes("[")) rules = rules.split("[")[0]; 166 | 167 | const circular = rules === this.name; 168 | 169 | // break recursion 170 | if (circular) throw new ReferenceError(`Circular reference in SchemaType '${rules}'`); 171 | 172 | const schema = this.scope?.$schema.get(rules); 173 | 174 | if (!schema) return; 175 | 176 | schema.traverseStringRules((rule: string) => this.checkRefByString(rule)); 177 | } 178 | 179 | public traverseStringRules(cb: (rule: string) => void) { 180 | this.values.forEach((def) => { 181 | const { rules } = def ?? {}; 182 | 183 | if (!rules) return; 184 | 185 | if (typeof rules === "string") cb(rules); 186 | 187 | const stringRules: string[] = []; 188 | if (Array.isArray(rules)) stringRules.push(...(rules.filter((value) => typeof value === "string") as string[])); 189 | if (rules instanceof RuleSet) stringRules.push(...rules.getStringRules()); 190 | stringRules.forEach((str) => cb(str)); 191 | }); 192 | } 193 | 194 | public setValidFn(validFn: ValidateFn) { 195 | if (typeof validFn !== "function") return; 196 | 197 | this.#validFn = validFn; 198 | } 199 | 200 | public validate(option: ValidateOption): void { 201 | const { param, value } = option; 202 | 203 | try { 204 | if (typeof value !== "object" || value === null || Array.isArray(value)) 205 | throw new ValidationError({ 206 | prop: param, 207 | value, 208 | type: this.name, 209 | }); 210 | 211 | this.#validFn?.(value); 212 | } catch (error) { 213 | if (!(error instanceof ValidationError)) throw error; 214 | 215 | const errorMsg = `'${param}' does not match to the SchemaType '${this.name}'.\nReason: ${error.message}`; 216 | 217 | throw new ValidationError(errorMsg); 218 | } 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /lib/core/karman/karman.ts: -------------------------------------------------------------------------------- 1 | import { KarmanInterceptors } from "@/types/hooks.type"; 2 | import { ReqStrategyTypes, RequestConfig } from "@/types/http.type"; 3 | import { CacheConfig, KarmanInstanceConfig } from "@/types/karman.type"; 4 | import { configInherit } from "@/core/out-of-paradigm/config-inherit"; 5 | import PathResolver from "@/utils/path-resolver.provider"; 6 | import TypeCheck from "@/utils/type-check.provider"; 7 | import { isString, isBoolean, isNumber, isFunction } from "lodash-es"; 8 | import SchemaType from "../validation-engine/schema-type/schema-type"; 9 | 10 | const HOUR = 60 * 60 * 60 * 1000; 11 | 12 | export default class Karman { 13 | // #region utils 14 | protected _typeCheck!: TypeCheck; 15 | protected _pathResolver!: PathResolver; 16 | // #endregion 17 | 18 | // #region fields 19 | #root: boolean = false; 20 | get $root(): boolean { 21 | return this.#root; 22 | } 23 | set $root(value: boolean | undefined) { 24 | if (isBoolean(value)) this.#root = value; 25 | } 26 | #parent: null | Karman = null; 27 | get $parent() { 28 | return this.#parent; 29 | } 30 | set $parent(value) { 31 | if (value instanceof Karman) this.#parent = value; 32 | } 33 | #baseURL: string = ""; 34 | get $baseURL() { 35 | return this.#baseURL; 36 | } 37 | set $baseURL(value) { 38 | if (!isString(value)) return; 39 | this.#baseURL = value; 40 | } 41 | $cacheConfig: CacheConfig = { 42 | cache: false, 43 | cacheExpireTime: HOUR, 44 | cacheStrategy: "memory", 45 | }; 46 | $requestConfig: RequestConfig = {}; 47 | $interceptors: KarmanInterceptors = {}; 48 | #validation?: boolean; 49 | get $validation() { 50 | return this.#validation; 51 | } 52 | set $validation(value) { 53 | if (isBoolean(value)) this.#validation = value; 54 | } 55 | #scheduleInterval?: number; 56 | get $scheduleInterval() { 57 | return this.#scheduleInterval; 58 | } 59 | set $scheduleInterval(value) { 60 | if (isNumber(value)) this.#scheduleInterval = value; 61 | } 62 | #inherited = false; 63 | readonly $schema: Map = new Map(); 64 | // #endregion 65 | 66 | constructor(config: KarmanInstanceConfig) { 67 | const { 68 | root, 69 | url, 70 | validation, 71 | scheduleInterval, 72 | cache, 73 | cacheExpireTime, 74 | cacheStrategy, 75 | headers, 76 | auth, 77 | timeout, 78 | timeoutErrorMessage, 79 | responseType, 80 | headerMap, 81 | withCredentials, 82 | credentials, 83 | integrity, 84 | keepalive, 85 | mode, 86 | redirect, 87 | referrer, 88 | referrerPolicy, 89 | requestCache, 90 | window, 91 | onRequest, 92 | onResponse, 93 | } = config ?? {}; 94 | this.$baseURL = url ?? ""; 95 | this.$root = root; 96 | this.$validation = validation; 97 | this.$scheduleInterval = scheduleInterval; 98 | this.$cacheConfig = { cache, cacheExpireTime, cacheStrategy }; 99 | this.$requestConfig = { 100 | headers, 101 | auth, 102 | timeout, 103 | timeoutErrorMessage, 104 | responseType, 105 | headerMap, 106 | withCredentials, 107 | credentials, 108 | integrity, 109 | keepalive, 110 | mode, 111 | redirect, 112 | referrer, 113 | referrerPolicy, 114 | requestCache, 115 | window, 116 | }; 117 | this.$interceptors = { onRequest, onResponse }; 118 | } 119 | 120 | public $mount(o: O, name: string = "$karman") { 121 | Object.defineProperty(o, name, { value: this }); 122 | } 123 | 124 | public $use(plugin: T) { 125 | if (!isFunction(plugin?.install)) throw new TypeError("[karman error] plugin must has an install function!"); 126 | 127 | plugin.install(Karman); 128 | } 129 | 130 | /** 131 | * Inheriting all configurations down to the whole Karman tree from root node. 132 | * Only allows to be invoked once on root layer. 133 | */ 134 | public $inherit(): void { 135 | if (this.#inherited) return; 136 | 137 | if (this.$parent) { 138 | const { $baseURL, $requestConfig, $cacheConfig, $interceptors, $validation, $scheduleInterval } = this.$parent; 139 | this.$baseURL = this._pathResolver.resolve($baseURL, this.$baseURL); 140 | this.$requestConfig = configInherit($requestConfig, this.$requestConfig); 141 | this.$cacheConfig = configInherit($cacheConfig, this.$cacheConfig); 142 | this.$interceptors = configInherit($interceptors, this.$interceptors); 143 | if (this._typeCheck.isUndefined(this.$validation)) this.$validation = $validation; 144 | if (this._typeCheck.isUndefined(this.$scheduleInterval)) this.$scheduleInterval = $scheduleInterval; 145 | } 146 | 147 | this.$invokeChildrenInherit(); 148 | } 149 | 150 | public $setDependencies(...deps: (TypeCheck | PathResolver)[]) { 151 | deps.forEach((dep) => { 152 | if (dep instanceof TypeCheck) this._typeCheck = dep; 153 | else if (dep instanceof PathResolver) this._pathResolver = dep; 154 | }); 155 | } 156 | 157 | public $requestGuard(request: Function) { 158 | return (...args: any[]) => { 159 | if (!this.#inherited) 160 | console.warn( 161 | // eslint-disable-next-line @stylistic/max-len 162 | "[karman warn] Inherit event on Karman tree hasn't been triggered, please make sure you have specified the root Karman layer.", 163 | ); 164 | 165 | return request(...args); 166 | }; 167 | } 168 | 169 | public $getRoot() { 170 | // eslint-disable-next-line @typescript-eslint/no-this-alias 171 | let node: Karman = this; 172 | 173 | while (!node.$root && node.$parent) node = node.$parent; 174 | 175 | return node; 176 | } 177 | 178 | public $setSchema(name: string, schema: SchemaType) { 179 | if (this.$schema.has(name)) return console.warn(`[karman warn] duplicate SchemaType '${name}'`); 180 | 181 | this.$schema.set(name, schema); 182 | schema.$setScope(this); 183 | schema.circularRefCheck(); 184 | } 185 | 186 | private $invokeChildrenInherit(): void { 187 | this.$traverseInstanceTree({ 188 | onTraverse: (prop) => { 189 | if (prop instanceof Karman) prop.$inherit(); 190 | }, 191 | onTraverseEnd: () => { 192 | this.#inherited = true; 193 | }, 194 | }); 195 | } 196 | 197 | private $traverseInstanceTree( 198 | { 199 | onTraverse, 200 | onTraverseEnd, 201 | }: { 202 | onTraverse: (value: any, index: number, array: any[]) => void; 203 | onTraverseEnd?: () => void; 204 | }, 205 | instance = this, 206 | ) { 207 | Object.values(instance).forEach(onTraverse); 208 | onTraverseEnd?.(); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /assets/doc/en/dynamic-type-annotation.md: -------------------------------------------------------------------------------- 1 | # Dynamic Type Annotation 2 | 3 | > [!TIP] 4 | > It is recommended to have prior knowledge of [JSDoc](https://jsdoc.app/) and [TypeScript](https://www.typescriptlang.org/) before reading this chapter. 5 | 6 | Another powerful feature provided by Karman is the ability to dynamically map configurations of `defineKarman` and `defineAPI` to Karman nodes in real-time through TypeScript generic parameters and IDE's [LSP](https://microsoft.github.io/language-server-protocol/) integration. This includes finalAPIs, subpaths, as well as inputs and outputs of finalAPIs. 7 | 8 | - [Dynamic Type Annotation](#dynamic-type-annotation) 9 | - [JSDoc](#jsdoc) 10 | - [DTO of Input/Payload](#dto-of-inputpayload) 11 | - [DTO of Output/Response](#dto-of-outputresponse) 12 | 13 | ## JSDoc 14 | 15 | JSDoc is a standardized specification for annotating code. When used in IDEs that support automatic parsing of JSDoc (such as Visual Studio Code), it provides corresponding annotation messages for annotated variables, properties, or methods. 16 | 17 | ```js 18 | import { defineKarman, defineAPI } from "@vic0627/karman"; 19 | 20 | /** 21 | * # API Management Center 22 | */ 23 | const rootKarman = defineKarman({ 24 | // ... 25 | api: { 26 | /** 27 | * ## Connection Test 28 | */ 29 | connect: defineAPI(), 30 | }, 31 | route: { 32 | /** 33 | * ## User Management 34 | */ 35 | user: defineKarman({ 36 | // ... 37 | api: { 38 | /** 39 | * ### Get All Users 40 | */ 41 | getAll: defineAPI({ 42 | // ... 43 | }), 44 | /** 45 | * ### Create New User 46 | */ 47 | create: defineAPI({ 48 | // ... 49 | }), 50 | }, 51 | }), 52 | }, 53 | }); 54 | 55 | // Hovering over the following variables, properties, or methods in JavaScript will display the annotation content on the right in the tooltip 56 | rootKarman; // API Management Center 57 | rootKarman.connect(); // Connection Test 58 | rootKarman.user; // User Management 59 | rootKarman.user.getAll(); // Get All Users 60 | rootKarman.user.create(); // Create New User 61 | ``` 62 | 63 | ## DTO of Input/Payload 64 | 65 | As mentioned in the [Parameter Definition](./final-api.md) section, the `payload` of the FinalAPI is mainly defined through the `payloadDef` property of `defineAPI` and mapped to the `payload` of the FinalAPI. However, the properties of `payloadDef` are objects, and in most cases, the mapped payload may not comply with the defined rules. 66 | 67 | Therefore, you can change the type of attributes displayed in the `payload` by setting the `type` property. The `type` itself is an optional parameter. If you need to call the FinalAPI and want the `payload` object to show the correct type hints for each attribute, you must set this parameter. Additionally, if the attribute is a composite type, you can use the `getType` API. `getType` will convert all passed parameters into a Union Type. `getType` also supports converting `SchemaType.def`, so you can use `getType` to obtain the correct type of a Schema. 68 | 69 | > [!WARNING] 70 | > Type annotation on the keys of `PayloadDef` with JSDoc tag `@type` has been deprecated. Considering annotate types via `ParamDef.type` and `getType` which were introduced in Karman v1.3.0. 71 | 72 | ```js 73 | import { defineKarman, defineAPI } from "@vic0627/karman"; 74 | 75 | const rootKarman = defineKarman({ 76 | // ... 77 | api: { 78 | /** 79 | * ### Get All Results 80 | */ 81 | getAll: defineAPI({ 82 | // ... 83 | payloadDef: { 84 | /** 85 | * Limit of returned records 86 | */ 87 | limit: { 88 | position: "query", 89 | rules: "int", 90 | type: getType(1, undefined), // output => number | undefined 91 | }, 92 | /** 93 | * Sorting strategy 94 | */ 95 | sort: { 96 | position: "query", 97 | rules: "string", 98 | type: getType("asc", "desc", undefined), // output => "asc" | "desc" | undefined 99 | }, 100 | }, 101 | }), 102 | }, 103 | }); 104 | 105 | // Hovering over limit and sort will display the corresponding types and annotations 106 | rootKarman.getAll({ 107 | limit: 10, 108 | sort: "asc", 109 | }); 110 | ``` 111 | 112 | ## DTO of Output/Response 113 | 114 | Output needs to be configured through the `dto` property in `defineAPI()`. `dto` does not affect program execution; it only affects the type of the FinalAPI's return result. Therefore, it can be assigned any value. There are many ways to configure `dto`, but to save memory space, it is recommended to use type files and JSDoc. 115 | 116 | > [!WARNING] 117 | > There are many factors that can affect the return type, including `dto`, `onSuccess`, `onError`, etc. Therefore, the compiler may encounter type discrepancies due to environmental or contextual factors during parsing. 118 | 119 | - Schema + getType 120 | 121 | ```js 122 | import { defineKarman, defineAPi, getType } from "@vic0627/karman"; 123 | import productSchema from "./schema/product-schema.js"; 124 | 125 | export default defineKarman({ 126 | // ... 127 | api: { 128 | getProducts: defineAPI({ 129 | dto: getType([productSchema.def]), 130 | }), 131 | }, 132 | }); 133 | ``` 134 | 135 | - Direct Assignment 136 | 137 | ```js 138 | // ... 139 | export default defineKarman({ 140 | // ... 141 | api: { 142 | getProducts: defineAPI({ 143 | dto: [ 144 | { 145 | /** ID */ 146 | id: 0, 147 | /** Title */ 148 | title: "", 149 | /** Price */ 150 | price: 0, 151 | /** Description */ 152 | description: "", 153 | }, 154 | ], 155 | }), 156 | }, 157 | }); 158 | ``` 159 | 160 | - JSDoc 161 | 162 | ```js 163 | /** 164 | * @typedef {object} Product 165 | * @prop {number} Product.id - ID 166 | * @prop {string} Product.title - Title 167 | * @prop {number} Product.price - Price 168 | * @prop {string} Product.description - Description 169 | */ 170 | // ... 171 | export default defineKarman({ 172 | // ... 173 | api: { 174 | getProducts: defineAPI({ 175 | /** 176 | * @type {Product[]} 177 | */ 178 | dto: null, 179 | }), 180 | }, 181 | }); 182 | ``` 183 | 184 | - TypeScript + JSDoc 185 | 186 | ```ts 187 | // /product.type.ts 188 | export interface Product { 189 | /** ID */ 190 | id: number; 191 | /** Title */ 192 | title: string; 193 | /** Price */ 194 | price: number; 195 | /** Description */ 196 | description: string; 197 | } 198 | ``` 199 | 200 | ```js 201 | // ... 202 | export default defineKarman({ 203 | // ... 204 | api: { 205 | getProducts: defineAPI({ 206 | /** 207 | * @type {import("product.type").Product[]} 208 | */ 209 | dto: null, 210 | }), 211 | }, 212 | }); 213 | ``` 214 | -------------------------------------------------------------------------------- /lib/core/request-strategy/fetch.injectable.ts: -------------------------------------------------------------------------------- 1 | import RequestStrategy, { SelectRequestStrategy } from "@/abstract/request-strategy.abstract"; 2 | import Injectable from "@/decorator/Injectable.decorator"; 3 | import RequestDetail, { 4 | ReqStrategyTypes, 5 | HttpBody, 6 | HttpConfig, 7 | HttpAuthentication, 8 | PromiseExecutor, 9 | RequestConfig, 10 | RequestExecutor, 11 | FetchResponse, 12 | } from "@/types/http.type"; 13 | import Template from "@/utils/template.provider"; 14 | import TypeCheck from "@/utils/type-check.provider"; 15 | import { merge } from "lodash-es"; 16 | 17 | @Injectable() 18 | export default class Fetch implements RequestStrategy { 19 | constructor( 20 | private readonly typeCheck: TypeCheck, 21 | private readonly template: Template, 22 | ) {} 23 | 24 | public request( 25 | payload: HttpBody, 26 | config: HttpConfig, 27 | ): RequestDetail, T> { 28 | const { 29 | url, 30 | method = "GET", 31 | // headerMap, 32 | auth, 33 | timeout, 34 | timeoutErrorMessage, 35 | responseType, 36 | headers, 37 | // withCredentials, 38 | credentials, 39 | integrity, 40 | keepalive, 41 | mode, 42 | redirect, 43 | referrer, 44 | referrerPolicy, 45 | requestCache, 46 | window, 47 | } = config; 48 | const _method = method.toUpperCase(); 49 | const _headers = this.getHeaders(headers, auth); 50 | const fetchConfig = { 51 | method: _method, 52 | headers: _headers, 53 | body: payload as BodyInit, 54 | credentials, 55 | integrity, 56 | keepalive, 57 | mode, 58 | redirect, 59 | referrer, 60 | referrerPolicy, 61 | cache: requestCache, 62 | window, 63 | }; 64 | const initObject = this.initFetch(url, fetchConfig, { responseType, timeout, timeoutErrorMessage }); 65 | 66 | return { ...initObject, config }; 67 | } 68 | 69 | private buildTimeout( 70 | timeoutOptions: Pick, "timeout" | "timeoutErrorMessage"> & { 71 | abortObject: { abort: () => void }; 72 | }, 73 | ) { 74 | const { timeout, timeoutErrorMessage, abortObject } = timeoutOptions; 75 | 76 | if (!this.typeCheck.isNumber(timeout) || timeout < 1) return; 77 | 78 | const t = setTimeout(() => { 79 | abortObject.abort(); 80 | clearTimeout(t); 81 | }, timeout); 82 | 83 | return { 84 | clearTimer: () => clearTimeout(t), 85 | TOMessage: timeoutErrorMessage || `time of ${timeout}ms exceeded`, 86 | }; 87 | } 88 | 89 | private initFetch( 90 | url: string, 91 | config: RequestInit, 92 | addition: Pick, "responseType" | "timeout" | "timeoutErrorMessage">, 93 | ): Omit, T>, "config"> { 94 | const promiseUninitWarn = () => this.template.warn("promise resolver hasn't been initialized"); 95 | const { responseType, timeout, timeoutErrorMessage } = addition; 96 | const { method } = config; 97 | const requestKey = `fetch:${method}:${url}`; 98 | 99 | if (method === "GET" || method === "HEAD") config.body = null; 100 | 101 | // abort 102 | const abortObject = { 103 | abort: () => this.template.warn("Failed to abort request."), 104 | }; 105 | 106 | // timout 107 | const { clearTimer, TOMessage } = this.buildTimeout({ timeout, timeoutErrorMessage, abortObject }) ?? {}; 108 | 109 | // request fn 110 | const request = () => { 111 | const abortController = new AbortController(); 112 | const signal = abortController.signal; 113 | abortObject.abort = abortController.abort.bind(abortController); 114 | 115 | return fetch(url, { ...config, signal }) as unknown as Promise>; 116 | }; 117 | 118 | // promise initialized 119 | const promiseExecutor: PromiseExecutor> = { 120 | resolve: promiseUninitWarn, 121 | reject: promiseUninitWarn, 122 | }; 123 | let response: any = null; 124 | const requestPromise = new Promise>((_resolve, _reject) => { 125 | promiseExecutor.resolve = (value: SelectRequestStrategy) => { 126 | _resolve(value as SelectRequestStrategy); 127 | clearTimer?.(); 128 | }; 129 | 130 | promiseExecutor.reject = (reason?: unknown) => { 131 | _reject(reason); 132 | clearTimer?.(); 133 | }; 134 | 135 | abortObject.abort = (reason?: unknown) => { 136 | if (this.typeCheck.isString(reason)) reason = new Error(reason); 137 | _reject(reason); 138 | }; 139 | }) 140 | .then((res) => { 141 | if (!(res instanceof Response)) return res; 142 | 143 | const type = res.headers.get("Content-Type"); 144 | 145 | if (!response) response = {}; 146 | response.url = res.url; 147 | response.bodyUsed = res.bodyUsed; 148 | response.headers = res.headers; 149 | response.ok = res.ok; 150 | response.redirected = res.redirected; 151 | response.status = res.status; 152 | response.statusText = res.statusText; 153 | response.type = res.type; 154 | 155 | if (type?.includes("json") || responseType === "json") return res.json(); 156 | if (responseType === "blob") return res.blob(); 157 | if (responseType === "arraybuffer") return res.arrayBuffer(); 158 | return res; 159 | }) 160 | .then((body) => { 161 | if (!(body instanceof Response) && response) return { ...response, body }; 162 | 163 | return body; 164 | }) as Promise>; 165 | 166 | const requestWrapper = async () => { 167 | try { 168 | promiseExecutor.resolve(await request()); 169 | } catch (error) { 170 | let _error: Error | null = null; 171 | 172 | if (error instanceof DOMException && error.message.includes("abort") && TOMessage) 173 | _error = new Error(TOMessage); 174 | 175 | promiseExecutor.reject(_error ?? error); 176 | } 177 | }; 178 | 179 | const requestExecutor: RequestExecutor> = (send?: boolean) => { 180 | if (send) requestWrapper(); 181 | 182 | return [requestPromise, abortObject.abort]; 183 | }; 184 | 185 | return { requestKey, promiseExecutor, requestExecutor }; 186 | } 187 | 188 | private getHeaders(headers?: Record, auth?: Partial) { 189 | return merge({}, headers, this.getAuthHeaders(auth)); 190 | } 191 | 192 | private getAuthHeaders(auth?: Partial) { 193 | let { password } = auth ?? {}; 194 | const { username } = auth ?? {}; 195 | 196 | if (this.typeCheck.isUndefinedOrNull(username) || this.typeCheck.isUndefinedOrNull(password)) return; 197 | 198 | password = decodeURIComponent(encodeURIComponent(password)); 199 | const Authorization = "Basic " + btoa(username + ":" + password); 200 | 201 | return { Authorization }; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | /** @type {import('jest').Config} */ 7 | const config = { 8 | // All imported modules in your tests should be mocked automatically 9 | // automock: false, 10 | 11 | // Stop running tests after `n` failures 12 | // bail: 0, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "C:\\Users\\User\\AppData\\Local\\Temp\\jest", 16 | 17 | // Automatically clear mock calls, instances, contexts and results before every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: true, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | collectCoverageFrom: ["**/*.provider.ts", "**/*.injectable.ts"], 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "./_coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | coveragePathIgnorePatterns: ["\\\\node_modules\\\\"], 31 | 32 | // Indicates which provider should be used to instrument code for coverage 33 | // coverageProvider: "babel", 34 | 35 | // A list of reporter names that Jest uses when writing coverage reports 36 | // coverageReporters: [ 37 | // "json", 38 | // "text", 39 | // "lcov", 40 | // "clover" 41 | // ], 42 | 43 | // An object that configures minimum threshold enforcement for coverage results 44 | // coverageThreshold: undefined, 45 | 46 | // A path to a custom dependency extractor 47 | // dependencyExtractor: undefined, 48 | 49 | // Make calling deprecated APIs throw helpful error messages 50 | // errorOnDeprecated: false, 51 | 52 | // The default configuration for fake timers 53 | // fakeTimers: { 54 | // "enableGlobally": false 55 | // }, 56 | 57 | // Force coverage collection from ignored files using an array of glob patterns 58 | // forceCoverageMatch: [], 59 | 60 | // A path to a module which exports an async function that is triggered once before all test suites 61 | // globalSetup: undefined, 62 | 63 | // A path to a module which exports an async function that is triggered once after all test suites 64 | // globalTeardown: undefined, 65 | 66 | // A set of global variables that need to be available in all test environments 67 | // globals: {}, 68 | 69 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 70 | // maxWorkers: "50%", 71 | 72 | // An array of directory names to be searched recursively up from the requiring module's location 73 | // moduleDirectories: ["node_modules"], 74 | 75 | // An array of file extensions your modules use 76 | moduleFileExtensions: [ 77 | "js", 78 | "mjs", 79 | "cjs", 80 | // "jsx", 81 | "ts", 82 | // "tsx", 83 | // "json", 84 | "node", 85 | ], 86 | 87 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 88 | moduleNameMapper: { 89 | "^@/(.*)$": "/../lib/$1", 90 | "^lodash-es$": "lodash", 91 | }, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | preset: "ts-jest", 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state before every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state and implementation before every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | rootDir: "./test", 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | setupFiles: ["./setup-file.ts"], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | // setupFilesAfterEnv: [], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | testEnvironment: "jsdom", 148 | 149 | // Options that will be passed to the testEnvironment 150 | // testEnvironmentOptions: {}, 151 | 152 | // Adds a location field to test results 153 | // testLocationInResults: false, 154 | 155 | // The glob patterns Jest uses to detect test files 156 | // testMatch: [ 157 | // "**/__tests__/**/*.[jt]s?(x)", 158 | // "**/?(*.)+(spec|test).[tj]s?(x)" 159 | // ], 160 | 161 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 162 | // testPathIgnorePatterns: [ 163 | // "\\\\node_modules\\\\" 164 | // ], 165 | 166 | // The regexp pattern or array of patterns that Jest uses to detect test files 167 | // testRegex: [], 168 | 169 | // This option allows the use of a custom results processor 170 | // testResultsProcessor: undefined, 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jest-circus/runner", 174 | 175 | // A map from regular expressions to paths to transformers 176 | // transform: { 177 | // "^.+\\.ts?$": "ts-jest", 178 | // "^.+\\.(js|jsx)$": require.resolve("babel-jest"), 179 | // }, 180 | 181 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 182 | // transformIgnorePatterns: ["node_modules/(?!lodash-es/)"], 183 | 184 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 185 | // unmockedModulePathPatterns: undefined, 186 | 187 | // Indicates whether each individual test should be reported during the run 188 | // verbose: true, 189 | 190 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 191 | // watchPathIgnorePatterns: [], 192 | 193 | // Whether to use watchman for file crawling 194 | // watchman: true, 195 | }; 196 | 197 | module.exports = config; 198 | -------------------------------------------------------------------------------- /assets/doc/en/schema-api.md: -------------------------------------------------------------------------------- 1 | # Schema API 2 | 3 | The Schema API allows you to use the entire [`payloadDef`](./final-api.md) as a versatile, flexible, and reusable `SchemaType`. Besides serving as a `payloadDef`, a `SchemaType` can also be registered on the Karman Tree. Once registered, all `payloadDef[param].rules` under the Karman Tree can be used as a [String Rule](./validation-engine.md) with the name of the `SchemaType`. Even better, when using a `SchemaType` as a String Rule, it supports [array syntax](./validation-engine.md), allowing you to perform complex validation with minimal code. 4 | 5 | To use it, you need to define a schema using `defineSchemaType`. This method takes two parameters: the name of the schema and an object similar to the object type of `payloadDef`. This object can define parameters as required or optional (`required`), validation rules (`rules`), usage position (`position`), and default values (`defaultValue`). These parameter-related messages serve as the initial state of the schema. Except for `rules`, all attributes can be changed using `SchemaType.mutate()`. 6 | 7 | Below is an example of defining a simple schema for product categories. This schema includes only one parameter, `category`, and its validation rules: 8 | 9 | ```js 10 | // schema/category.js 11 | import { defineSchemaType, defineCustomValidator, ValidationError } from "@vic0627/karman"; 12 | 13 | export default defineSchemaType("Category", { 14 | /** 15 | * category of products 16 | */ 17 | category: { 18 | rules: [ 19 | "string", 20 | defineCustomValidator((_, value) => { 21 | if (!["electronics", "jewelery", "men's clothing", "women's clothing"].includes(value)) 22 | throw new ValidationError("invalid category"); 23 | }), 24 | ], 25 | /** @type {"electronics" | "jewelery" | "men's clothing" | "women's clothing"} */ 26 | type: null, 27 | }, 28 | }); 29 | ``` 30 | 31 | Next, you can use this schema in `payloadDef`, but you need to access the objects that `payloadDef` can use through the `def` property. Additionally, in this context, `category` will be used as a path parameter and must be a required parameter. Therefore, you can initialize additional information of the schema using `.mutate()` method, and then change the detailed definition inside the schema using `.setPosition()` and `.setRequired()` methods. Finally, obtain the edited schema through the `def` property: 32 | 33 | ```js 34 | // ... 35 | import category from "./schema/category.js"; 36 | 37 | const getProductsByCategory = defineAPI({ 38 | url: "https://karman.com/products/:category", 39 | payloadDef: category.mutate().setPosition("path").setRequired().def, 40 | validation: true, 41 | // ... 42 | }); 43 | 44 | const [resPromise] = getProductsByCategory({ category: "electronics" }); 45 | resPromise.then((res) => console.log(res)); 46 | ``` 47 | 48 | Next, let's try defining a product information schema and include `category` as part of the product information schema. Here, we use the initial value of the `category` schema, so there is no need to call `.mutate()`. Simply use the spread operator `...` to spread `category.def`: 49 | 50 | ```js 51 | // schema/product.js 52 | // ... 53 | import category from "./category.js"; 54 | 55 | export default defineSchemaType("Product", { 56 | ...category.def, 57 | /** 58 | * name of the product 59 | * @min 1 60 | * @max 20 61 | */ 62 | title: { 63 | rules: ["string", { min: 1, max: 20, measurement: "length" }], 64 | type: "", 65 | }, 66 | /** 67 | * price 68 | * @min 1 69 | */ 70 | price: { 71 | rules: ["number", { min: 1 }], 72 | type: 1, 73 | }, 74 | /** 75 | * description 76 | * @min 1 77 | * @max 100 78 | */ 79 | description: { 80 | rules: ["string", { min: 1, max: 100, measurement: "length" }], 81 | type: "", 82 | }, 83 | /** 84 | * image 85 | * @max 5MiB 86 | */ 87 | image: { 88 | rules: [File, { measurement: "size", max: 1024 * 1024 * 5 }], 89 | /** @type {File} */ 90 | type: null, 91 | }, 92 | }); 93 | ``` 94 | 95 | ## Using Schema as String Rule 96 | 97 | To use a schema as a string rule, it must be registered in the `schema` property of the Karman tree. It is not necessary to register it on the root node, but eventually, the registered schema will be temporarily stored in the root node. Furthermore, it designates the Karman tree as the scope for this schema's string rule type. Within this scope, the schema name can be used as a validation rule. However, it's important to note that when a schema is used as a string rule, only the original definition of the schema is used as the validation rule, and the `defaultValue` attribute is not applicable. 98 | 99 | Suppose we register the above schema `product` on a Karman tree. In that case, it can be used as the "type" of a parameter and serve as a validation rule. Additionally, this type can be added to array syntax to perform deep traversal and validation of arrays: 100 | 101 | ```js 102 | // /product-management.js 103 | import { defineKarman, defineAPI, getType } from "@vic0627/karman"; 104 | import product from "./schema/product.js"; 105 | 106 | export default defineKarman({ 107 | // root: true, // Assume this node is not the root node 108 | url: "products", 109 | schema: [product], 110 | api: { 111 | addProducts: defineAPI({ 112 | method: "POST", 113 | payloadDef: { 114 | data: { 115 | rules: "Product[]", 116 | required: true, 117 | type: getType([product.def]), 118 | }, 119 | }, 120 | }), 121 | }, 122 | }); 123 | 124 | // /index.js 125 | // ... 126 | import karman from "./karman.js"; 127 | 128 | const [resPromise] = karman.productManagement.addProducts({ 129 | data: [ 130 | { 131 | title: "blue shirt", 132 | price: 100, 133 | // ... 134 | }, 135 | { 136 | title: "red skirt", 137 | price: 99.99, 138 | // ... 139 | }, 140 | ], 141 | }); 142 | ``` 143 | 144 | In fact, we can also use the string rule type of a schema within an attribute of another schema, as long as the schemas referencing each other are registered on the same Karman tree. However, it's essential to note whether there are any occurrences of circular references between schemas (including self-reference or closed cycles). Since the Schema API does not support this type of reference pattern, it checks the reference status of each schema within the same scope during registration. When a circular reference occurs, Karman immediately throws an error and notifies about the schemas involved in the circular reference: 145 | 146 | ```js 147 | // ... 148 | 149 | const schemaA = defineSchemaType("SchemaA", { 150 | param01: { 151 | // ... 152 | }, 153 | param02: { 154 | rules: "SchemaB", // Referencing another schema 155 | }, 156 | }); 157 | const schemaB = defineSchemaType("SchemaB", { 158 | param01: { 159 | // ... 160 | }, 161 | param02: { 162 | rules: "SchemaA", // Referencing the previous schema, creating a closed cycle 163 | }, 164 | }); 165 | 166 | const routeA = defineKarman({ 167 | // ... 168 | schema: [schemaB], // Schemas don't have to be registered on the root node, just ensure that two schemas with referencing relationships belong to the same root node 169 | }); 170 | 171 | export default defineKarman({ 172 | // ... 173 | root: true, 174 | schema: [schemaA], // Reference Error: schemaA and schemaB form a closed cycle, error is thrown during initialization 175 | route: { 176 | routeA, 177 | }, 178 | }); 179 | ``` 180 | -------------------------------------------------------------------------------- /assets/doc/en/validation-engine.md: -------------------------------------------------------------------------------- 1 | # Validation Engine 2 | 3 | The validation engine handles the parameter validation mechanism for final APIs. When sending a request with a final API, it verifies whether the received parameters comply with the validation rules defined for the parameters. If any parameter fails validation, the request will not be executed, and a `ValidationError` will be thrown. Error messages can be automatically generated by the validation engine or defined by the user. 4 | 5 | - [Validation Engine](#validation-engine) 6 | - [Rules](#rules) 7 | - [Rule Set](#rule-set) 8 | - [String Rule - Array Syntax](#string-rule---array-syntax) 9 | 10 | ## Rules 11 | 12 | There are various types of validation rules available, ranging from those provided by the validation engine itself to custom validation functions. Here are the different categories: 13 | 14 | - **String Rule**: Describes types using strings, extending JavaScript's primitive types. Some special types have unique definitions. Error messages for this rule are automatically generated by the validation engine. 15 | - `"char"`: Character, a string with a length of 1 16 | - `"string"`: String 17 | - `"int"`: Integer 18 | - `"number"`: Number 19 | - `"nan"`: NaN 20 | - `"boolean"`: Boolean 21 | - `"object"`: Broad object, including `() => {}`, `{}`, or `[]` 22 | - `"null"`: Null 23 | - `"function"`: Function 24 | - `"array"`: Array 25 | - `"object-literal"`: Object represented by curly braces 26 | - `"undefined"`: Undefined 27 | - `"bigint"`: BigInt 28 | - `"symbol"`: Symbol 29 | - **Constructor**: Any constructor (class), validated using `instanceof` by the validation engine. 30 | - **Custom Validator**: Custom validation functions. Since JavaScript cannot distinguish between regular functions and constructors, you need to define them using `defineCustomValidator()`; otherwise, the function will be treated as a constructor. 31 | - **Regular Expression**: Regular expressions, which can include error messages. 32 | - **Parameter Descriptor**: A parameter descriptor represented as an object. It can define the maximum, minimum, equal values, and measurement properties for parameters. It is best used in conjunction with String Rule to form a [Rule Set](#rule-set). Perform type validation first and then proceed with unit measurement to ensure the integrity of the validation mechanism. 33 | 34 | ```js 35 | import { defineKarman, defineAPI, defineCustomValidator, ValidationError } from "@vic0627/karman"; 36 | 37 | const customValidator = defineCustomValidator((prop, value) => { 38 | if (value !== "@vic0627/karman") 39 | throw new ValidationError(`Parameter '${prop}' must be 'karman' but received '${value}'`); 40 | }); 41 | 42 | const emailRule = { 43 | regexp: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, 44 | errorMessage: "Invalid email format", 45 | }; 46 | 47 | const karman = defineKarman({ 48 | // ... 49 | validation: true, 50 | api: { 51 | ruleTest: defineAPI({ 52 | payloadDef: { 53 | param01: { rules: "char" }, // String Rule 54 | param02: { rules: Date }, // Constructor 55 | param03: { rules: customValidator }, // Custom Validator 56 | param04: { rules: emailRule }, // Regular Expression 57 | param05: { 58 | // Parameter Descriptor 59 | rules: { 60 | min: 0, 61 | max: 5, 62 | measurement: "length", 63 | }, 64 | }, 65 | }, 66 | }), 67 | }, 68 | }); 69 | 70 | karman.ruleTest(); // No required parameter set, so no error thrown 71 | karman.ruleTest({ param01: "A" }); // Valid 72 | karman.ruleTest({ param01: "foo" }); // ValidationError 73 | karman.ruleTest({ param02: new Date() }); // Valid 74 | karman.ruleTest({ param02: "2024-01-01" }); // ValidationError 75 | karman.ruleTest({ param03: "@vic0627/karman" }); // Valid 76 | karman.ruleTest({ param03: "bar" }); // ValidationError: Parameter 'param03' must be 'karman' but received 'bar' 77 | karman.ruleTest({ param04: "karman@gmail.com" }); // Valid 78 | karman.ruleTest({ param04: "karman is the best" }); // ValidationError: Invalid email format 79 | karman.ruleTest({ param05: "@vic0627/karman" }); // Valid 80 | karman.ruleTest({ param05: "karman is the best" }); // ValidationError 81 | karman.ruleTest({ param05: 1 }); // Warning: Unable to find measurable property 82 | ``` 83 | 84 | ## Rule Set 85 | 86 | A collection of rules, formed by the rules described in the previous section, will be sequentially validated starting from the first rule in the collection. There are two types of rule sets: Intersection Rules and Union Rules, each triggering different validation mechanisms. 87 | 88 | - **Intersection Rules**: Intersection Rules can be defined using `defineIntersectionRules()` or a regular array. When the validation engine receives a regular array as rules, it implicitly converts it into a union rule set. When using this collection as validation rules, parameters must comply with all rules to pass validation. 89 | - **Union Rules**: Defined using `defineUnionRules()`, when using this collection as validation rules, parameters only need to comply with one of the rules in the collection to pass validation. 90 | 91 | ```js 92 | import { defineKarman, defineAPI, defineIntersectionRules, defineUnionRules } from "@vic0627/karman"; 93 | 94 | const karman = defineKarman({ 95 | // ... 96 | api: { 97 | ruleSetTest: defineAPI({ 98 | param01: { 99 | // The array will be implicitly converted into a intersection rule set 100 | rules: [ 101 | "string", 102 | { 103 | min: 1, 104 | measurement: "length", 105 | }, 106 | ], 107 | }, 108 | param02: { 109 | // Equivalent to the rules of param01 110 | rules: defineIntersectionRules("string", { 111 | min: 1, 112 | measurement: "length", 113 | }), 114 | }, 115 | param03: { 116 | // Union Rules 117 | rules: defineUnionRules("string", "number", "boolean"), 118 | }, 119 | }), 120 | }, 121 | }); 122 | 123 | karman.ruleSetTest({ param01: "" }); // ValidationError 124 | karman.ruleSetTest({ param02: "foo" }); // Valid 125 | karman.ruleSetTest({ param03: false }); // Valid 126 | ``` 127 | 128 | ## String Rule - Array Syntax 129 | 130 | An extension syntax for [String Rule](#rules), primarily used to validate arrays, array lengths, and the types of elements within arrays. The basic syntax is as follows: 131 | 132 | ```txt 133 | [] 134 | [] 135 | [:] 136 | [:] 137 | [:] 138 | ``` 139 | 140 | On the left side, there must be a string representing the type, and on the right side, there is a set of square brackets. Inside the brackets, you can optionally use a colon to define minimum and maximum values. The parameters using array syntax as validation rules **must be of type array**. If no value is provided inside the brackets, it means there is no limit on the array length. 141 | 142 | ```js 143 | const arrTest = defineAPI({ 144 | // ... 145 | payloadDef: { 146 | param01: { 147 | rules: "int[]", // Must be an array of integers 148 | }, 149 | param02: { 150 | rules: "string[5]", // Must be an array of strings with length 5 151 | }, 152 | param03: { 153 | rules: "char[5:]", // Must be an array of characters with a length greater than or equal to 5 154 | }, 155 | param04: { 156 | rules: "number[:5]", // Must be an array of numbers with a length less than or equal to 5 157 | }, 158 | param05: { 159 | rules: "boolean[3:5]", // Must be an array of booleans with a length between 3 and 5 (inclusive) 160 | }, 161 | }, 162 | }); 163 | ``` 164 | --------------------------------------------------------------------------------