@carecard/validate 3.1.16 → 3.1.18
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/.github/workflows/auto-draft-pr.yml +114 -0
- package/.github/workflows/ci.yml +15 -15
- package/.github/workflows/publish.yml +24 -24
- package/.prettierrc.js +11 -10
- package/eslint.config.mjs +8 -8
- package/index.d.ts +76 -30
- package/index.js +6 -4
- package/lib/validate.js +131 -131
- package/lib/validateProperties.js +218 -218
- package/lib/validateWhitelistProperties.js +285 -0
- package/package.json +41 -38
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
error: { throwBadInputError },
|
|
5
|
+
caseConverter: { keysToSnakeCase },
|
|
6
|
+
} = require('@carecard/common-util');
|
|
7
|
+
|
|
8
|
+
const { validateProperties } = require('./validateProperties');
|
|
9
|
+
|
|
10
|
+
/** Maximum supported nesting depth for dot-notation property paths. */
|
|
11
|
+
const MAX_NESTING_DEPTH = 5;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maximum number of property paths (required + optional combined) that can
|
|
15
|
+
* be validated in a single call. Guards against pathologically large or
|
|
16
|
+
* adversarial inputs.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_KEYS_PER_CALL = 5000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns true if the segment contains a mix of snake_case (underscore) and
|
|
22
|
+
* camelCase (uppercase letter), e.g. `my_mixName`. Such names are ambiguous
|
|
23
|
+
* and are not supported.
|
|
24
|
+
* @param {string} segment
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
function isMixedCaseSegment(segment) {
|
|
28
|
+
return /_/.test(segment) && /[A-Z]/.test(segment);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Converts a snake_case identifier to camelCase. Leaves identifiers without
|
|
33
|
+
* underscores unchanged.
|
|
34
|
+
* @param {string} s
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function snakeToCamel(s) {
|
|
38
|
+
return s.replace(/_([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Converts a camelCase identifier to snake_case. Leaves identifiers without
|
|
43
|
+
* uppercase letters unchanged.
|
|
44
|
+
* @param {string} s
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function camelToSnake(s) {
|
|
48
|
+
return s.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns the alternate-case form of the segment:
|
|
53
|
+
* - snake_case -> camelCase
|
|
54
|
+
* - camelCase -> snake_case
|
|
55
|
+
* - otherwise (no `_` and no uppercase) the segment itself.
|
|
56
|
+
* @param {string} segment
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function alternateCase(segment) {
|
|
60
|
+
if (segment.indexOf('_') !== -1) return snakeToCamel(segment);
|
|
61
|
+
if (/[A-Z]/.test(segment)) return camelToSnake(segment);
|
|
62
|
+
return segment;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Splits a dot-notation property path into its segments and validates depth
|
|
67
|
+
* and per-segment casing (mixed snake/camel segments are rejected).
|
|
68
|
+
* @param {string} path
|
|
69
|
+
* @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
function splitPath(path) {
|
|
72
|
+
const segments = String(path).split('.');
|
|
73
|
+
if (segments.length > MAX_NESTING_DEPTH) {
|
|
74
|
+
throwBadInputError({
|
|
75
|
+
userMessage: `Property path "${path}" exceeds maximum nesting depth of ${MAX_NESTING_DEPTH}`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
for (const seg of segments) {
|
|
79
|
+
if (isMixedCaseSegment(seg)) {
|
|
80
|
+
throwBadInputError({
|
|
81
|
+
userMessage: `Property path "${path}" has a segment "${seg}" mixing snake_case and camelCase`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return segments;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reads the leaf value at `path` from `obj`. Returns `{ found, value }`.
|
|
90
|
+
* `found` is true only if every intermediate node exists and the final
|
|
91
|
+
* `hasOwnProperty(leaf)` check passes.
|
|
92
|
+
*
|
|
93
|
+
* @param {*} obj
|
|
94
|
+
* @param {string[]} segments
|
|
95
|
+
* @returns {{ found: boolean, value: any }}
|
|
96
|
+
*/
|
|
97
|
+
function readLeaf(obj, segments) {
|
|
98
|
+
// Resolve a segment against the current node, trying its as-written form
|
|
99
|
+
// first and then its alternate snake/camel form. Returns the actual key
|
|
100
|
+
// present in the node, or undefined if neither form exists.
|
|
101
|
+
function resolveKey(node, seg) {
|
|
102
|
+
if (Object.prototype.hasOwnProperty.call(node, seg)) return seg;
|
|
103
|
+
const alt = alternateCase(seg);
|
|
104
|
+
if (alt !== seg && Object.prototype.hasOwnProperty.call(node, alt)) return alt;
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let current = obj;
|
|
109
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
110
|
+
if (current === null || typeof current !== 'object') return { found: false, value: undefined };
|
|
111
|
+
const key = resolveKey(current, segments[i]);
|
|
112
|
+
if (key === undefined) return { found: false, value: undefined };
|
|
113
|
+
current = current[key];
|
|
114
|
+
}
|
|
115
|
+
if (current === null || typeof current !== 'object') return { found: false, value: undefined };
|
|
116
|
+
const leafKey = resolveKey(current, segments[segments.length - 1]);
|
|
117
|
+
if (leafKey === undefined) return { found: false, value: undefined };
|
|
118
|
+
return { found: true, value: current[leafKey] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Writes `value` into `target` at the nested location described by `segments`,
|
|
123
|
+
* creating intermediate plain-object nodes as needed.
|
|
124
|
+
*
|
|
125
|
+
* @param {Object} target
|
|
126
|
+
* @param {string[]} segments
|
|
127
|
+
* @param {*} value
|
|
128
|
+
*/
|
|
129
|
+
function writeLeaf(target, segments, value) {
|
|
130
|
+
let current = target;
|
|
131
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
132
|
+
const key = segments[i];
|
|
133
|
+
if (current[key] === null || typeof current[key] !== 'object') {
|
|
134
|
+
current[key] = {};
|
|
135
|
+
}
|
|
136
|
+
current = current[key];
|
|
137
|
+
}
|
|
138
|
+
current[segments[segments.length - 1]] = value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Recursively flattens a nested plain object so every leaf becomes a top-level
|
|
143
|
+
* key. Keys are joined by `.`; the original nested shape is discarded.
|
|
144
|
+
*
|
|
145
|
+
* Example: `{ a: { b: { c: 1 } }, d: 2 }` => `{ 'a.b.c': 1, d: 2 }`.
|
|
146
|
+
*
|
|
147
|
+
* @param {Object} obj
|
|
148
|
+
* @param {string} [prefix]
|
|
149
|
+
* @param {Object} [out]
|
|
150
|
+
* @returns {Object}
|
|
151
|
+
*/
|
|
152
|
+
function flattenObject(obj, prefix = '', out = {}) {
|
|
153
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
154
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
155
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
156
|
+
flattenObject(value, path, out);
|
|
157
|
+
} else {
|
|
158
|
+
out[path] = value;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Validates and transforms whitelisted properties from an input object.
|
|
166
|
+
*
|
|
167
|
+
* Supports nested objects via dot-notation paths (e.g. `'address.city'`),
|
|
168
|
+
* up to {@link MAX_NESTING_DEPTH} levels deep. For each whitelisted path the
|
|
169
|
+
* function checks that the leaf property exists, validates the leaf value
|
|
170
|
+
* using {@link validateProperties} (keyed by the leaf segment), and rebuilds
|
|
171
|
+
* the same nested shape in the returned object.
|
|
172
|
+
*
|
|
173
|
+
* Steps:
|
|
174
|
+
* 1. Extracts only the whitelisted (required + optional) leaf properties.
|
|
175
|
+
* 2. Validates leaf values using {@link validateProperties}.
|
|
176
|
+
* 3. Asserts every required leaf is present and valid; throws otherwise.
|
|
177
|
+
* 4. Asserts any provided optional leaf is valid; throws otherwise.
|
|
178
|
+
*
|
|
179
|
+
* Array values: a leaf value may be an array; in that case the per-leaf
|
|
180
|
+
* validator is applied to each element. The leaf is accepted only when every
|
|
181
|
+
* element passes validation, and the returned value is an array of the
|
|
182
|
+
* validated elements (in the same order).
|
|
183
|
+
* 5. Optionally converts all keys (including nested) to snake_case.
|
|
184
|
+
* 6. Optionally flattens the result so every leaf is a top-level key,
|
|
185
|
+
* joined by `.` (`flattenOutput`). Applied after snake_case conversion.
|
|
186
|
+
*
|
|
187
|
+
* @param {Object} inputObject - The input object (e.g., req.body / req.params).
|
|
188
|
+
* @param {Array<string>} [requiredProperties=[]] - Leaf paths that MUST be present and valid.
|
|
189
|
+
* @param {Object} [options]
|
|
190
|
+
* @param {Array<string>} [options.optionalProperties=[]] - Leaf paths allowed but not required.
|
|
191
|
+
* @param {boolean} [options.convertToSnakeCase=false] - Whether to convert keys to snake_case.
|
|
192
|
+
* @param {boolean} [options.flattenOutput=false] - Whether to flatten the result so that
|
|
193
|
+
* every leaf is a top-level key (joined by `.`), with no nested objects in the output.
|
|
194
|
+
* @returns {Promise<Object>} Resolves with the validated (and possibly transformed) object.
|
|
195
|
+
*/
|
|
196
|
+
function validateWhitelistProperties(
|
|
197
|
+
inputObject,
|
|
198
|
+
requiredProperties = [],
|
|
199
|
+
options = { optionalProperties: [], convertToSnakeCase: false, flattenOutput: false },
|
|
200
|
+
) {
|
|
201
|
+
const optionalProperties = (options && options.optionalProperties) || [];
|
|
202
|
+
const convertToSnakeCase = !!(options && options.convertToSnakeCase);
|
|
203
|
+
const flattenOutput = !!(options && options.flattenOutput);
|
|
204
|
+
|
|
205
|
+
// Cap the total number of paths to validate per call.
|
|
206
|
+
const totalKeys = (requiredProperties ? requiredProperties.length : 0) + optionalProperties.length;
|
|
207
|
+
if (totalKeys > MAX_KEYS_PER_CALL) {
|
|
208
|
+
throwBadInputError({
|
|
209
|
+
userMessage: `Too many properties to validate: ${totalKeys} (maximum ${MAX_KEYS_PER_CALL})`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const requiredPaths = (requiredProperties || []).map(p => ({ raw: p, segments: splitPath(p) }));
|
|
214
|
+
const optionalPaths = optionalProperties.map(p => ({ raw: p, segments: splitPath(p) }));
|
|
215
|
+
|
|
216
|
+
let validatedObject = {};
|
|
217
|
+
|
|
218
|
+
// Helper: validate a single leaf value by feeding `{ [leafKey]: value }` to
|
|
219
|
+
// `validateProperties` and checking whether the leaf key survived.
|
|
220
|
+
//
|
|
221
|
+
// If `value` is an array, the same per-element validation is applied to
|
|
222
|
+
// every element; the result is an array of validated element values. The
|
|
223
|
+
// leaf is considered valid only when every element passes validation.
|
|
224
|
+
function validateLeafValue(leafKey, value) {
|
|
225
|
+
if (Array.isArray(value)) {
|
|
226
|
+
const validatedArray = [];
|
|
227
|
+
for (const element of value) {
|
|
228
|
+
const out = validateProperties({ [leafKey]: element });
|
|
229
|
+
if (!Object.prototype.hasOwnProperty.call(out, leafKey)) {
|
|
230
|
+
return { valid: false, value: undefined };
|
|
231
|
+
}
|
|
232
|
+
validatedArray.push(out[leafKey]);
|
|
233
|
+
}
|
|
234
|
+
return { valid: true, value: validatedArray };
|
|
235
|
+
}
|
|
236
|
+
const out = validateProperties({ [leafKey]: value });
|
|
237
|
+
if (Object.prototype.hasOwnProperty.call(out, leafKey)) {
|
|
238
|
+
return { valid: true, value: out[leafKey] };
|
|
239
|
+
}
|
|
240
|
+
return { valid: false, value: undefined };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 1 + 3. Required paths must exist and be valid.
|
|
244
|
+
requiredPaths.forEach(({ raw, segments }) => {
|
|
245
|
+
const { found, value } = readLeaf(inputObject, segments);
|
|
246
|
+
if (!found) {
|
|
247
|
+
throwBadInputError({ userMessage: `Missing or invalid property: ${raw}` });
|
|
248
|
+
}
|
|
249
|
+
const leafKey = segments[segments.length - 1];
|
|
250
|
+
const { valid, value: validatedValue } = validateLeafValue(leafKey, value);
|
|
251
|
+
if (!valid) {
|
|
252
|
+
throwBadInputError({ userMessage: `Missing or invalid property: ${raw}` });
|
|
253
|
+
}
|
|
254
|
+
writeLeaf(validatedObject, segments, validatedValue);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// 1 + 4. Optional paths: if provided, must be valid.
|
|
258
|
+
optionalPaths.forEach(({ raw, segments }) => {
|
|
259
|
+
const { found, value } = readLeaf(inputObject, segments);
|
|
260
|
+
if (!found) return;
|
|
261
|
+
const leafKey = segments[segments.length - 1];
|
|
262
|
+
const { valid, value: validatedValue } = validateLeafValue(leafKey, value);
|
|
263
|
+
if (!valid) {
|
|
264
|
+
throwBadInputError({ userMessage: `Invalid property value: ${raw}` });
|
|
265
|
+
}
|
|
266
|
+
writeLeaf(validatedObject, segments, validatedValue);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// 5. Optional case transformation (recursive, handles nested keys).
|
|
270
|
+
if (convertToSnakeCase) {
|
|
271
|
+
validatedObject = keysToSnakeCase(validatedObject);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 6. Optional flattening: produce a flat object with dot-joined keys.
|
|
275
|
+
if (flattenOutput) {
|
|
276
|
+
validatedObject = flattenObject(validatedObject);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return Promise.resolve(validatedObject);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = validateWhitelistProperties;
|
|
283
|
+
module.exports.validateWhitelistProperties = validateWhitelistProperties;
|
|
284
|
+
module.exports.MAX_NESTING_DEPTH = MAX_NESTING_DEPTH;
|
|
285
|
+
module.exports.MAX_KEYS_PER_CALL = MAX_KEYS_PER_CALL;
|
package/package.json
CHANGED
|
@@ -1,40 +1,43 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
2
|
+
"name": "@carecard/validate",
|
|
3
|
+
"version": "3.1.18",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/CareCard-ca/pkg-validate.git"
|
|
7
|
+
},
|
|
8
|
+
"description": "Validate data",
|
|
9
|
+
"main": "index.js",
|
|
10
|
+
"types": "index.d.ts",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "mocha --recursive",
|
|
13
|
+
"test:types": "tsc --noEmit && echo \"\\n ✔ Type tests passed: tsc --noEmit reported 0 errors across index.d.ts and test/**/*.ts\\n\"",
|
|
14
|
+
"test:coverage": "tsc --noEmit && export NODE_ENV=test && nyc mocha --recursive",
|
|
15
|
+
"test:All": "npm run test && npm run test:types",
|
|
16
|
+
"format": "prettier --write .",
|
|
17
|
+
"format:check": "prettier --check .",
|
|
18
|
+
"prepare": "husky",
|
|
19
|
+
"lint:fix": "eslint --fix",
|
|
20
|
+
"lint": "eslint"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"validate",
|
|
24
|
+
"data"
|
|
25
|
+
],
|
|
26
|
+
"author": "CareCard team",
|
|
27
|
+
"license": "ISC",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/mocha": "10.0.10",
|
|
30
|
+
"@types/node": "25.6.2",
|
|
31
|
+
"eslint": "9.39.4",
|
|
32
|
+
"husky": "9.1.7",
|
|
33
|
+
"lint-staged": "17.0.3",
|
|
34
|
+
"mocha": "11.7.5",
|
|
35
|
+
"nyc": "18.0.0",
|
|
36
|
+
"prettier": "3.8.3",
|
|
37
|
+
"ts-node": "10.9.2",
|
|
38
|
+
"typescript": "5.9.3"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@carecard/common-util": "^3.1.13"
|
|
42
|
+
}
|
|
40
43
|
}
|