@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.
@@ -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
- "name": "@carecard/validate",
3
- "version": "3.1.16",
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 && mocha -r ts-node/register test/types.test.ts",
14
- "test:coverage": "tsc --noEmit && export NODE_ENV=test && nyc mocha --recursive -r ts-node/register 'test/**/*.{js,ts}'",
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
- }
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
  }