UNPKG

5.4 kBPlain TextView Raw
1import * as fs from 'fs'
2import {
3 JsonSchema,
4 JsonSchemaAnyBuilder,
5 JsonSchemaBuilder,
6 _filterNullishValues,
7 _isObject,
8 _substringBefore,
9 CommonLogger,
10} from '@naturalcycles/js-lib'
11import Ajv, { ValidateFunction } from 'ajv'
12import { inspectAny, requireFileToExist } from '../../index'
13import { AjvValidationError } from './ajvValidationError'
14import { getAjv } from './getAjv'
15
16export interface AjvValidationOptions {
17 objectName?: string
18 objectId?: string
19
20 /**
21 * @default to cfg.logErrors, which defaults to true
22 */
23 logErrors?: boolean
24
25 /**
26 * Used to separate multiple validation errors.
27 *
28 * @default cfg.separator || '\n'
29 */
30 separator?: string
31}
32
33export interface AjvSchemaCfg {
34 /**
35 * Pass Ajv instance, otherwise Ajv will be created with
36 * AjvSchema default (not the same as Ajv defaults) parameters
37 */
38 ajv: Ajv
39
40 /**
41 * Dependent schemas to pass to Ajv instance constructor.
42 * Simpler than instantiating and passing ajv instance yourself.
43 */
44 schemas?: (JsonSchema | JsonSchemaBuilder | AjvSchema)[]
45
46 objectName?: string
47
48 /**
49 * Used to separate multiple validation errors.
50 *
51 * @default '\n'
52 */
53 separator: string
54
55 /**
56 * @default true
57 */
58 logErrors: boolean
59
60 /**
61 * Default to `console`
62 */
63 logger: CommonLogger
64
65 /**
66 * Option of Ajv.
67 * If set to true - will mutate your input objects!
68 * Defaults to false.
69 *
70 * This option is a "shortcut" to skip creating and passing Ajv instance.
71 */
72 coerceTypes?: boolean
73}
74
75/**
76 * On creation - compiles ajv validation function.
77 * Provides convenient methods, error reporting, etc.
78 *
79 * @experimental
80 */
81export class AjvSchema<T = unknown> {
82 private constructor(public schema: JsonSchema<T>, cfg: Partial<AjvSchemaCfg> = {}) {
83 this.cfg = {
84 logErrors: true,
85 logger: console,
86 separator: '\n',
87 ...cfg,
88 ajv:
89 cfg.ajv ||
90 getAjv({
91 schemas: cfg.schemas?.map(s => {
92 if (s instanceof AjvSchema) return s.schema
93 if (s instanceof JsonSchemaAnyBuilder) return s.build()
94 return s as JsonSchema
95 }),
96 coerceTypes: cfg.coerceTypes || false,
97 // verbose: true,
98 }),
99 // Auto-detecting "ObjectName" from $id of the schema (e.g "Address.schema.json")
100 objectName: cfg.objectName || (schema.$id ? _substringBefore(schema.$id, '.') : undefined),
101 }
102
103 this.validateFunction = this.cfg.ajv.compile<T>(schema)
104 }
105
106 /**
107 * Conveniently allows to pass either JsonSchema or JsonSchemaBuilder, or existing AjvSchema.
108 * If it's already an AjvSchema - it'll just return it without any processing.
109 * If it's a Builder - will call `build` before proceeding.
110 * Otherwise - will construct AjvSchema instance ready to be used.
111 *
112 * Implementation note: JsonSchemaBuilder goes first in the union type, otherwise TypeScript fails to infer <T> type
113 * correctly for some reason.
114 */
115 static create<T>(
116 schema: JsonSchemaBuilder<T> | JsonSchema<T> | AjvSchema<T>,
117 cfg: Partial<AjvSchemaCfg> = {},
118 ): AjvSchema<T> {
119 if (schema instanceof AjvSchema) return schema
120 if (schema instanceof JsonSchemaAnyBuilder) {
121 return new AjvSchema<T>(schema.build(), cfg)
122 }
123 return new AjvSchema<T>(schema as JsonSchema<T>, cfg)
124 }
125
126 /**
127 * Create AjvSchema directly from a filePath of json schema.
128 * Convenient method that just does fs.readFileSync for you.
129 */
130 static readJsonSync<T = unknown>(
131 filePath: string,
132 cfg: Partial<AjvSchemaCfg> = {},
133 ): AjvSchema<T> {
134 requireFileToExist(filePath)
135 const schema = JSON.parse(fs.readFileSync(filePath, 'utf8'))
136 return new AjvSchema<T>(schema, cfg)
137 }
138
139 readonly cfg: AjvSchemaCfg
140 private readonly validateFunction: ValidateFunction<T>
141
142 /**
143 * It returns the original object just for convenience.
144 * Reminder: Ajv will MUTATE your object under 2 circumstances:
145 * 1. `useDefaults` option (enabled by default!), which will set missing/empty values that have `default` set in the schema.
146 * 2. `coerceTypes` (false by default).
147 *
148 * Returned object is always the same object (`===`) that was passed, so it is returned just for convenience.
149 */
150 validate(obj: T, opt: AjvValidationOptions = {}): T {
151 const err = this.getValidationError(obj, opt)
152 if (err) throw err
153 return obj
154 }
155
156 getValidationError(obj: T, opt: AjvValidationOptions = {}): AjvValidationError | undefined {
157 if (this.isValid(obj)) return
158
159 const errors = this.validateFunction.errors!
160
161 const {
162 objectId = _isObject(obj) ? (obj['id'] as string) : undefined,
163 objectName = this.cfg.objectName,
164 logErrors = this.cfg.logErrors,
165 separator = this.cfg.separator,
166 } = opt
167 const name = [objectName || 'Object', objectId].filter(Boolean).join('.')
168
169 let message = this.cfg.ajv.errorsText(errors, {
170 dataVar: name,
171 separator,
172 })
173
174 const strValue = inspectAny(obj, { maxLen: 1000 })
175 message = [message, 'Input: ' + strValue].join(separator)
176
177 if (logErrors) {
178 this.cfg.logger.error(errors)
179 }
180
181 return new AjvValidationError(
182 message,
183 _filterNullishValues({
184 errors,
185 userFriendly: true,
186 objectName,
187 objectId,
188 }),
189 )
190 }
191
192 isValid(obj: T): boolean {
193 return this.validateFunction(obj)
194 }
195}