@fogpipe/forma-core 0.6.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.
@@ -0,0 +1,647 @@
1
+ /**
2
+ * Validation Engine
3
+ *
4
+ * Validates form data against Forma rules including:
5
+ * - JSON Schema type validation
6
+ * - Required field validation (with conditional requiredWhen)
7
+ * - Custom FEEL validation rules
8
+ * - Array item validation
9
+ */
10
+
11
+ import { evaluateBoolean } from "../feel/index.js";
12
+ import type {
13
+ Forma,
14
+ FieldDefinition,
15
+ ValidationRule,
16
+ EvaluationContext,
17
+ ValidationResult,
18
+ FieldError,
19
+ JSONSchemaProperty,
20
+ } from "../types.js";
21
+ import { calculate } from "./calculate.js";
22
+ import { getVisibility } from "./visibility.js";
23
+ import { isFieldRequired } from "./required.js";
24
+
25
+ // ============================================================================
26
+ // Types
27
+ // ============================================================================
28
+
29
+ export interface ValidateOptions {
30
+ /** Pre-calculated computed values */
31
+ computed?: Record<string, unknown>;
32
+ /** Pre-calculated visibility */
33
+ visibility?: Record<string, boolean>;
34
+ /** Only validate visible fields (default: true) */
35
+ onlyVisible?: boolean;
36
+ }
37
+
38
+ // ============================================================================
39
+ // Main Function
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Validate form data against a Forma
44
+ *
45
+ * Performs comprehensive validation including:
46
+ * - Required field checks (respecting conditional requiredWhen)
47
+ * - JSON Schema type validation
48
+ * - Custom FEEL validation rules
49
+ * - Array min/max items validation
50
+ * - Array item field validation
51
+ *
52
+ * By default, only visible fields are validated.
53
+ *
54
+ * @param data - Current form data
55
+ * @param spec - Form specification
56
+ * @param options - Validation options
57
+ * @returns Validation result with valid flag and errors array
58
+ *
59
+ * @example
60
+ * const result = validate(
61
+ * { name: "", age: 15 },
62
+ * forma
63
+ * );
64
+ * // => {
65
+ * // valid: false,
66
+ * // errors: [
67
+ * // { field: "name", message: "Name is required", severity: "error" },
68
+ * // { field: "age", message: "Must be 18 or older", severity: "error" }
69
+ * // ]
70
+ * // }
71
+ */
72
+ export function validate(
73
+ data: Record<string, unknown>,
74
+ spec: Forma,
75
+ options: ValidateOptions = {}
76
+ ): ValidationResult {
77
+ const { onlyVisible = true } = options;
78
+
79
+ // Calculate computed values
80
+ const computed = options.computed ?? calculate(data, spec);
81
+
82
+ // Calculate visibility
83
+ const visibility = options.visibility ?? getVisibility(data, spec, { computed });
84
+
85
+ // Collect errors
86
+ const errors: FieldError[] = [];
87
+
88
+ // Validate each field
89
+ for (const fieldPath of spec.fieldOrder) {
90
+ const fieldDef = spec.fields[fieldPath];
91
+ if (!fieldDef) continue;
92
+
93
+ // Skip hidden fields if onlyVisible is true
94
+ if (onlyVisible && visibility[fieldPath] === false) {
95
+ continue;
96
+ }
97
+
98
+ // Get schema property for type validation
99
+ const schemaProperty = spec.schema.properties[fieldPath];
100
+
101
+ // Validate this field
102
+ const fieldErrors = validateField(
103
+ fieldPath,
104
+ data[fieldPath],
105
+ fieldDef,
106
+ schemaProperty,
107
+ spec,
108
+ data,
109
+ computed,
110
+ visibility,
111
+ onlyVisible
112
+ );
113
+
114
+ errors.push(...fieldErrors);
115
+ }
116
+
117
+ return {
118
+ valid: errors.filter((e) => e.severity === "error").length === 0,
119
+ errors,
120
+ };
121
+ }
122
+
123
+ // ============================================================================
124
+ // Field Validation
125
+ // ============================================================================
126
+
127
+ /**
128
+ * Validate a single field and its nested fields
129
+ */
130
+ function validateField(
131
+ path: string,
132
+ value: unknown,
133
+ fieldDef: FieldDefinition,
134
+ schemaProperty: JSONSchemaProperty | undefined,
135
+ spec: Forma,
136
+ data: Record<string, unknown>,
137
+ computed: Record<string, unknown>,
138
+ visibility: Record<string, boolean>,
139
+ onlyVisible: boolean
140
+ ): FieldError[] {
141
+ const errors: FieldError[] = [];
142
+ const context: EvaluationContext = {
143
+ data,
144
+ computed,
145
+ referenceData: spec.referenceData,
146
+ value,
147
+ };
148
+
149
+ // 1. Required validation
150
+ const required = isFieldRequired(path, fieldDef, spec, context);
151
+ if (required && isEmpty(value)) {
152
+ errors.push({
153
+ field: path,
154
+ message: fieldDef.label
155
+ ? `${fieldDef.label} is required`
156
+ : "This field is required",
157
+ severity: "error",
158
+ });
159
+ }
160
+
161
+ // 2. Type validation (only if value is present)
162
+ if (!isEmpty(value) && schemaProperty) {
163
+ const typeError = validateType(path, value, schemaProperty, fieldDef);
164
+ if (typeError) {
165
+ errors.push(typeError);
166
+ }
167
+ }
168
+
169
+ // 3. Custom FEEL validation rules
170
+ if (fieldDef.validations && !isEmpty(value)) {
171
+ const customErrors = validateCustomRules(path, fieldDef.validations, context);
172
+ errors.push(...customErrors);
173
+ }
174
+
175
+ // 4. Array validation
176
+ if (Array.isArray(value) && fieldDef.itemFields) {
177
+ const arrayErrors = validateArray(
178
+ path,
179
+ value,
180
+ fieldDef,
181
+ spec,
182
+ data,
183
+ computed,
184
+ visibility,
185
+ onlyVisible
186
+ );
187
+ errors.push(...arrayErrors);
188
+ }
189
+
190
+ return errors;
191
+ }
192
+
193
+ /**
194
+ * Check if a value is empty
195
+ */
196
+ function isEmpty(value: unknown): boolean {
197
+ if (value === null || value === undefined) return true;
198
+ if (typeof value === "string" && value.trim() === "") return true;
199
+ if (Array.isArray(value) && value.length === 0) return true;
200
+ return false;
201
+ }
202
+
203
+ // ============================================================================
204
+ // Type Validation
205
+ // ============================================================================
206
+
207
+ /**
208
+ * Validate value against JSON Schema type
209
+ */
210
+ function validateType(
211
+ path: string,
212
+ value: unknown,
213
+ schema: JSONSchemaProperty,
214
+ fieldDef: FieldDefinition
215
+ ): FieldError | null {
216
+ const label = fieldDef.label ?? path;
217
+
218
+ switch (schema.type) {
219
+ case "string": {
220
+ if (typeof value !== "string") {
221
+ return {
222
+ field: path,
223
+ message: `${label} must be a string`,
224
+ severity: "error",
225
+ };
226
+ }
227
+
228
+ // String-specific validations
229
+ if ("minLength" in schema && schema.minLength !== undefined) {
230
+ if (value.length < schema.minLength) {
231
+ return {
232
+ field: path,
233
+ message: `${label} must be at least ${schema.minLength} characters`,
234
+ severity: "error",
235
+ };
236
+ }
237
+ }
238
+
239
+ if ("maxLength" in schema && schema.maxLength !== undefined) {
240
+ if (value.length > schema.maxLength) {
241
+ return {
242
+ field: path,
243
+ message: `${label} must be no more than ${schema.maxLength} characters`,
244
+ severity: "error",
245
+ };
246
+ }
247
+ }
248
+
249
+ if ("pattern" in schema && schema.pattern) {
250
+ const regex = new RegExp(schema.pattern);
251
+ if (!regex.test(value)) {
252
+ return {
253
+ field: path,
254
+ message: `${label} format is invalid`,
255
+ severity: "error",
256
+ };
257
+ }
258
+ }
259
+
260
+ if ("enum" in schema && schema.enum) {
261
+ if (!schema.enum.includes(value)) {
262
+ return {
263
+ field: path,
264
+ message: `${label} must be one of: ${schema.enum.join(", ")}`,
265
+ severity: "error",
266
+ };
267
+ }
268
+ }
269
+
270
+ if ("format" in schema && schema.format) {
271
+ const formatError = validateFormat(path, value, schema.format, label);
272
+ if (formatError) return formatError;
273
+ }
274
+
275
+ return null;
276
+ }
277
+
278
+ case "number":
279
+ case "integer": {
280
+ if (typeof value !== "number") {
281
+ return {
282
+ field: path,
283
+ message: `${label} must be a number`,
284
+ severity: "error",
285
+ };
286
+ }
287
+
288
+ if (schema.type === "integer" && !Number.isInteger(value)) {
289
+ return {
290
+ field: path,
291
+ message: `${label} must be a whole number`,
292
+ severity: "error",
293
+ };
294
+ }
295
+
296
+ if ("minimum" in schema && schema.minimum !== undefined) {
297
+ if (value < schema.minimum) {
298
+ return {
299
+ field: path,
300
+ message: `${label} must be at least ${schema.minimum}`,
301
+ severity: "error",
302
+ };
303
+ }
304
+ }
305
+
306
+ if ("maximum" in schema && schema.maximum !== undefined) {
307
+ if (value > schema.maximum) {
308
+ return {
309
+ field: path,
310
+ message: `${label} must be no more than ${schema.maximum}`,
311
+ severity: "error",
312
+ };
313
+ }
314
+ }
315
+
316
+ if ("exclusiveMinimum" in schema && schema.exclusiveMinimum !== undefined) {
317
+ if (value <= schema.exclusiveMinimum) {
318
+ return {
319
+ field: path,
320
+ message: `${label} must be greater than ${schema.exclusiveMinimum}`,
321
+ severity: "error",
322
+ };
323
+ }
324
+ }
325
+
326
+ if ("exclusiveMaximum" in schema && schema.exclusiveMaximum !== undefined) {
327
+ if (value >= schema.exclusiveMaximum) {
328
+ return {
329
+ field: path,
330
+ message: `${label} must be less than ${schema.exclusiveMaximum}`,
331
+ severity: "error",
332
+ };
333
+ }
334
+ }
335
+
336
+ return null;
337
+ }
338
+
339
+ case "boolean": {
340
+ if (typeof value !== "boolean") {
341
+ return {
342
+ field: path,
343
+ message: `${label} must be true or false`,
344
+ severity: "error",
345
+ };
346
+ }
347
+ return null;
348
+ }
349
+
350
+ case "array": {
351
+ if (!Array.isArray(value)) {
352
+ return {
353
+ field: path,
354
+ message: `${label} must be a list`,
355
+ severity: "error",
356
+ };
357
+ }
358
+ return null;
359
+ }
360
+
361
+ case "object": {
362
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
363
+ return {
364
+ field: path,
365
+ message: `${label} must be an object`,
366
+ severity: "error",
367
+ };
368
+ }
369
+ return null;
370
+ }
371
+
372
+ default:
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Validate string format
379
+ */
380
+ function validateFormat(
381
+ path: string,
382
+ value: string,
383
+ format: string,
384
+ label: string
385
+ ): FieldError | null {
386
+ switch (format) {
387
+ case "email": {
388
+ // Simple email regex
389
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
390
+ if (!emailRegex.test(value)) {
391
+ return {
392
+ field: path,
393
+ message: `${label} must be a valid email address`,
394
+ severity: "error",
395
+ };
396
+ }
397
+ return null;
398
+ }
399
+
400
+ case "date": {
401
+ // ISO date format YYYY-MM-DD
402
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
403
+ if (!dateRegex.test(value)) {
404
+ return {
405
+ field: path,
406
+ message: `${label} must be a valid date`,
407
+ severity: "error",
408
+ };
409
+ }
410
+ // Verify the date is actually valid (e.g., not Feb 30)
411
+ const parsed = new Date(value + "T00:00:00Z");
412
+ if (isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
413
+ return {
414
+ field: path,
415
+ message: `${label} must be a valid date`,
416
+ severity: "error",
417
+ };
418
+ }
419
+ return null;
420
+ }
421
+
422
+ case "date-time": {
423
+ if (isNaN(Date.parse(value))) {
424
+ return {
425
+ field: path,
426
+ message: `${label} must be a valid date and time`,
427
+ severity: "error",
428
+ };
429
+ }
430
+ return null;
431
+ }
432
+
433
+ case "uri": {
434
+ try {
435
+ new URL(value);
436
+ return null;
437
+ } catch {
438
+ return {
439
+ field: path,
440
+ message: `${label} must be a valid URL`,
441
+ severity: "error",
442
+ };
443
+ }
444
+ }
445
+
446
+ case "uuid": {
447
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
448
+ if (!uuidRegex.test(value)) {
449
+ return {
450
+ field: path,
451
+ message: `${label} must be a valid UUID`,
452
+ severity: "error",
453
+ };
454
+ }
455
+ return null;
456
+ }
457
+
458
+ default:
459
+ return null;
460
+ }
461
+ }
462
+
463
+ // ============================================================================
464
+ // Custom Rule Validation
465
+ // ============================================================================
466
+
467
+ /**
468
+ * Validate custom FEEL validation rules
469
+ */
470
+ function validateCustomRules(
471
+ path: string,
472
+ rules: ValidationRule[],
473
+ context: EvaluationContext
474
+ ): FieldError[] {
475
+ const errors: FieldError[] = [];
476
+
477
+ for (const rule of rules) {
478
+ const isValid = evaluateBoolean(rule.rule, context);
479
+
480
+ if (!isValid) {
481
+ errors.push({
482
+ field: path,
483
+ message: rule.message,
484
+ severity: rule.severity ?? "error",
485
+ });
486
+ }
487
+ }
488
+
489
+ return errors;
490
+ }
491
+
492
+ // ============================================================================
493
+ // Array Validation
494
+ // ============================================================================
495
+
496
+ /**
497
+ * Validate array field including items
498
+ */
499
+ function validateArray(
500
+ path: string,
501
+ value: unknown[],
502
+ fieldDef: FieldDefinition,
503
+ spec: Forma,
504
+ data: Record<string, unknown>,
505
+ computed: Record<string, unknown>,
506
+ visibility: Record<string, boolean>,
507
+ onlyVisible: boolean
508
+ ): FieldError[] {
509
+ const errors: FieldError[] = [];
510
+ const label = fieldDef.label ?? path;
511
+
512
+ // Check min/max items
513
+ if (fieldDef.minItems !== undefined && value.length < fieldDef.minItems) {
514
+ errors.push({
515
+ field: path,
516
+ message: `${label} must have at least ${fieldDef.minItems} items`,
517
+ severity: "error",
518
+ });
519
+ }
520
+
521
+ if (fieldDef.maxItems !== undefined && value.length > fieldDef.maxItems) {
522
+ errors.push({
523
+ field: path,
524
+ message: `${label} must have no more than ${fieldDef.maxItems} items`,
525
+ severity: "error",
526
+ });
527
+ }
528
+
529
+ // Validate each item's fields
530
+ if (fieldDef.itemFields) {
531
+ for (let i = 0; i < value.length; i++) {
532
+ const item = value[i] as Record<string, unknown>;
533
+ const itemErrors = validateArrayItem(
534
+ path,
535
+ i,
536
+ item,
537
+ fieldDef.itemFields,
538
+ spec,
539
+ data,
540
+ computed,
541
+ visibility,
542
+ onlyVisible
543
+ );
544
+ errors.push(...itemErrors);
545
+ }
546
+ }
547
+
548
+ return errors;
549
+ }
550
+
551
+ /**
552
+ * Validate fields within a single array item
553
+ */
554
+ function validateArrayItem(
555
+ arrayPath: string,
556
+ index: number,
557
+ item: Record<string, unknown>,
558
+ itemFields: Record<string, FieldDefinition>,
559
+ spec: Forma,
560
+ data: Record<string, unknown>,
561
+ computed: Record<string, unknown>,
562
+ visibility: Record<string, boolean>,
563
+ onlyVisible: boolean
564
+ ): FieldError[] {
565
+ const errors: FieldError[] = [];
566
+
567
+ for (const [fieldName, fieldDef] of Object.entries(itemFields)) {
568
+ const itemFieldPath = `${arrayPath}[${index}].${fieldName}`;
569
+
570
+ // Skip hidden fields
571
+ if (onlyVisible && visibility[itemFieldPath] === false) {
572
+ continue;
573
+ }
574
+
575
+ const value = item[fieldName];
576
+ const context: EvaluationContext = {
577
+ data,
578
+ computed,
579
+ referenceData: spec.referenceData,
580
+ item,
581
+ itemIndex: index,
582
+ value,
583
+ };
584
+
585
+ // Required check
586
+ const isRequired = fieldDef.requiredWhen
587
+ ? evaluateBoolean(fieldDef.requiredWhen, context)
588
+ : false;
589
+
590
+ if (isRequired && isEmpty(value)) {
591
+ errors.push({
592
+ field: itemFieldPath,
593
+ message: fieldDef.label
594
+ ? `${fieldDef.label} is required`
595
+ : "This field is required",
596
+ severity: "error",
597
+ });
598
+ }
599
+
600
+ // Custom validations
601
+ if (fieldDef.validations && !isEmpty(value)) {
602
+ const customErrors = validateCustomRules(itemFieldPath, fieldDef.validations, context);
603
+ errors.push(...customErrors);
604
+ }
605
+ }
606
+
607
+ return errors;
608
+ }
609
+
610
+ // ============================================================================
611
+ // Single Field Validation
612
+ // ============================================================================
613
+
614
+ /**
615
+ * Validate a single field
616
+ *
617
+ * @param fieldPath - Path to the field
618
+ * @param data - Current form data
619
+ * @param spec - Form specification
620
+ * @returns Array of errors for this field
621
+ */
622
+ export function validateSingleField(
623
+ fieldPath: string,
624
+ data: Record<string, unknown>,
625
+ spec: Forma
626
+ ): FieldError[] {
627
+ const fieldDef = spec.fields[fieldPath];
628
+ if (!fieldDef) {
629
+ return [];
630
+ }
631
+
632
+ const computed = calculate(data, spec);
633
+ const visibility = getVisibility(data, spec, { computed });
634
+ const schemaProperty = spec.schema.properties[fieldPath];
635
+
636
+ return validateField(
637
+ fieldPath,
638
+ data[fieldPath],
639
+ fieldDef,
640
+ schemaProperty,
641
+ spec,
642
+ data,
643
+ computed,
644
+ visibility,
645
+ true
646
+ );
647
+ }