UNPKG

5.16 kBPlain TextView Raw
1/*
2 * Does 2 things:
3 * 1. Validates the value according to Schema passed.
4 * 2. Converts the value (also according to Schema).
5 *
6 * "Converts" mean e.g trims all strings from leading/trailing spaces.
7 */
8
9import { _hb, _isObject, _truncateMiddle } from '@naturalcycles/js-lib'
10import { ValidationError, ValidationOptions } from 'joi'
11import { AnySchemaTyped } from './joi.model'
12import { JoiValidationError, JoiValidationErrorData } from './joi.validation.error'
13
14// todo: consider replacing with Tuple of [error, value]
15export interface JoiValidationResult<T = any> {
16 value: T
17 error?: JoiValidationError
18}
19
20// Strip colors in production (for e.g Sentry reporting)
21// const stripColors = process.env.NODE_ENV === 'production' || !!process.env.GAE_INSTANCE
22// Currently colors do more bad than good, so let's strip them always for now
23const stripColors = true
24
25const defaultOptions: ValidationOptions = {
26 abortEarly: false,
27 convert: true,
28 allowUnknown: true,
29 stripUnknown: {
30 objects: true,
31 // true: it will SILENTLY strip invalid values from arrays. Very dangerous! Can lead to data loss!
32 // false: it will THROW validation error if any of array items is invalid
33 // Q: is it invalid if it has unknown properties?
34 // A: no, unknown properties are just stripped (in both 'false' and 'true' states), array is still valid
35 // Q: will it strip or keep unknown properties in array items?..
36 // A: strip
37 arrays: false, // let's be very careful with that! https://github.com/hapijs/joi/issues/658
38 },
39 presence: 'required',
40 // errors: {
41 // stack: true,
42 // }
43}
44
45/**
46 * Validates with Joi.
47 * Throws JoiValidationError if invalid.
48 * Returns *converted* value.
49 *
50 * If `schema` is undefined - returns value as is.
51 */
52export function validate<IN, OUT = IN>(
53 value: IN,
54 schema?: AnySchemaTyped<IN, OUT>,
55 objectName?: string,
56 options: ValidationOptions = {},
57): OUT {
58 const { value: returnValue, error } = getValidationResult<IN, OUT>(
59 value,
60 schema,
61 objectName,
62 options,
63 )
64
65 if (error) {
66 throw error
67 }
68
69 return returnValue
70}
71
72/**
73 * Validates with Joi.
74 * Returns JoiValidationResult with converted value and error (if any).
75 * Does not throw.
76 *
77 * If `schema` is undefined - returns value as is.
78 */
79export function getValidationResult<IN, OUT = IN>(
80 value: IN,
81 schema?: AnySchemaTyped<IN, OUT>,
82 objectName?: string,
83 options: ValidationOptions = {},
84): JoiValidationResult<OUT> {
85 if (!schema) return { value } as any
86
87 const { value: returnValue, error } = schema.validate(value, {
88 ...defaultOptions,
89 ...options,
90 })
91
92 const vr: JoiValidationResult<OUT> = {
93 value: returnValue as OUT,
94 }
95
96 if (error) {
97 vr.error = createError(value, error, objectName)
98 }
99
100 return vr
101}
102
103/**
104 * Convenience function that returns true if !error.
105 */
106export function isValid<IN, OUT = IN>(value: IN, schema?: AnySchemaTyped<IN, OUT>): boolean {
107 if (!schema) return { value } as any
108
109 const { error } = schema.validate(value, defaultOptions)
110 return !error
111}
112
113export function undefinedIfInvalid<IN, OUT = IN>(
114 value: IN,
115 schema?: AnySchemaTyped<IN, OUT>,
116): OUT | undefined {
117 if (!schema) return { value } as any
118
119 const { value: returnValue, error } = schema.validate(value, defaultOptions)
120
121 return error ? undefined : returnValue
122}
123
124/**
125 * Will do joi-convertation, regardless of error/validity of value.
126 *
127 * @returns converted value
128 */
129export function convert<IN, OUT = IN>(value: IN, schema?: AnySchemaTyped<IN, OUT>): OUT {
130 if (!schema) return value as any
131 const { value: returnValue } = schema.validate(value, defaultOptions)
132 return returnValue
133}
134
135function createError(value: any, err: ValidationError, objectName?: string): JoiValidationError {
136 if (!err) return undefined as any
137 const tokens: string[] = []
138
139 const objectId = _isObject(value) ? (value['id'] as string) : undefined
140
141 if (objectId || objectName) {
142 objectName = objectName || value?.constructor?.name
143
144 tokens.push('Invalid ' + [objectName, objectId].filter(Boolean).join('.'))
145 }
146
147 const annotation = err.annotate(stripColors)
148
149 if (annotation.length > 4000) {
150 // Annotation message is too big and will be replaced by stringified `error.details` instead
151
152 tokens.push(
153 _truncateMiddle(annotation, 4000, `\n... ${_hb(annotation.length)} message truncated ...\n`),
154 )
155
156 // Up to 5 `details`
157 tokens.push(...err.details.slice(0, 5).map(i => `${i.message} @ .${i.path.join('.')}`))
158
159 if (err.details.length > 5) tokens.push(`... ${err.details.length} errors`)
160 } else {
161 tokens.push(annotation)
162 }
163
164 const msg = tokens.join('\n')
165
166 const data: JoiValidationErrorData = {
167 joiValidationErrorItems: err.details,
168 ...(objectName && { joiValidationObjectName: objectName }),
169 ...(objectId && { joiValidationObjectId: objectId }),
170 }
171
172 // Make annotation non-enumerable, to not get it automatically printed,
173 // but still accessible
174 Object.defineProperty(data, 'annotation', {
175 writable: true,
176 configurable: true,
177 enumerable: false,
178 value: annotation,
179 })
180
181 return new JoiValidationError(msg, data)
182}