@idirdev/formguard 1.0.0
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/LICENSE +21 -0
- package/README.md +107 -0
- package/package.json +23 -0
- package/src/index.js +355 -0
- package/src/messages.js +10 -0
- package/src/middleware.js +118 -0
- package/src/react.js +242 -0
- package/src/sanitizers.js +14 -0
- package/src/schema.js +16 -0
- package/src/validator.js +218 -0
- package/src/validators.js +61 -0
- package/tests/formguard.test.js +272 -0
package/src/react.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* React hook for real-time form validation with FormGuard.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { useFormGuard } from '@idirdev/formguard/react';
|
|
8
|
+
*
|
|
9
|
+
* function LoginForm() {
|
|
10
|
+
* const { values, errors, touched, handleChange, handleBlur, handleSubmit, isValid, reset } =
|
|
11
|
+
* useFormGuard({
|
|
12
|
+
* initialValues: { email: '', password: '' },
|
|
13
|
+
* rules: {
|
|
14
|
+
* email: ['required', 'email'],
|
|
15
|
+
* password: ['required', { rule: 'minLength', param: 8 }]
|
|
16
|
+
* },
|
|
17
|
+
* onSubmit: (data) => { console.log(data); }
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* return (
|
|
21
|
+
* <form onSubmit={handleSubmit}>
|
|
22
|
+
* <input name="email" value={values.email} onChange={handleChange} onBlur={handleBlur} />
|
|
23
|
+
* {touched.email && errors.email && <span>{errors.email[0]}</span>}
|
|
24
|
+
* ...
|
|
25
|
+
* </form>
|
|
26
|
+
* );
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
let React;
|
|
31
|
+
try {
|
|
32
|
+
React = require('react');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
// React not available; exports will throw clear errors when called
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { validate } = require('./validator');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Object} config
|
|
41
|
+
* @param {Object} config.initialValues - Initial form values
|
|
42
|
+
* @param {Object} config.rules - Validation rules
|
|
43
|
+
* @param {Function} [config.onSubmit] - Submission handler (receives validated data)
|
|
44
|
+
* @param {string} [config.locale='en'] - Locale for messages
|
|
45
|
+
* @param {Object} [config.messages] - Custom messages
|
|
46
|
+
* @param {Object} [config.labels] - Human-readable field labels
|
|
47
|
+
* @param {boolean} [config.validateOnChange=false] - Validate on every change
|
|
48
|
+
* @param {boolean} [config.validateOnBlur=true] - Validate on blur
|
|
49
|
+
*/
|
|
50
|
+
function useFormGuard(config) {
|
|
51
|
+
if (!React) {
|
|
52
|
+
throw new Error('FormGuard: React is required for useFormGuard. Install react as a dependency.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { useState, useCallback, useRef, useMemo } = React;
|
|
56
|
+
|
|
57
|
+
const {
|
|
58
|
+
initialValues = {},
|
|
59
|
+
rules: validationRules = {},
|
|
60
|
+
onSubmit,
|
|
61
|
+
locale = 'en',
|
|
62
|
+
messages: customMessages,
|
|
63
|
+
labels,
|
|
64
|
+
validateOnChange = false,
|
|
65
|
+
validateOnBlur = true
|
|
66
|
+
} = config;
|
|
67
|
+
|
|
68
|
+
const [values, setValues] = useState({ ...initialValues });
|
|
69
|
+
const [errors, setErrors] = useState({});
|
|
70
|
+
const [touched, setTouched] = useState({});
|
|
71
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
72
|
+
const [submitCount, setSubmitCount] = useState(0);
|
|
73
|
+
|
|
74
|
+
const rulesRef = useRef(validationRules);
|
|
75
|
+
rulesRef.current = validationRules;
|
|
76
|
+
|
|
77
|
+
const validateOptions = useMemo(() => ({
|
|
78
|
+
locale,
|
|
79
|
+
messages: customMessages,
|
|
80
|
+
labels
|
|
81
|
+
}), [locale, customMessages, labels]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate a single field.
|
|
85
|
+
*/
|
|
86
|
+
const validateField = useCallback((fieldName, fieldValue, allValues) => {
|
|
87
|
+
if (!rulesRef.current[fieldName]) return [];
|
|
88
|
+
|
|
89
|
+
const result = validate(
|
|
90
|
+
{ ...allValues, [fieldName]: fieldValue },
|
|
91
|
+
{ [fieldName]: rulesRef.current[fieldName] },
|
|
92
|
+
validateOptions
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return result.errors[fieldName] || [];
|
|
96
|
+
}, [validateOptions]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validate all fields.
|
|
100
|
+
*/
|
|
101
|
+
const validateAll = useCallback((data) => {
|
|
102
|
+
return validate(data || values, rulesRef.current, validateOptions);
|
|
103
|
+
}, [values, validateOptions]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Handle input change events.
|
|
107
|
+
*/
|
|
108
|
+
const handleChange = useCallback((e) => {
|
|
109
|
+
const { name, value, type, checked } = e.target;
|
|
110
|
+
const newValue = type === 'checkbox' ? checked : value;
|
|
111
|
+
|
|
112
|
+
setValues(prev => {
|
|
113
|
+
const next = { ...prev, [name]: newValue };
|
|
114
|
+
|
|
115
|
+
if (validateOnChange || submitCount > 0) {
|
|
116
|
+
const fieldErrors = validateField(name, newValue, next);
|
|
117
|
+
setErrors(prevErrors => {
|
|
118
|
+
const updated = { ...prevErrors };
|
|
119
|
+
if (fieldErrors.length > 0) {
|
|
120
|
+
updated[name] = fieldErrors;
|
|
121
|
+
} else {
|
|
122
|
+
delete updated[name];
|
|
123
|
+
}
|
|
124
|
+
return updated;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return next;
|
|
129
|
+
});
|
|
130
|
+
}, [validateOnChange, validateField, submitCount]);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle blur events (triggers field validation).
|
|
134
|
+
*/
|
|
135
|
+
const handleBlur = useCallback((e) => {
|
|
136
|
+
const { name } = e.target;
|
|
137
|
+
|
|
138
|
+
setTouched(prev => ({ ...prev, [name]: true }));
|
|
139
|
+
|
|
140
|
+
if (validateOnBlur) {
|
|
141
|
+
const fieldErrors = validateField(name, values[name], values);
|
|
142
|
+
setErrors(prev => {
|
|
143
|
+
const updated = { ...prev };
|
|
144
|
+
if (fieldErrors.length > 0) {
|
|
145
|
+
updated[name] = fieldErrors;
|
|
146
|
+
} else {
|
|
147
|
+
delete updated[name];
|
|
148
|
+
}
|
|
149
|
+
return updated;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}, [validateOnBlur, validateField, values]);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Set a single field value programmatically.
|
|
156
|
+
*/
|
|
157
|
+
const setFieldValue = useCallback((name, value) => {
|
|
158
|
+
setValues(prev => ({ ...prev, [name]: value }));
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Set a field error programmatically.
|
|
163
|
+
*/
|
|
164
|
+
const setFieldError = useCallback((name, error) => {
|
|
165
|
+
setErrors(prev => ({
|
|
166
|
+
...prev,
|
|
167
|
+
[name]: Array.isArray(error) ? error : [error]
|
|
168
|
+
}));
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Handle form submission.
|
|
173
|
+
*/
|
|
174
|
+
const handleSubmit = useCallback((e) => {
|
|
175
|
+
if (e && e.preventDefault) e.preventDefault();
|
|
176
|
+
|
|
177
|
+
setSubmitCount(c => c + 1);
|
|
178
|
+
|
|
179
|
+
// Mark all fields as touched
|
|
180
|
+
const allTouched = {};
|
|
181
|
+
for (const key of Object.keys(rulesRef.current)) {
|
|
182
|
+
allTouched[key] = true;
|
|
183
|
+
}
|
|
184
|
+
setTouched(prev => ({ ...prev, ...allTouched }));
|
|
185
|
+
|
|
186
|
+
const result = validateAll(values);
|
|
187
|
+
setErrors(result.errors);
|
|
188
|
+
|
|
189
|
+
if (!result.valid) return;
|
|
190
|
+
|
|
191
|
+
if (onSubmit) {
|
|
192
|
+
setIsSubmitting(true);
|
|
193
|
+
const maybePromise = onSubmit(values);
|
|
194
|
+
if (maybePromise && typeof maybePromise.then === 'function') {
|
|
195
|
+
maybePromise
|
|
196
|
+
.catch(() => {})
|
|
197
|
+
.finally(() => setIsSubmitting(false));
|
|
198
|
+
} else {
|
|
199
|
+
setIsSubmitting(false);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}, [values, validateAll, onSubmit]);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Reset form to initial state.
|
|
206
|
+
*/
|
|
207
|
+
const reset = useCallback((newValues) => {
|
|
208
|
+
setValues(newValues || { ...initialValues });
|
|
209
|
+
setErrors({});
|
|
210
|
+
setTouched({});
|
|
211
|
+
setIsSubmitting(false);
|
|
212
|
+
setSubmitCount(0);
|
|
213
|
+
}, [initialValues]);
|
|
214
|
+
|
|
215
|
+
const isValid = useMemo(() => Object.keys(errors).length === 0, [errors]);
|
|
216
|
+
|
|
217
|
+
const isDirty = useMemo(() => {
|
|
218
|
+
return Object.keys(values).some(key => values[key] !== initialValues[key]);
|
|
219
|
+
}, [values, initialValues]);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
values,
|
|
223
|
+
errors,
|
|
224
|
+
touched,
|
|
225
|
+
isValid,
|
|
226
|
+
isDirty,
|
|
227
|
+
isSubmitting,
|
|
228
|
+
submitCount,
|
|
229
|
+
handleChange,
|
|
230
|
+
handleBlur,
|
|
231
|
+
handleSubmit,
|
|
232
|
+
setFieldValue,
|
|
233
|
+
setFieldError,
|
|
234
|
+
setValues,
|
|
235
|
+
setErrors,
|
|
236
|
+
validateAll,
|
|
237
|
+
validateField,
|
|
238
|
+
reset
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { useFormGuard };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const ENT = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/' };
|
|
3
|
+
function escapeHtml(s) { return String(s).replace(/[&<>"'/]/g, c => ENT[c]); }
|
|
4
|
+
function stripTags(s, a = []) { if (!a.length) return String(s).replace(/<[^>]*>/g, ''); const t = a.map(x => x.toLowerCase()); return String(s).replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g, (m, x) => t.includes(x.toLowerCase()) ? m : ''); }
|
|
5
|
+
function slugify(s) { return String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
|
|
6
|
+
function normalizeEmail(e) { const [l, d] = String(e).trim().toLowerCase().split('@'); if (!l || !d) return e; let n = l.replace(/\+.*$/, ''); if (['gmail.com','googlemail.com'].includes(d)) n = n.replace(/\./g, ''); return n + '@' + d; }
|
|
7
|
+
function trim(s) { return String(s).trim(); }
|
|
8
|
+
function toLowerCase(s) { return String(s).toLowerCase(); }
|
|
9
|
+
function toNumber(s) { const n = Number(s); return isNaN(n) ? 0 : n; }
|
|
10
|
+
function toBoolean(s) { return ['true','1','yes','on'].includes(String(s).toLowerCase()); }
|
|
11
|
+
function truncate(s, l = 100) { s = String(s); return s.length > l ? s.slice(0, l) + '...' : s; }
|
|
12
|
+
const SANITIZERS = { escapeHtml, stripTags, slugify, normalizeEmail, trim, toLowerCase, toNumber, toBoolean, truncate };
|
|
13
|
+
function sanitize(data, rules) { const r = { ...data }; for (const [f, fns] of Object.entries(rules)) { if (r[f] === undefined) continue; const list = typeof fns === 'string' ? fns.split('|') : fns; for (const fn of list) { const s = typeof fn === 'function' ? fn : SANITIZERS[typeof fn === 'string' ? fn.trim() : fn]; if (s) r[f] = s(r[f]); } } return r; }
|
|
14
|
+
module.exports = { sanitize, escapeHtml, stripTags, slugify, normalizeEmail, trim, toLowerCase, toNumber, toBoolean, truncate, SANITIZERS };
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { validate: run } = require('./validators');
|
|
3
|
+
class Schema {
|
|
4
|
+
constructor() { this._f = {}; this._c = null; }
|
|
5
|
+
field(n) { this._c = n; this._f[n] = []; return this; }
|
|
6
|
+
required() { this._f[this._c].push({ name: 'required', params: [] }); return this; }
|
|
7
|
+
email() { this._f[this._c].push({ name: 'email', params: [] }); return this; }
|
|
8
|
+
minLength(n) { this._f[this._c].push({ name: 'minLength', params: [n] }); return this; }
|
|
9
|
+
maxLength(n) { this._f[this._c].push({ name: 'maxLength', params: [n] }); return this; }
|
|
10
|
+
min(n) { this._f[this._c].push({ name: 'min', params: [n] }); return this; }
|
|
11
|
+
max(n) { this._f[this._c].push({ name: 'max', params: [n] }); return this; }
|
|
12
|
+
integer() { this._f[this._c].push({ name: 'integer', params: [] }); return this; }
|
|
13
|
+
strongPassword() { this._f[this._c].push({ name: 'strongPassword', params: [] }); return this; }
|
|
14
|
+
validate(data, opts) { return run(data, this._f, opts); }
|
|
15
|
+
}
|
|
16
|
+
module.exports = { createSchema: () => new Schema(), Schema };
|
package/src/validator.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const builtInRules = require('./rules');
|
|
4
|
+
const { getMessage } = require('./messages');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse a rule definition into a normalized structure.
|
|
8
|
+
* Supports multiple formats:
|
|
9
|
+
* - String: 'required'
|
|
10
|
+
* - String with params: 'minLength:6'
|
|
11
|
+
* - Function: (value) => boolean
|
|
12
|
+
* - Object: { rule: 'email', message: 'Bad email' }
|
|
13
|
+
* - Array: ['required', 'email', { rule: 'minLength', param: 6 }]
|
|
14
|
+
*/
|
|
15
|
+
function parseRuleDefinition(def) {
|
|
16
|
+
if (typeof def === 'string') {
|
|
17
|
+
const [name, ...paramParts] = def.split(':');
|
|
18
|
+
const param = paramParts.length > 0 ? paramParts.join(':') : undefined;
|
|
19
|
+
return [{ name, param }];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof def === 'function') {
|
|
23
|
+
return [{ name: 'custom', fn: def }];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (Array.isArray(def)) {
|
|
27
|
+
const results = [];
|
|
28
|
+
for (const item of def) {
|
|
29
|
+
results.push(...parseRuleDefinition(item));
|
|
30
|
+
}
|
|
31
|
+
return results;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof def === 'object' && def !== null) {
|
|
35
|
+
return [{
|
|
36
|
+
name: def.rule || 'custom',
|
|
37
|
+
param: def.param,
|
|
38
|
+
message: def.message,
|
|
39
|
+
fn: def.fn || def.validate
|
|
40
|
+
}];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a parsed rule definition into an executable validation function.
|
|
48
|
+
*/
|
|
49
|
+
function resolveRule(parsed) {
|
|
50
|
+
if (parsed.fn) {
|
|
51
|
+
const fn = typeof parsed.fn === 'function' ? parsed.fn : null;
|
|
52
|
+
return { validate: fn, name: parsed.name, param: parsed.param, message: parsed.message, needsData: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const rule = builtInRules[parsed.name];
|
|
56
|
+
if (!rule) {
|
|
57
|
+
throw new Error(`FormGuard: Unknown rule "${parsed.name}". Register it or use a custom function.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Rules that take parameters return a function
|
|
61
|
+
const parameterizedRules = ['min', 'max', 'minLength', 'maxLength', 'pattern', 'before', 'after', 'in', 'enum', 'notIn', 'equals', 'confirmed', 'custom'];
|
|
62
|
+
|
|
63
|
+
if (parameterizedRules.includes(parsed.name) && typeof rule === 'function') {
|
|
64
|
+
let param = parsed.param;
|
|
65
|
+
// Auto-parse numeric params for min/max/minLength/maxLength
|
|
66
|
+
if (['min', 'max', 'minLength', 'maxLength'].includes(parsed.name) && typeof param === 'string') {
|
|
67
|
+
param = Number(param);
|
|
68
|
+
}
|
|
69
|
+
// Parse array params for in/notIn/enum
|
|
70
|
+
if (['in', 'notIn', 'enum'].includes(parsed.name) && typeof param === 'string') {
|
|
71
|
+
param = param.split(',').map(s => s.trim());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const validatorFn = rule(param);
|
|
75
|
+
return {
|
|
76
|
+
validate: validatorFn,
|
|
77
|
+
name: parsed.name,
|
|
78
|
+
param: param,
|
|
79
|
+
message: parsed.message,
|
|
80
|
+
needsData: validatorFn._needsData || false
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
validate: rule,
|
|
86
|
+
name: parsed.name,
|
|
87
|
+
param: parsed.param,
|
|
88
|
+
message: parsed.message,
|
|
89
|
+
needsData: rule._needsData || false
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate a data object against a set of rules.
|
|
95
|
+
*
|
|
96
|
+
* @param {Object} data - The data object to validate
|
|
97
|
+
* @param {Object} rules - Validation rules keyed by field name
|
|
98
|
+
* @param {Object} [options] - Options
|
|
99
|
+
* @param {string} [options.locale='en'] - Locale for error messages ('en' or 'fr')
|
|
100
|
+
* @param {Object} [options.messages] - Custom error message overrides
|
|
101
|
+
* @param {boolean} [options.abortEarly=false] - Stop on first error
|
|
102
|
+
* @param {Object} [options.labels] - Human-readable field labels { fieldName: 'Label' }
|
|
103
|
+
* @returns {{ valid: boolean, errors: Object<string, string[]> }}
|
|
104
|
+
*/
|
|
105
|
+
function validate(data, rules, options = {}) {
|
|
106
|
+
const {
|
|
107
|
+
locale = 'en',
|
|
108
|
+
messages: customMessages = {},
|
|
109
|
+
abortEarly = false,
|
|
110
|
+
labels = {}
|
|
111
|
+
} = options;
|
|
112
|
+
|
|
113
|
+
const errors = {};
|
|
114
|
+
const dataObj = data || {};
|
|
115
|
+
|
|
116
|
+
for (const [field, ruleDefs] of Object.entries(rules)) {
|
|
117
|
+
const fieldLabel = labels[field] || field;
|
|
118
|
+
const value = getNestedValue(dataObj, field);
|
|
119
|
+
const parsedRules = parseRuleDefinition(ruleDefs);
|
|
120
|
+
const fieldErrors = [];
|
|
121
|
+
|
|
122
|
+
for (const parsed of parsedRules) {
|
|
123
|
+
const resolved = resolveRule(parsed);
|
|
124
|
+
|
|
125
|
+
if (!resolved.validate) continue;
|
|
126
|
+
|
|
127
|
+
let isValid;
|
|
128
|
+
if (resolved.needsData) {
|
|
129
|
+
isValid = resolved.validate(value, field, dataObj);
|
|
130
|
+
} else {
|
|
131
|
+
isValid = resolved.validate(value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!isValid) {
|
|
135
|
+
const msg = resolved.message ||
|
|
136
|
+
getMessage(resolved.name, fieldLabel, resolved.param, locale, customMessages);
|
|
137
|
+
fieldErrors.push(msg);
|
|
138
|
+
|
|
139
|
+
if (abortEarly) break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (fieldErrors.length > 0) {
|
|
144
|
+
errors[field] = fieldErrors;
|
|
145
|
+
if (abortEarly) break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
valid: Object.keys(errors).length === 0,
|
|
151
|
+
errors
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get a nested value from an object using dot notation.
|
|
157
|
+
* Supports array indexing: 'items.0.name'
|
|
158
|
+
*/
|
|
159
|
+
function getNestedValue(obj, path) {
|
|
160
|
+
if (!path.includes('.')) return obj[path];
|
|
161
|
+
return path.split('.').reduce((current, key) => {
|
|
162
|
+
if (current === undefined || current === null) return undefined;
|
|
163
|
+
return current[key];
|
|
164
|
+
}, obj);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Async version of validate for rules that return promises.
|
|
169
|
+
*/
|
|
170
|
+
async function validateAsync(data, rules, options = {}) {
|
|
171
|
+
const {
|
|
172
|
+
locale = 'en',
|
|
173
|
+
messages: customMessages = {},
|
|
174
|
+
abortEarly = false,
|
|
175
|
+
labels = {}
|
|
176
|
+
} = options;
|
|
177
|
+
|
|
178
|
+
const errors = {};
|
|
179
|
+
const dataObj = data || {};
|
|
180
|
+
|
|
181
|
+
for (const [field, ruleDefs] of Object.entries(rules)) {
|
|
182
|
+
const fieldLabel = labels[field] || field;
|
|
183
|
+
const value = getNestedValue(dataObj, field);
|
|
184
|
+
const parsedRules = parseRuleDefinition(ruleDefs);
|
|
185
|
+
const fieldErrors = [];
|
|
186
|
+
|
|
187
|
+
for (const parsed of parsedRules) {
|
|
188
|
+
const resolved = resolveRule(parsed);
|
|
189
|
+
if (!resolved.validate) continue;
|
|
190
|
+
|
|
191
|
+
let isValid;
|
|
192
|
+
if (resolved.needsData) {
|
|
193
|
+
isValid = await resolved.validate(value, field, dataObj);
|
|
194
|
+
} else {
|
|
195
|
+
isValid = await resolved.validate(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!isValid) {
|
|
199
|
+
const msg = resolved.message ||
|
|
200
|
+
getMessage(resolved.name, fieldLabel, resolved.param, locale, customMessages);
|
|
201
|
+
fieldErrors.push(msg);
|
|
202
|
+
if (abortEarly) break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (fieldErrors.length > 0) {
|
|
207
|
+
errors[field] = fieldErrors;
|
|
208
|
+
if (abortEarly) break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
valid: Object.keys(errors).length === 0,
|
|
214
|
+
errors
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { validate, validateAsync, parseRuleDefinition, resolveRule, getNestedValue };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { getMessage } = require('./messages');
|
|
3
|
+
const RULES = {
|
|
4
|
+
required: (v) => v !== undefined && v !== null && v !== '',
|
|
5
|
+
email: (v) => /^[^@s]+@[^@s]+.[^@s]+$/.test(v),
|
|
6
|
+
url: (v) => { try { new URL(v); return true; } catch { return false; } },
|
|
7
|
+
minLength: (v, n) => typeof v === 'string' && v.length >= n,
|
|
8
|
+
maxLength: (v, n) => typeof v === 'string' && v.length <= n,
|
|
9
|
+
min: (v, n) => Number(v) >= n,
|
|
10
|
+
max: (v, n) => Number(v) <= n,
|
|
11
|
+
integer: (v) => Number.isInteger(Number(v)) && !isNaN(v),
|
|
12
|
+
numeric: (v) => !isNaN(v) && !isNaN(parseFloat(v)),
|
|
13
|
+
alpha: (v) => /^[a-zA-Z]+$/.test(v),
|
|
14
|
+
alphanumeric: (v) => /^[a-zA-Z0-9]+$/.test(v),
|
|
15
|
+
phone: (v) => /^\+?[0-9\s\-().]{7,20}$/.test(v),
|
|
16
|
+
creditCard: (v) => {
|
|
17
|
+
const d = String(v).replace(/\D/g, '');
|
|
18
|
+
if (d.length < 13 || d.length > 19) return false;
|
|
19
|
+
let sum = 0, alt = false;
|
|
20
|
+
for (let i = d.length - 1; i >= 0; i--) {
|
|
21
|
+
let n = parseInt(d[i], 10);
|
|
22
|
+
if (alt) { n *= 2; if (n > 9) n -= 9; }
|
|
23
|
+
sum += n; alt = !alt;
|
|
24
|
+
}
|
|
25
|
+
return sum % 10 === 0;
|
|
26
|
+
},
|
|
27
|
+
uuid: (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v),
|
|
28
|
+
ip: (v) => /^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/.test(v),
|
|
29
|
+
date: (v) => !isNaN(Date.parse(v)),
|
|
30
|
+
slug: (v) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v),
|
|
31
|
+
hex: (v) => /^[0-9a-fA-F]+$/.test(v),
|
|
32
|
+
json: (v) => { try { JSON.parse(v); return true; } catch { return false; } },
|
|
33
|
+
equals: (v, expected) => v === expected,
|
|
34
|
+
oneOf: (v, list) => (Array.isArray(list) ? list : list.split(',')).includes(v),
|
|
35
|
+
matches: (v, pattern) => new RegExp(pattern).test(v),
|
|
36
|
+
strongPassword: (v) => typeof v === 'string' && v.length >= 8 && /[a-z]/.test(v) && /[A-Z]/.test(v) && /[0-9]/.test(v) && /[^a-zA-Z0-9]/.test(v),
|
|
37
|
+
};
|
|
38
|
+
function validate(data, rules, opts = {}) {
|
|
39
|
+
const errors = {};
|
|
40
|
+
for (const [field, fr] of Object.entries(rules)) {
|
|
41
|
+
const value = field.split('.').reduce((o, k) => o && o[k], data);
|
|
42
|
+
const rl = typeof fr === 'string' ? fr.split('|').map(parseRule) : fr;
|
|
43
|
+
const fe = validateField(value, rl, { ...opts, field });
|
|
44
|
+
if (fe.length > 0) { errors[field] = fe; if (opts.abortEarly) break; }
|
|
45
|
+
}
|
|
46
|
+
return { valid: Object.keys(errors).length === 0, errors };
|
|
47
|
+
}
|
|
48
|
+
function validateField(value, rules, opts = {}) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
const rl = typeof rules === 'string' ? rules.split('|').map(parseRule) : rules;
|
|
51
|
+
for (const rule of rl) {
|
|
52
|
+
const { name, params } = typeof rule === 'string' ? parseRule(rule) : rule;
|
|
53
|
+
if (name === 'required') { if (!RULES.required(value)) errors.push(getMessage(name, opts.field, params)); continue; }
|
|
54
|
+
if (value === undefined || value === null || value === '') continue;
|
|
55
|
+
const v = RULES[name];
|
|
56
|
+
if (v && !v(value, ...params)) errors.push(getMessage(name, opts.field, params));
|
|
57
|
+
}
|
|
58
|
+
return errors;
|
|
59
|
+
}
|
|
60
|
+
function parseRule(s) { const [n, p] = s.split(':'); return { name: n.trim(), params: p ? p.split(',').map(x => isNaN(x) ? x.trim() : Number(x.trim())) : [] }; }
|
|
61
|
+
module.exports = { validate, validateField, RULES };
|