@tsed/ajv 8.0.0 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -2,13 +2,14 @@
2
2
  "name": "@tsed/ajv",
3
3
  "description": "AJV package for Ts.ED framework",
4
4
  "type": "module",
5
- "version": "8.0.0",
5
+ "version": "8.0.2",
6
6
  "source": "./src/index.ts",
7
7
  "main": "./lib/esm/index.js",
8
8
  "module": "./lib/esm/index.js",
9
9
  "typings": "./lib/types/index.d.ts",
10
10
  "exports": {
11
11
  ".": {
12
+ "@tsed/source": "./src/index.ts",
12
13
  "types": "./lib/types/index.d.ts",
13
14
  "import": "./lib/esm/index.js",
14
15
  "default": "./lib/esm/index.js"
@@ -27,22 +28,22 @@
27
28
  "tslib": "2.7.0"
28
29
  },
29
30
  "devDependencies": {
30
- "@tsed/barrels": "8.0.0",
31
- "@tsed/core": "8.0.0",
32
- "@tsed/di": "8.0.0",
33
- "@tsed/exceptions": "8.0.0",
34
- "@tsed/schema": "8.0.0",
35
- "@tsed/typescript": "8.0.0",
31
+ "@tsed/barrels": "8.0.2",
32
+ "@tsed/core": "8.0.2",
33
+ "@tsed/di": "8.0.2",
34
+ "@tsed/exceptions": "8.0.2",
35
+ "@tsed/schema": "8.0.2",
36
+ "@tsed/typescript": "8.0.2",
36
37
  "ajv": "^8.17.1",
37
38
  "eslint": "9.12.0",
38
39
  "typescript": "5.4.5",
39
40
  "vitest": "2.1.2"
40
41
  },
41
42
  "peerDependencies": {
42
- "@tsed/core": "8.0.0",
43
- "@tsed/di": "8.0.0",
44
- "@tsed/exceptions": "8.0.0",
45
- "@tsed/schema": "8.0.0",
43
+ "@tsed/core": "8.0.2",
44
+ "@tsed/di": "8.0.2",
45
+ "@tsed/exceptions": "8.0.2",
46
+ "@tsed/schema": "8.0.2",
46
47
  "ajv": ">=8.9.0",
47
48
  "ajv-errors": ">=3.0.0"
48
49
  },
@@ -0,0 +1,24 @@
1
+ import {StoreSet, useDecorators} from "@tsed/core";
2
+ import {Injectable} from "@tsed/di";
3
+ import {AsyncFormatDefinition, FormatDefinition} from "ajv";
4
+
5
+ export type FormatsOptions = Omit<FormatDefinition<any>, "validate" | "compare"> | Omit<AsyncFormatDefinition<any>, "validate" | "compare">;
6
+
7
+ /**
8
+ * Create a new custom formats validator
9
+ * @param name
10
+ * @param options
11
+ * @decorator
12
+ * @ajv
13
+ */
14
+ export function Formats(name: string, options: FormatsOptions = {}): ClassDecorator {
15
+ return useDecorators(
16
+ Injectable({
17
+ type: "ajv:formats"
18
+ }),
19
+ StoreSet("ajv:formats", {
20
+ name,
21
+ options
22
+ })
23
+ );
24
+ }
@@ -0,0 +1,26 @@
1
+ import {StoreSet, useDecorators} from "@tsed/core";
2
+ import {Injectable} from "@tsed/di";
3
+ import {JsonSchema} from "@tsed/schema";
4
+ import {AnySchemaObject, KeywordDefinition} from "ajv";
5
+
6
+ export type KeywordOptions = Partial<Omit<KeywordDefinition, "metaSchema">> & {
7
+ metaSchema?: AnySchemaObject | JsonSchema;
8
+ };
9
+
10
+ /**
11
+ * Create new keyword custom validator
12
+ * @param options
13
+ * @decorator
14
+ * @ajv
15
+ */
16
+ export function Keyword(options: KeywordOptions): ClassDecorator {
17
+ return useDecorators(
18
+ Injectable({
19
+ type: "ajv:keyword"
20
+ }),
21
+ StoreSet("ajv:keyword", {
22
+ ...options,
23
+ metaSchema: options.metaSchema && options.metaSchema.toJSON ? options.metaSchema.toJSON() : options.metaSchema
24
+ })
25
+ );
26
+ }
@@ -0,0 +1,11 @@
1
+ import {BadRequest} from "@tsed/exceptions";
2
+
3
+ export class AjvValidationError extends BadRequest {
4
+ public name: string = "AJV_VALIDATION_ERROR";
5
+ public errors: any[];
6
+
7
+ constructor(message: string, errors: any[] = []) {
8
+ super(message);
9
+ this.errors = errors;
10
+ }
11
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @file Automatically generated by @tsed/barrels.
3
+ */
4
+ export * from "./decorators/formats.js";
5
+ export * from "./decorators/keyword.js";
6
+ export * from "./errors/AjvValidationError.js";
7
+ export * from "./interfaces/FormatsMethods.js";
8
+ export * from "./interfaces/IAjvSettings.js";
9
+ export * from "./interfaces/interfaces.js";
10
+ export * from "./interfaces/KeywordMethods.js";
11
+ export * from "./services/Ajv.js";
12
+ export * from "./services/AjvService.js";
13
+ export * from "./utils/defaultErrorFormatter.js";
14
+ export * from "./utils/getPath.js";
@@ -0,0 +1,5 @@
1
+ export interface FormatsMethods<T extends string | number> {
2
+ compare?: (data1: T, data2: T) => number | undefined;
3
+
4
+ validate(data: T): boolean;
5
+ }
@@ -0,0 +1,23 @@
1
+ import {ErrorObject, Options} from "ajv";
2
+
3
+ /**
4
+ *
5
+ */
6
+ export interface AjvErrorObject extends ErrorObject {
7
+ modelName: string;
8
+ collectionName?: string;
9
+ }
10
+
11
+ /**
12
+ *
13
+ */
14
+ export type ErrorFormatter = (error: AjvErrorObject) => string;
15
+
16
+ /**
17
+ *
18
+ */
19
+ export interface IAjvSettings extends Options {
20
+ Ajv?: any;
21
+ errorFormatter?: ErrorFormatter;
22
+ returnsCoercedValues?: boolean;
23
+ }
@@ -0,0 +1,23 @@
1
+ import {AnySchemaObject, KeywordCxt, SchemaObjCxt, ValidateFunction} from "ajv";
2
+
3
+ export interface AjvDataValidationCtx<T extends string | number = string | number> {
4
+ dataPath: string;
5
+ parentData: {[K in T]: any}; // object or array
6
+ parentDataProperty: T; // string or number
7
+ rootData: Record<string, any> | any[];
8
+ dynamicAnchors: {[Ref in string]?: ValidateFunction};
9
+ }
10
+
11
+ export interface AjvDataValidateFunction {
12
+ (...args: Parameters<ValidateFunction>): boolean | Promise<any>;
13
+ }
14
+
15
+ export interface AjvSchemaValidateFunction {
16
+ (schema: any, data: any, parentSchema?: AnySchemaObject, dataCxt?: AjvDataValidationCtx): boolean | Promise<any>;
17
+ }
18
+
19
+ export interface KeywordMethods {
20
+ code?: (cxt: KeywordCxt, ruleType?: string) => void;
21
+ compile?: (schema: any, parentSchema: AnySchemaObject, it: SchemaObjCxt) => AjvDataValidateFunction;
22
+ validate?: AjvSchemaValidateFunction | AjvDataValidateFunction;
23
+ }
@@ -0,0 +1,10 @@
1
+ import {IAjvSettings} from "./IAjvSettings.js";
2
+
3
+ declare global {
4
+ namespace TsED {
5
+ interface Configuration {
6
+ // @ts-ignore
7
+ ajv?: IAjvSettings;
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,39 @@
1
+ import {PlatformTest} from "@tsed/platform-http/testing";
2
+ import {Ajv} from "ajv";
3
+
4
+ describe("Ajv", () => {
5
+ beforeEach(() => PlatformTest.create());
6
+ afterEach(() => PlatformTest.reset());
7
+ it("should create a new Ajv instance", async () => {
8
+ const ajv = await PlatformTest.invoke<Ajv>(Ajv);
9
+
10
+ expect(ajv).toBeInstanceOf(Ajv);
11
+ expect(
12
+ ajv.validate(
13
+ {
14
+ type: "object"
15
+ },
16
+ {}
17
+ )
18
+ ).toEqual(true);
19
+
20
+ ajv.validate(
21
+ {
22
+ type: "object"
23
+ },
24
+ []
25
+ );
26
+
27
+ expect(ajv.errors).toEqual([
28
+ {
29
+ instancePath: "",
30
+ keyword: "type",
31
+ message: "must be object",
32
+ params: {
33
+ type: "object"
34
+ },
35
+ schemaPath: "#/type"
36
+ }
37
+ ]);
38
+ });
39
+ });
@@ -0,0 +1,84 @@
1
+ import {cleanObject} from "@tsed/core";
2
+ import {constant, inject, injectable, injector, InjectorService, ProviderScope} from "@tsed/di";
3
+ import {Ajv, Format, KeywordDefinition, Options, Vocabulary} from "ajv";
4
+ import AjvErrors from "ajv-errors";
5
+ import AjvFormats from "ajv-formats";
6
+
7
+ import {FormatsMethods} from "../interfaces/FormatsMethods.js";
8
+ import {IAjvSettings} from "../interfaces/IAjvSettings.js";
9
+
10
+ function getHandler(key: string, service: any) {
11
+ if (service[key]) {
12
+ return service[key].bind(service);
13
+ }
14
+ }
15
+
16
+ function getKeywordProviders() {
17
+ return injector().getProviders("ajv:keyword");
18
+ }
19
+
20
+ function bindKeywords(): Vocabulary {
21
+ return getKeywordProviders().map((provider) => {
22
+ const options = provider.store.get<Omit<KeywordDefinition, "compile">>("ajv:keyword", {})!;
23
+ const service = inject(provider.token);
24
+
25
+ return <KeywordDefinition>cleanObject({
26
+ coerceTypes: "array",
27
+ ...options,
28
+ validate: getHandler("validate", service),
29
+ compile: getHandler("compile", service),
30
+ code: getHandler("code", service),
31
+ macro: getHandler("macro", service)
32
+ });
33
+ });
34
+ }
35
+
36
+ function getFormatsProviders() {
37
+ return injector().getProviders("ajv:formats");
38
+ }
39
+
40
+ function getFormats(): {name: string; options: Format}[] {
41
+ return getFormatsProviders().map((provider) => {
42
+ const {name, options} = provider.store.get<any>("ajv:formats", {})!;
43
+ const service = inject<FormatsMethods<any>>(provider.token);
44
+
45
+ return {
46
+ name,
47
+ options: {
48
+ ...options,
49
+ validate: service.validate.bind(service),
50
+ compare: service.compare?.bind(service)
51
+ }
52
+ };
53
+ });
54
+ }
55
+
56
+ injectable(Ajv)
57
+ .scope(ProviderScope.SINGLETON)
58
+ .factory(() => {
59
+ const {errorFormatter, keywords = [], ...props} = constant<IAjvSettings>("ajv") || {};
60
+ const options: Options = {
61
+ verbose: false,
62
+ coerceTypes: true,
63
+ strict: false,
64
+ keywords: [...keywords, ...bindKeywords()],
65
+ discriminator: true,
66
+ allErrors: true,
67
+ ...props
68
+ };
69
+
70
+ // @ts-ignore
71
+ const ajv = new Ajv(options);
72
+
73
+ // add support for custom error messages
74
+ // @ts-ignore
75
+ AjvErrors(ajv);
76
+ // @ts-ignore
77
+ AjvFormats(ajv as any);
78
+
79
+ getFormats().forEach(({name, options}) => {
80
+ ajv.addFormat(name, options);
81
+ });
82
+
83
+ return ajv;
84
+ });
@@ -0,0 +1,47 @@
1
+ import {catchAsyncError} from "@tsed/core";
2
+ import {PlatformTest} from "@tsed/platform-http/testing";
3
+ import {object, string} from "@tsed/schema";
4
+
5
+ import {AjvService} from "./AjvService.js";
6
+
7
+ describe("AjvService", () => {
8
+ beforeEach(() => PlatformTest.create());
9
+ afterEach(() => PlatformTest.reset());
10
+
11
+ it("should use the function api as schema", async () => {
12
+ const ajvService = PlatformTest.get<AjvService>(AjvService);
13
+
14
+ const error: any = await catchAsyncError(() => ajvService.validate("test", string().minLength(5)));
15
+ expect(error.errors).toEqual([
16
+ {
17
+ data: "test",
18
+ dataPath: "",
19
+ instancePath: "",
20
+ keyword: "minLength",
21
+ message: "must NOT have fewer than 5 characters",
22
+ params: {
23
+ limit: 5
24
+ },
25
+ schemaPath: "#/minLength"
26
+ }
27
+ ]);
28
+ });
29
+
30
+ it("should use the function api as schema (required)", async () => {
31
+ const ajvService = PlatformTest.get<AjvService>(AjvService);
32
+
33
+ const error: any = await catchAsyncError(() => ajvService.validate({}, object({test: string().required()})));
34
+ expect(error.errors).toEqual([
35
+ {
36
+ dataPath: ".test",
37
+ instancePath: "",
38
+ keyword: "required",
39
+ message: "must have required property 'test'",
40
+ params: {
41
+ missingProperty: "test"
42
+ },
43
+ schemaPath: "#/required"
44
+ }
45
+ ]);
46
+ });
47
+ });
@@ -0,0 +1,116 @@
1
+ import "./Ajv.js";
2
+
3
+ import {deepClone, getValue, nameOf, prototypeOf, setValue, Type} from "@tsed/core";
4
+ import {constant, inject, injectable} from "@tsed/di";
5
+ import {getJsonSchema, JsonEntityStore, JsonSchema, JsonSchemaObject} from "@tsed/schema";
6
+ import {Ajv, ErrorObject} from "ajv";
7
+
8
+ import {AjvValidationError} from "../errors/AjvValidationError.js";
9
+ import {AjvErrorObject, ErrorFormatter} from "../interfaces/IAjvSettings.js";
10
+ import {defaultErrorFormatter} from "../utils/defaultErrorFormatter.js";
11
+ import {getPath} from "../utils/getPath.js";
12
+
13
+ export interface AjvValidateOptions extends Record<string, any> {
14
+ schema?: JsonSchema | Partial<JsonSchemaObject>;
15
+ type?: Type<any> | any;
16
+ collectionType?: Type<any> | any;
17
+ }
18
+
19
+ export class AjvService {
20
+ readonly name = "ajv";
21
+ protected errorFormatter = constant<ErrorFormatter>("ajv.errorFormatter", defaultErrorFormatter);
22
+ protected returnsCoercedValues = constant<boolean>("ajv.returnsCoercedValues");
23
+ protected ajv = inject(Ajv);
24
+
25
+ async validate(value: any, options: AjvValidateOptions | JsonSchema): Promise<any> {
26
+ let {schema: defaultSchema, type, collectionType, ...additionalOptions} = this.mapOptions(options);
27
+
28
+ const schema = defaultSchema || getJsonSchema(type, {...additionalOptions, customKeys: true});
29
+
30
+ if (schema) {
31
+ const localValue = this.returnsCoercedValues ? value : deepClone(value);
32
+ const validate = this.ajv.compile(schema);
33
+
34
+ const valid = await validate(localValue);
35
+ const {errors} = validate;
36
+
37
+ if (!valid && errors) {
38
+ throw this.mapErrors(errors, {
39
+ type,
40
+ collectionType,
41
+ async: true,
42
+ value: localValue
43
+ });
44
+ }
45
+
46
+ if (this.returnsCoercedValues) {
47
+ return localValue;
48
+ }
49
+ }
50
+
51
+ return value;
52
+ }
53
+
54
+ protected mapOptions(options: AjvValidateOptions | JsonSchema): AjvValidateOptions {
55
+ if (options instanceof JsonSchema) {
56
+ return {
57
+ schema: options.toJSON({customKeys: true})
58
+ };
59
+ }
60
+
61
+ return options;
62
+ }
63
+
64
+ protected mapErrors(errors: ErrorObject[], options: any) {
65
+ const {type, collectionType, value} = options;
66
+
67
+ const message = errors
68
+ .map((error: AjvErrorObject) => {
69
+ if (collectionType) {
70
+ error.collectionName = nameOf(collectionType);
71
+ }
72
+
73
+ const dataPath = getPath(error);
74
+
75
+ if (!error.data) {
76
+ if (dataPath) {
77
+ error.data = getValue(value, dataPath.replace(/^\./, ""));
78
+ } else if (error.schemaPath !== "#/required") {
79
+ error.data = value;
80
+ }
81
+ }
82
+
83
+ if (dataPath && dataPath.match(/pwd|password|mdp|secret/)) {
84
+ error.data = "[REDACTED]";
85
+ }
86
+
87
+ if (type) {
88
+ error.modelName = nameOf(type);
89
+ error.message = this.mapClassError(error, type);
90
+ }
91
+
92
+ return this.errorFormatter.call(this, error, {});
93
+ })
94
+ .join("\n");
95
+
96
+ return new AjvValidationError(message, errors);
97
+ }
98
+
99
+ protected mapClassError(error: AjvErrorObject, targetType: Type<any>) {
100
+ const propertyKey = getValue(error, "params.missingProperty");
101
+
102
+ if (propertyKey) {
103
+ const store = JsonEntityStore.from<JsonEntityStore>(prototypeOf(targetType), propertyKey);
104
+
105
+ if (store) {
106
+ setValue(error, "params.missingProperty", store.name || propertyKey);
107
+
108
+ return error.message?.replace(`'${propertyKey}'`, `'${store.name || propertyKey}'`);
109
+ }
110
+ }
111
+
112
+ return error.message;
113
+ }
114
+ }
115
+
116
+ injectable(AjvService).type("validator:service");
@@ -0,0 +1,73 @@
1
+ import {defaultErrorFormatter} from "./defaultErrorFormatter.js";
2
+
3
+ describe("defaultErrorFormatter", () => {
4
+ it("should extract data and format error (data = undefined)", () => {
5
+ expect(
6
+ defaultErrorFormatter({
7
+ keyword: "maximum",
8
+ dataPath: "/length",
9
+ schemaPath: "#/properties/length/maximum",
10
+ params: {comparison: "<=", limit: 250},
11
+ message: "should be <= 250",
12
+ data: undefined,
13
+ modelName: "RandomStringModel"
14
+ } as any)
15
+ ).toEqual('RandomStringModel.length should be <= 250. Given value: "undefined"');
16
+ });
17
+
18
+ it("should extract data and format error (modelName = '' & collectionName)", () => {
19
+ expect(
20
+ defaultErrorFormatter({
21
+ keyword: "maximum",
22
+ dataPath: "/0",
23
+ schemaPath: "#/properties/length/maximum",
24
+ params: {comparison: "<=", limit: 250},
25
+ message: "should be <= 250",
26
+ data: undefined,
27
+ modelName: undefined,
28
+ collectionName: "Array"
29
+ } as any)
30
+ ).toEqual('[0] should be <= 250. Given value: "undefined"');
31
+ });
32
+
33
+ it("should extract data and format error (modelName = '')", () => {
34
+ expect(
35
+ defaultErrorFormatter({
36
+ keyword: "maximum",
37
+ dataPath: "/length",
38
+ schemaPath: "#/properties/length/maximum",
39
+ params: {comparison: "<=", limit: 250},
40
+ message: "should be <= 250",
41
+ data: undefined,
42
+ modelName: undefined
43
+ } as any)
44
+ ).toEqual('Value.length should be <= 250. Given value: "undefined"');
45
+ });
46
+
47
+ it("should extract data and format error", () => {
48
+ expect(
49
+ defaultErrorFormatter({
50
+ keyword: "maximum",
51
+ dataPath: "/length",
52
+ schemaPath: "#/properties/length/maximum",
53
+ params: {comparison: "<=", limit: 250},
54
+ message: "should be <= 250",
55
+ data: 251,
56
+ modelName: "RandomStringModel"
57
+ } as any)
58
+ ).toEqual("RandomStringModel.length should be <= 250. Given value: 251");
59
+ });
60
+ it("should extract data and format error(2)", () => {
61
+ expect(
62
+ defaultErrorFormatter({
63
+ instancePath: "/length",
64
+ schemaPath: "#/properties/length/maximum",
65
+ keyword: "maximum",
66
+ params: {comparison: "<=", limit: 250},
67
+ message: "must be <= 250",
68
+ data: {length: 251},
69
+ modelName: "RandomStringModel"
70
+ } as any)
71
+ ).toEqual('RandomStringModel.length must be <= 250. Given value: {"length":251}');
72
+ });
73
+ });
@@ -0,0 +1,33 @@
1
+ import {AjvErrorObject} from "../interfaces/IAjvSettings.js";
2
+ import {getInstancePath, getPath} from "./getPath.js";
3
+
4
+ export function defaultErrorFormatter(error: AjvErrorObject & {dataPath: string}) {
5
+ const value = JSON.stringify(error.data === undefined ? "undefined" : error.data);
6
+ const join = (list: any[]): string => list.filter(Boolean).join("").trim();
7
+
8
+ const [, indexPath, ...paths] = getInstancePath(error).split(".");
9
+ const deepPaths = paths.length ? "." + paths.join(".") : "";
10
+
11
+ error.dataPath = getPath(error);
12
+
13
+ if (error.collectionName) {
14
+ const modelName = error.modelName || "";
15
+
16
+ switch (error.collectionName) {
17
+ case "Array":
18
+ return join([`${modelName}[${indexPath}]${deepPaths}`, ` ${error.message}. Given value: ${value}`]);
19
+ case "Map":
20
+ return join([`Map<${indexPath}, ${modelName}>${deepPaths}`, ` ${error.message}. Given value: ${value}`]);
21
+ case "Set":
22
+ return join([`Set<${indexPath}, ${modelName}>${deepPaths}`, ` ${error.message}. Given value: ${value}`]);
23
+ }
24
+ }
25
+
26
+ return join([
27
+ !error.modelName && "Value",
28
+ `${error.modelName || ""}`,
29
+ (!error?.params?.missingProperty && error.dataPath) ||
30
+ (error?.params?.missingProperty && (error.instancePath || "").replace(/\//g, ".")),
31
+ ` ${error.message}. Given value: ${value}`
32
+ ]);
33
+ }
@@ -0,0 +1,13 @@
1
+ import {getValue} from "@tsed/core";
2
+
3
+ export function getPath(error: any) {
4
+ if (error.params && error.params.missingProperty) {
5
+ return `.${error.params.missingProperty}`;
6
+ }
7
+
8
+ return getInstancePath(error);
9
+ }
10
+
11
+ export function getInstancePath(error: any) {
12
+ return getValue(error, "dataPath", getValue(error, "instancePath", "")).replace(/\//gi, ".");
13
+ }