Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: impl ajv + typebox Validator #201

Merged
merged 1 commit into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions core/ajv-decorator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# `@eggjs/ajv-decorator`

## Usage

Please read [@eggjs/tegg-ajv-plugin](../../plugin/ajv-plugin)
4 changes: 4 additions & 0 deletions core/ajv-decorator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from '@sinclair/typebox';
export * from './src/enum/TransformEnum';
export * from './src/error/AjvInvalidParamError';
export * from './src/type/Ajv';
53 changes: 53 additions & 0 deletions core/ajv-decorator/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@eggjs/ajv-decorator",
"version": "3.35.1",
"description": "tegg ajv decorator",
"keywords": [
"egg",
"typescript",
"decorator",
"tegg",
"ajv"
],
"main": "dist/index.js",
"files": [
"dist/**/*.js",
"dist/**/*.d.ts"
],
"typings": "dist/index.d.ts",
"scripts": {
"test": "cross-env NODE_ENV=test NODE_OPTIONS='--no-deprecation' mocha",
"clean": "tsc -b --clean",
"tsc": "npm run clean && tsc -p ./tsconfig.json",
"tsc:pub": "npm run clean && tsc -p ./tsconfig.pub.json",
"prepublishOnly": "npm run tsc:pub"
},
"license": "MIT",
"homepage": "https://github.com/eggjs/tegg",
"bugs": {
"url": "https://github.com/eggjs/tegg/issues"
},
"repository": {
"type": "git",
"url": "[email protected]:eggjs/tegg.git",
"directory": "core/ajv-decorator"
},
"engines": {
"node": ">=16.0.0"
},
"dependencies": {
"@sinclair/typebox": "^0.32.20",
"ajv": "^8.12.0"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/mocha": "^10.0.1",
"@types/node": "^20.2.4",
"cross-env": "^7.0.3",
"mocha": "^10.2.0",
"ts-node": "^10.9.1",
"typescript": "^5.0.4"
}
}
45 changes: 45 additions & 0 deletions core/ajv-decorator/src/enum/TransformEnum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* This keyword allows a string to be modified during validation.
* This keyword applies only to strings. If the data is not a string, the transform keyword is ignored.
* @see https://github.com/ajv-validator/ajv-keywords?tab=readme-ov-file#transform
*/
export enum TransformEnum {
/** remove whitespace from start and end */
trim = 'trim',
/** remove whitespace from start */
trimStart = 'trimStart',
/**
* @alias trimStart
*/
trimLeft = 'trimLeft',
/** remove whitespace from end */
trimEnd = 'trimEnd',
/**
* @alias trimEnd
*/
trimRight = 'trimRight',
/** convert to lower case */
toLowerCase = 'toLowerCase',
/** convert to upper case */
toUpperCase = 'toUpperCase',
/**
* change string case to be equal to one of `enum` values in the schema
*
* **NOTE**: requires that all allowed values are unique when case insensitive
* ```ts
* const schema = {
* type: "array",
* items: {
* type: "string",
* transform: ["trim", Transform.toEnumCase],
* enum: ["pH"],
* },
* };
*
* const data = ["ph", " Ph", "PH", "pH "];
* ajv.validate(schema, data);
* console.log(data) // ['pH','pH','pH','pH'];
* ```
*/
toEnumCase = 'toEnumCase',
}
21 changes: 21 additions & 0 deletions core/ajv-decorator/src/error/AjvInvalidParamError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { type ErrorObject } from 'ajv/dist/2019';

export interface AjvInvalidParamErrorOptions {
errorData: unknown;
currentSchema: string;
errors: ErrorObject[];
}

export class AjvInvalidParamError extends Error {
errorData: unknown;
currentSchema: string;
errors: ErrorObject[];

constructor(message: string, options: AjvInvalidParamErrorOptions) {
super(message);
this.name = this.constructor.name;
this.errorData = options.errorData;
this.currentSchema = options.currentSchema;
this.errors = options.errors;
}
}
5 changes: 5 additions & 0 deletions core/ajv-decorator/src/type/Ajv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Schema } from 'ajv/dist/2019';

export interface Ajv {
validate(schema: Schema, data: unknown): void;
}
8 changes: 8 additions & 0 deletions core/ajv-decorator/test/TransformEnum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { strict as assert } from 'node:assert';
import { TransformEnum } from '..';

describe('core/ajv-decorator/test/TransformEnum.test.ts', () => {
it('should get TransformEnum', () => {
assert.equal(TransformEnum.trim, 'trim');
});
});
12 changes: 12 additions & 0 deletions core/ajv-decorator/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"baseUrl": "./"
},
"exclude": [
"dist",
"node_modules",
"test"
]
}
12 changes: 12 additions & 0 deletions core/ajv-decorator/tsconfig.pub.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"baseUrl": "./"
},
"exclude": [
"dist",
"node_modules",
"test"
]
}
1 change: 0 additions & 1 deletion core/core-decorator/src/util/PrototypeUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ export class PrototypeUtil {
* get class property
* @param {EggProtoImplClass} clazz -
* @param {MultiInstancePrototypeGetObjectsContext} ctx -
* @return {EggPrototypeInfo} -
*/
static getMultiInstanceProperty(clazz: EggProtoImplClass, ctx: MultiInstancePrototypeGetObjectsContext): EggMultiInstancePrototypeInfo | undefined {
const metadata = MetadataUtil.getMetaData<EggMultiInstancePrototypeInfo>(this.MULTI_INSTANCE_PROTOTYPE_STATIC_PROPERTY, clazz);
Expand Down
1 change: 1 addition & 0 deletions core/tegg/ajv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@eggjs/ajv-decorator';
1 change: 1 addition & 0 deletions core/tegg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"node": ">=14.0.0"
},
"dependencies": {
"@eggjs/ajv-decorator": "^3.35.1",
"@eggjs/aop-decorator": "^3.35.1",
"@eggjs/controller-decorator": "^3.35.1",
"@eggjs/core-decorator": "^3.35.1",
Expand Down
144 changes: 144 additions & 0 deletions plugin/ajv/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# @eggjs/tegg-ajv-plugin

参考 [egg-typebox-validate](https://github.com/xiekw2010/egg-typebox-validate) 的最佳实践,结合 ajv + typebox,只需要定义一次参数类型和规则,就能同时拥有参数校验和类型定义(完整的 ts 类型提示)。

## egg 模式

### Install

```shell
# tegg 注解
npm i --save @eggjs/tegg
# tegg 插件
npm i --save @eggjs/tegg-plugin
# tegg ajv 插件
npm i --save @eggjs/tegg-ajv-plugin
```

### Prepare

```json
// tsconfig.json
{
"extends": "@eggjs/tsconfig"
}
```

### Config

```js
// config/plugin.js
exports.tegg = {
package: '@eggjs/tegg-plugin',
enable: true,
};

exports.teggAjv = {
package: '@eggjs/tegg-ajv-plugin',
enable: true,
};
```

## standalone 模式

### Install

```shell
# tegg 注解
npm i --save @eggjs/tegg
# tegg ajv 插件
npm i --save @eggjs/tegg-ajv-plugin
```

### Prepare

```json
// tsconfig.json
{
"extends": "@eggjs/tsconfig"
}
```

## Usage

1、定义入参校验 Schema

使用 typebox 定义,会内置到 tegg 导出

```ts
import { Type, TransformEnum } from '@eggjs/tegg/ajv';

const SyncPackageTaskSchema = Type.Object({
fullname: Type.String({
transform: [ TransformEnum.trim ],
maxLength: 100,
}),
tips: Type.String({
transform: [ TransformEnum.trim ],
maxLength: 1024,
}),
skipDependencies: Type.Boolean(),
syncDownloadData: Type.Boolean(),
// force sync immediately, only allow by admin
force: Type.Boolean(),
// sync history version
forceSyncHistory: Type.Boolean(),
// source registry
registryName: Type.Optional(Type.String()),
});
```

2、从校验 Schema 生成静态的入参类型

```ts
import { Static } from '@eggjs/tegg/ajv';

type SyncPackageTaskType = Static<typeof SyncPackageTaskSchema>;
```

3、在 Controller 中使用入参类型和校验 Schema

注入全局单例 ajv,调用 `ajv.validate(XxxSchema, params)` 进行参数校验,参数校验失败会直接抛出 `AjvInvalidParamError` 异常,
tegg 会自动返回相应的错误响应给客户端。

```ts
import { Inject, HTTPController, HTTPMethod, HTTPMethodEnum, HTTPBody } from '@eggjs/tegg';
import { Ajv, Type, Static, TransformEnum } from '@eggjs/tegg/ajv';

const SyncPackageTaskSchema = Type.Object({
fullname: Type.String({
transform: [ TransformEnum.trim ],
maxLength: 100,
}),
tips: Type.String({
transform: [ TransformEnum.trim ],
maxLength: 1024,
}),
skipDependencies: Type.Boolean(),
syncDownloadData: Type.Boolean(),
// force sync immediately, only allow by admin
force: Type.Boolean(),
// sync history version
forceSyncHistory: Type.Boolean(),
// source registry
registryName: Type.Optional(Type.String()),
});

type SyncPackageTaskType = Static<typeof SyncPackageTaskSchema>;

@HTTPController()
export class HelloController {
private readonly ajv: Ajv;

@HTTPMethod({
method: HTTPMethodEnum.POST,
path: '/sync',
})
async sync(@HTTPBody() task: SyncPackageTaskType) {
this.ajv.validate(SyncPackageTaskSchema, task);
return {
task,
};
}
}
```
54 changes: 54 additions & 0 deletions plugin/ajv/lib/Ajv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Ajv2019, { type Schema } from 'ajv/dist/2019';
import addFormats from 'ajv-formats';
import keyWords from 'ajv-keywords';
import { type Ajv as IAjv, AjvInvalidParamError } from '@eggjs/tegg/ajv';
import { SingletonProto, AccessLevel, LifecycleInit } from '@eggjs/tegg';

@SingletonProto({
accessLevel: AccessLevel.PUBLIC,
})
export class Ajv implements IAjv {
static InvalidParamErrorClass = AjvInvalidParamError;

#ajvInstance: Ajv2019;

@LifecycleInit()
protected _init() {
this.#ajvInstance = new Ajv2019();
keyWords(this.#ajvInstance, 'transform');
addFormats(this.#ajvInstance, [
'date-time',
'time',
'date',
'email',
'hostname',
'ipv4',
'ipv6',
'uri',
'uri-reference',
'uuid',
'uri-template',
'json-pointer',
'relative-json-pointer',
'regex',
])
.addKeyword('kind')
.addKeyword('modifier');
}

/**
* Validate data with typebox Schema.
*
* If validate fail, with throw `Ajv.InvalidParamErrorClass`
*/
validate(schema: Schema, data: unknown): void {
const result = this.#ajvInstance.validate(schema, data);
if (!result) {
throw new Ajv.InvalidParamErrorClass('Validation Failed', {
errorData: data,
currentSchema: JSON.stringify(schema),
errors: this.#ajvInstance.errors!,
});
}
}
}
Loading
Loading