@reso-standards/reso-validation 0.1.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.
Files changed (43) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +109 -0
  3. package/dist/business-rules/index.d.ts +15 -0
  4. package/dist/business-rules/index.d.ts.map +1 -0
  5. package/dist/business-rules/index.js +87 -0
  6. package/dist/business-rules/index.js.map +1 -0
  7. package/dist/business-rules/member-rules.d.ts +4 -0
  8. package/dist/business-rules/member-rules.d.ts.map +1 -0
  9. package/dist/business-rules/member-rules.js +9 -0
  10. package/dist/business-rules/member-rules.js.map +1 -0
  11. package/dist/business-rules/office-rules.d.ts +4 -0
  12. package/dist/business-rules/office-rules.d.ts.map +1 -0
  13. package/dist/business-rules/office-rules.js +9 -0
  14. package/dist/business-rules/office-rules.js.map +1 -0
  15. package/dist/business-rules/property-rules.d.ts +6 -0
  16. package/dist/business-rules/property-rules.d.ts.map +1 -0
  17. package/dist/business-rules/property-rules.js +97 -0
  18. package/dist/business-rules/property-rules.js.map +1 -0
  19. package/dist/business-rules/types.d.ts +18 -0
  20. package/dist/business-rules/types.d.ts.map +1 -0
  21. package/dist/business-rules/types.js +2 -0
  22. package/dist/business-rules/types.js.map +1 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +2 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/metadata/helpers.d.ts +7 -0
  28. package/dist/metadata/helpers.d.ts.map +1 -0
  29. package/dist/metadata/helpers.js +9 -0
  30. package/dist/metadata/helpers.js.map +1 -0
  31. package/dist/metadata/index.d.ts +6 -0
  32. package/dist/metadata/index.d.ts.map +1 -0
  33. package/dist/metadata/index.js +4 -0
  34. package/dist/metadata/index.js.map +1 -0
  35. package/dist/metadata/types.d.ts +24 -0
  36. package/dist/metadata/types.d.ts.map +1 -0
  37. package/dist/metadata/types.js +2 -0
  38. package/dist/metadata/types.js.map +1 -0
  39. package/dist/metadata/validate.d.ts +16 -0
  40. package/dist/metadata/validate.d.ts.map +1 -0
  41. package/dist/metadata/validate.js +121 -0
  42. package/dist/metadata/validate.js.map +1 -0
  43. package/package.json +47 -0
package/LICENSE ADDED
@@ -0,0 +1,11 @@
1
+ REAL ESTATE STANDARDS ORGANIZATION
2
+ End-user License Agreement (EULA)
3
+
4
+ By downloading these resources, you confirm that you agree to the RESO EULA: https://www.reso.org/eula/
5
+
6
+ Contact Information:
7
+ Attn: Sam DeBord, Chief Executive Officer
8
+ Address: P.O. Box 10824
9
+ Raleigh, NC 27605
10
+ Phone: 206.658.3225
11
+ Email: sam@reso.org
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @reso-standards/validation
2
+
3
+ Isomorphic TypeScript validation library for RESO Data Dictionary records. Validates field types, lengths, and ranges against metadata, plus resource-specific business rules with cross-field constraints. Zero external dependencies.
4
+
5
+ > **[User Guide](doc/GUIDE.md)** – a task-oriented walkthrough with realistic examples.
6
+
7
+ ## Install
8
+
9
+ This package isn't on npm yet. Install from the [`reso-tools`](https://github.com/RESOStandards/reso-tools) monorepo on GitHub:
10
+
11
+ ```bash
12
+ git clone https://github.com/RESOStandards/reso-tools.git
13
+ cd reso-tools/reso-validation
14
+ npm install
15
+ ```
16
+
17
+ This package has no sibling dependencies — `npm install` just runs. To bootstrap every package at once, run `npm run bootstrap` from the repo root instead.
18
+
19
+ To consume the SDK from another project, link it locally with `npm link` or use a `file:` dependency.
20
+
21
+ ## Usage
22
+
23
+ ```typescript
24
+ import { validateRecord, validateBusinessRules, getBusinessRules } from '@reso-standards/validation';
25
+ import type { ResoField, ValidationFailure } from '@reso-standards/validation';
26
+
27
+ // Validate a record against field metadata
28
+ const failures = validateRecord(record, fields);
29
+
30
+ // Validate resource-specific business rules
31
+ const ruleFailures = validateBusinessRules('Property', record);
32
+
33
+ // Get the rule definitions for a resource
34
+ const rules = getBusinessRules('Property');
35
+ ```
36
+
37
+ ## API
38
+
39
+ ### `validateRecord(body, fields): ValidationFailure[]`
40
+
41
+ Validates a record payload against RESO field metadata. Checks:
42
+
43
+ - **Unknown fields** – fields not in metadata
44
+ - **Type mismatches** – Edm.String, Edm.Boolean, Edm.Int32, Edm.Decimal, Edm.Date, Edm.DateTimeOffset, etc.
45
+ - **Negative numerics** – rejects negative values for numeric types
46
+ - **MaxLength** – strings exceeding `field.maxLength`
47
+ - **Integer enforcement** – Int types must be whole numbers
48
+ - **Collection fields** – must be arrays
49
+ - **Enum fields** – must be string or number
50
+ - **Business rules** – delegates to `validateBusinessRules()` for range/relationship checks
51
+
52
+ Null, undefined, and empty string values are silently skipped unless the field is required. Fields starting with `@` (OData annotations) are ignored.
53
+
54
+ ### `validateBusinessRules(resourceName, body): ValidationFailure[]`
55
+
56
+ Validates resource-specific constraints:
57
+
58
+ **Property:**
59
+ - Required: City, StateOrProvince, PostalCode, Country
60
+ - Price fields (> 0 to $1B): ListPrice, OriginalListPrice, PreviousListPrice, ClosePrice, ListPriceLow
61
+ - Room counts (0 to 100): BedroomsTotal, BathroomsFull, BathroomsHalf, etc.
62
+ - Expense/fee/amount fields ($0 to $10K): matched via `fieldPattern` regex `/(?:Expense|Amount|Fee\d?)$/`
63
+ - Latitude and Longitude are exempt from the non-negative rule (negative coordinates are valid)
64
+ - Cross-field: `BathroomsTotalInteger = sum(BathroomsFull + BathroomsHalf + ...)`
65
+
66
+ **Member:** Required MemberCity, MemberStateOrProvince, MemberPostalCode, MemberCountry
67
+
68
+ **Office:** Required OfficeCity, OfficeStateOrProvince, OfficePostalCode, OfficeCountry
69
+
70
+ ### `getBusinessRules(resourceName): FieldRule[]`
71
+
72
+ Returns the per-field constraint definitions for a resource.
73
+
74
+ ### Type Helpers
75
+
76
+ - `isEnumType(type)` – true if type is an enum reference (not a primitive Edm type)
77
+ - `isNumericEdmType(type)` – true for Edm.Decimal, Edm.Int32, Edm.Double, etc.
78
+ - `isIntegerEdmType(type)` – true for Edm.Int16, Edm.Int32, Edm.Int64, Edm.Byte
79
+
80
+ ## Types
81
+
82
+ ```typescript
83
+ interface ValidationFailure {
84
+ readonly field: string;
85
+ readonly reason: string;
86
+ }
87
+
88
+ interface FieldRule {
89
+ readonly fieldName: string;
90
+ readonly fieldPattern?: RegExp; // regex alternative to fieldName for matching multiple fields
91
+ readonly required?: boolean;
92
+ readonly min?: number;
93
+ readonly max?: number;
94
+ readonly message?: string;
95
+ }
96
+
97
+ interface CrossFieldRule {
98
+ readonly name: string;
99
+ readonly validate: (body: Record<string, unknown>) => ValidationFailure | null;
100
+ }
101
+ ```
102
+
103
+ ## Integration
104
+
105
+ Used by both the reference server (request body validation on POST/PATCH) and the React UI (client-side form validation). Also used by `@reso-standards/certification` for payload validation in compliance testing.
106
+
107
+ ## License
108
+
109
+ See [LICENSE](https://github.com/RESOStandards/reso-tools/blob/main/LICENSE) in the repository root.
@@ -0,0 +1,15 @@
1
+ import type { ValidationFailure } from '../metadata/types.js';
2
+ import type { CrossFieldRule, FieldRule } from './types.js';
3
+ export type { CrossFieldRule, FieldRule } from './types.js';
4
+ /** Returns the per-field business rules for a given resource (empty array if none). */
5
+ export declare const getBusinessRules: (resourceName: string) => ReadonlyArray<FieldRule>;
6
+ /** Returns the cross-field business rules for a given resource (empty array if none). */
7
+ export declare const getCrossFieldRules: (resourceName: string) => ReadonlyArray<CrossFieldRule>;
8
+ /**
9
+ * Validates a record against business rules for the given resource.
10
+ * Checks per-field range rules first, then cross-field relationship rules.
11
+ * When `skipRequired` is true (e.g., for PATCH partial updates), required-field checks are skipped.
12
+ * Returns an array of failures (empty if valid).
13
+ */
14
+ export declare const validateBusinessRules: (resourceName: string, body: Readonly<Record<string, unknown>>, skipRequired?: boolean) => ReadonlyArray<ValidationFailure>;
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/business-rules/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAI9D,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5D,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAc5D,uFAAuF;AACvF,eAAO,MAAM,gBAAgB,GAAI,cAAc,MAAM,KAAG,aAAa,CAAC,SAAS,CAA0C,CAAC;AAE1H,yFAAyF;AACzF,eAAO,MAAM,kBAAkB,GAAI,cAAc,MAAM,KAAG,aAAa,CAAC,cAAc,CAAgD,CAAC;AAevI;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAChC,cAAc,MAAM,EACpB,MAAM,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACvC,sBAAoB,KACnB,aAAa,CAAC,iBAAiB,CA+CjC,CAAC"}
@@ -0,0 +1,87 @@
1
+ import { MEMBER_RULES } from './member-rules.js';
2
+ import { OFFICE_RULES } from './office-rules.js';
3
+ import { PROPERTY_CROSS_RULES, PROPERTY_RULES } from './property-rules.js';
4
+ /** Per-field rule registry keyed by resource name. */
5
+ const RULES_BY_RESOURCE = {
6
+ Property: PROPERTY_RULES,
7
+ Member: MEMBER_RULES,
8
+ Office: OFFICE_RULES
9
+ };
10
+ /** Cross-field rule registry keyed by resource name. */
11
+ const CROSS_RULES_BY_RESOURCE = {
12
+ Property: PROPERTY_CROSS_RULES
13
+ };
14
+ /** Returns the per-field business rules for a given resource (empty array if none). */
15
+ export const getBusinessRules = (resourceName) => RULES_BY_RESOURCE[resourceName] ?? [];
16
+ /** Returns the cross-field business rules for a given resource (empty array if none). */
17
+ export const getCrossFieldRules = (resourceName) => CROSS_RULES_BY_RESOURCE[resourceName] ?? [];
18
+ /** Formats a default error message for a range violation. */
19
+ const formatRangeMessage = (rule, value) => {
20
+ if (rule.message)
21
+ return rule.message;
22
+ if (rule.min !== undefined && value < rule.min) {
23
+ return `Must be at least ${rule.min.toLocaleString('en-US')}.`;
24
+ }
25
+ if (rule.max !== undefined && value > rule.max) {
26
+ return `Must be at most ${rule.max.toLocaleString('en-US')}.`;
27
+ }
28
+ /* v8 ignore next 2 */
29
+ return 'Value is out of range.';
30
+ };
31
+ /**
32
+ * Validates a record against business rules for the given resource.
33
+ * Checks per-field range rules first, then cross-field relationship rules.
34
+ * When `skipRequired` is true (e.g., for PATCH partial updates), required-field checks are skipped.
35
+ * Returns an array of failures (empty if valid).
36
+ */
37
+ export const validateBusinessRules = (resourceName, body, skipRequired = false) => {
38
+ const failures = [];
39
+ // Per-field rules
40
+ const rules = getBusinessRules(resourceName);
41
+ if (rules.length > 0) {
42
+ const exactRules = new Map();
43
+ const patternRules = [];
44
+ for (const r of rules) {
45
+ if (r.fieldPattern)
46
+ patternRules.push(r);
47
+ else
48
+ exactRules.set(r.fieldName, r);
49
+ }
50
+ // Required field checks — iterate rules looking for missing fields
51
+ // Skipped for partial updates (PATCH) where only supplied fields are validated
52
+ if (!skipRequired) {
53
+ for (const rule of rules) {
54
+ if (!rule.required)
55
+ continue;
56
+ const value = body[rule.fieldName];
57
+ if (value === undefined || value === null || value === '') {
58
+ failures.push({ field: rule.fieldName, reason: rule.message ?? `${rule.fieldName} is required.` });
59
+ }
60
+ }
61
+ }
62
+ // Range checks — iterate body fields looking for out-of-range values
63
+ for (const [key, value] of Object.entries(body)) {
64
+ if (typeof value !== 'number')
65
+ continue;
66
+ // Check exact match first, then pattern rules
67
+ const rule = exactRules.get(key) ?? patternRules.find(r => r.fieldPattern.test(key));
68
+ if (!rule)
69
+ continue;
70
+ if (rule.min !== undefined && value < rule.min) {
71
+ failures.push({ field: key, reason: formatRangeMessage(rule, value) });
72
+ }
73
+ else if (rule.max !== undefined && value > rule.max) {
74
+ failures.push({ field: key, reason: formatRangeMessage(rule, value) });
75
+ }
76
+ }
77
+ }
78
+ // Cross-field relationship rules
79
+ const crossRules = getCrossFieldRules(resourceName);
80
+ for (const crossRule of crossRules) {
81
+ const failure = crossRule.validate(body);
82
+ if (failure)
83
+ failures.push(failure);
84
+ }
85
+ return failures;
86
+ };
87
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/business-rules/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import type { FieldRule } from './types.js';
2
+ /** Per-field rules for the Member resource. */
3
+ export declare const MEMBER_RULES: ReadonlyArray<FieldRule>;
4
+ //# sourceMappingURL=member-rules.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"member-rules.d.ts","sourceRoot":"","sources":["../../src/business-rules/member-rules.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,+CAA+C;AAC/C,eAAO,MAAM,YAAY,EAAE,aAAa,CAAC,SAAS,CAMjD,CAAC"}
@@ -0,0 +1,9 @@
1
+ /** Per-field rules for the Member resource. */
2
+ export const MEMBER_RULES = [
3
+ // Required address fields
4
+ { fieldName: 'MemberCity', required: true },
5
+ { fieldName: 'MemberStateOrProvince', required: true },
6
+ { fieldName: 'MemberPostalCode', required: true },
7
+ { fieldName: 'MemberCountry', required: true }
8
+ ];
9
+ //# sourceMappingURL=member-rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"member-rules.js","sourceRoot":"","sources":["../../src/business-rules/member-rules.ts"],"names":[],"mappings":"AAEA,+CAA+C;AAC/C,MAAM,CAAC,MAAM,YAAY,GAA6B;IACpD,0BAA0B;IAC1B,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC3C,EAAE,SAAS,EAAE,uBAAuB,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtD,EAAE,SAAS,EAAE,kBAAkB,EAAE,QAAQ,EAAE,IAAI,EAAE;IACjD,EAAE,SAAS,EAAE,eAAe,EAAE,QAAQ,EAAE,IAAI,EAAE;CAC/C,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { FieldRule } from './types.js';
2
+ /** Per-field rules for the Office resource. */
3
+ export declare const OFFICE_RULES: ReadonlyArray<FieldRule>;
4
+ //# sourceMappingURL=office-rules.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"office-rules.d.ts","sourceRoot":"","sources":["../../src/business-rules/office-rules.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,+CAA+C;AAC/C,eAAO,MAAM,YAAY,EAAE,aAAa,CAAC,SAAS,CAMjD,CAAC"}
@@ -0,0 +1,9 @@
1
+ /** Per-field rules for the Office resource. */
2
+ export const OFFICE_RULES = [
3
+ // Required address fields
4
+ { fieldName: 'OfficeCity', required: true },
5
+ { fieldName: 'OfficeStateOrProvince', required: true },
6
+ { fieldName: 'OfficePostalCode', required: true },
7
+ { fieldName: 'OfficeCountry', required: true }
8
+ ];
9
+ //# sourceMappingURL=office-rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"office-rules.js","sourceRoot":"","sources":["../../src/business-rules/office-rules.ts"],"names":[],"mappings":"AAEA,+CAA+C;AAC/C,MAAM,CAAC,MAAM,YAAY,GAA6B;IACpD,0BAA0B;IAC1B,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAE,IAAI,EAAE;IAC3C,EAAE,SAAS,EAAE,uBAAuB,EAAE,QAAQ,EAAE,IAAI,EAAE;IACtD,EAAE,SAAS,EAAE,kBAAkB,EAAE,QAAQ,EAAE,IAAI,EAAE;IACjD,EAAE,SAAS,EAAE,eAAe,EAAE,QAAQ,EAAE,IAAI,EAAE;CAC/C,CAAC"}
@@ -0,0 +1,6 @@
1
+ import type { CrossFieldRule, FieldRule } from './types.js';
2
+ /** Per-field rules for the Property resource. */
3
+ export declare const PROPERTY_RULES: ReadonlyArray<FieldRule>;
4
+ /** Cross-field rules for the Property resource. */
5
+ export declare const PROPERTY_CROSS_RULES: ReadonlyArray<CrossFieldRule>;
6
+ //# sourceMappingURL=property-rules.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"property-rules.d.ts","sourceRoot":"","sources":["../../src/business-rules/property-rules.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAmC5D,iDAAiD;AACjD,eAAO,MAAM,cAAc,EAAE,aAAa,CAAC,SAAS,CAmCnD,CAAC;AAaF,mDAAmD;AACnD,eAAO,MAAM,oBAAoB,EAAE,aAAa,CAAC,cAAc,CA2B9D,CAAC"}
@@ -0,0 +1,97 @@
1
+ /** Maximum allowed value for price fields. */
2
+ const MAX_PRICE = 1_000_000_000;
3
+ /** Maximum allowed value for bedroom/bathroom count fields. */
4
+ const MAX_ROOM_COUNT = 100;
5
+ /** Maximum allowed value for expense, fee, and amount fields. */
6
+ const MAX_EXPENSE = 10_000;
7
+ const priceRule = (fieldName) => ({
8
+ fieldName,
9
+ min: 0.01,
10
+ max: MAX_PRICE
11
+ });
12
+ const roomCountRule = (fieldName) => ({
13
+ fieldName,
14
+ min: 0,
15
+ max: MAX_ROOM_COUNT
16
+ });
17
+ const requiredRule = (fieldName) => ({
18
+ fieldName,
19
+ required: true
20
+ });
21
+ /**
22
+ * Pattern matching expense, fee, and amount fields by name suffix.
23
+ * Matches: CableTvExpense, AssociationFee, AssociationFee2, TaxAnnualAmount, etc.
24
+ * Does NOT match: LotSizeSquareFeet (ends with "Feet", not "Fee").
25
+ */
26
+ const EXPENSE_FEE_AMOUNT_PATTERN = /(?:Expense|Amount|Fee\d?)$/;
27
+ /** Per-field rules for the Property resource. */
28
+ export const PROPERTY_RULES = [
29
+ // Required address fields
30
+ requiredRule('City'),
31
+ requiredRule('StateOrProvince'),
32
+ requiredRule('PostalCode'),
33
+ requiredRule('Country'),
34
+ // Price fields (must be > $0)
35
+ priceRule('ListPrice'),
36
+ priceRule('OriginalListPrice'),
37
+ priceRule('PreviousListPrice'),
38
+ priceRule('ClosePrice'),
39
+ priceRule('ListPriceLow'),
40
+ // Expense, fee, and amount fields (pattern-matched)
41
+ {
42
+ fieldName: 'expenses/fees/amounts',
43
+ fieldPattern: EXPENSE_FEE_AMOUNT_PATTERN,
44
+ min: 0,
45
+ max: MAX_EXPENSE
46
+ },
47
+ // Bedroom fields
48
+ roomCountRule('BedroomsTotal'),
49
+ roomCountRule('BedroomsPossible'),
50
+ roomCountRule('MainLevelBedrooms'),
51
+ // Bathroom fields
52
+ roomCountRule('BathroomsTotalInteger'),
53
+ roomCountRule('BathroomsFull'),
54
+ roomCountRule('BathroomsHalf'),
55
+ roomCountRule('BathroomsOneQuarter'),
56
+ roomCountRule('BathroomsPartial'),
57
+ roomCountRule('BathroomsThreeQuarter'),
58
+ roomCountRule('MainLevelBathrooms')
59
+ ];
60
+ // --- Cross-field helpers ---
61
+ /** Extracts a numeric value from a record field, or returns null if absent/non-numeric. */
62
+ const numericField = (body, field) => {
63
+ const v = body[field];
64
+ return typeof v === 'number' ? v : null;
65
+ };
66
+ /** Bathroom part fields that should sum to BathroomsTotalInteger. */
67
+ const BATHROOM_PARTS = ['BathroomsFull', 'BathroomsHalf', 'BathroomsPartial', 'BathroomsOneQuarter', 'BathroomsThreeQuarter'];
68
+ /** Cross-field rules for the Property resource. */
69
+ export const PROPERTY_CROSS_RULES = [
70
+ {
71
+ name: 'BathroomsTotalInteger = sum of parts',
72
+ validate: body => {
73
+ const total = numericField(body, 'BathroomsTotalInteger');
74
+ if (total === null)
75
+ return null;
76
+ let sum = 0;
77
+ let hasParts = false;
78
+ for (const part of BATHROOM_PARTS) {
79
+ const v = numericField(body, part);
80
+ if (v !== null) {
81
+ sum += v;
82
+ hasParts = true;
83
+ }
84
+ }
85
+ if (!hasParts)
86
+ return null;
87
+ if (total !== sum) {
88
+ return {
89
+ field: 'BathroomsTotalInteger',
90
+ reason: `BathroomsTotalInteger (${total}) must equal the sum of bathroom parts (${sum}).`
91
+ };
92
+ }
93
+ return null;
94
+ }
95
+ }
96
+ ];
97
+ //# sourceMappingURL=property-rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"property-rules.js","sourceRoot":"","sources":["../../src/business-rules/property-rules.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import type { ValidationFailure } from '../metadata/types.js';
2
+ /** A per-field constraint for a specific field within a resource. */
3
+ export interface FieldRule {
4
+ readonly fieldName: string;
5
+ /** Pattern to match multiple field names. When set, fieldName is a descriptive label. */
6
+ readonly fieldPattern?: RegExp;
7
+ readonly required?: boolean;
8
+ readonly min?: number;
9
+ readonly max?: number;
10
+ /** Custom error message. If omitted, a default is generated from min/max or required. */
11
+ readonly message?: string;
12
+ }
13
+ /** A cross-field constraint that validates relationships between multiple fields. */
14
+ export interface CrossFieldRule {
15
+ readonly name: string;
16
+ readonly validate: (body: Readonly<Record<string, unknown>>) => ValidationFailure | null;
17
+ }
18
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/business-rules/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAE9D,qEAAqE;AACrE,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,yFAAyF;IACzF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,yFAAyF;IACzF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,qFAAqF;AACrF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,KAAK,iBAAiB,GAAG,IAAI,CAAC;CAC1F"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/business-rules/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,3 @@
1
+ export type { CrossFieldRule, FieldRule, ResoAnnotation, ResoField, ValidationFailure } from './metadata/index.js';
2
+ export { getBusinessRules, isEnumType, isIntegerEdmType, isNumericEdmType, validateBusinessRules, validateRecord } from './metadata/index.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,cAAc,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACnH,OAAO,EACL,gBAAgB,EAChB,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,qBAAqB,EACrB,cAAc,EACf,MAAM,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { getBusinessRules, isEnumType, isIntegerEdmType, isNumericEdmType, validateBusinessRules, validateRecord } from './metadata/index.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,gBAAgB,EAChB,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,qBAAqB,EACrB,cAAc,EACf,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,7 @@
1
+ /** Checks whether a field type is an enum reference (not a primitive Edm type). */
2
+ export declare const isEnumType: (type: string) => boolean;
3
+ /** Checks if an Edm type is numeric. */
4
+ export declare const isNumericEdmType: (type: string) => boolean;
5
+ /** Checks if an Edm type is an integer (no decimals allowed). */
6
+ export declare const isIntegerEdmType: (type: string) => boolean;
7
+ //# sourceMappingURL=helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../src/metadata/helpers.ts"],"names":[],"mappings":"AAAA,mFAAmF;AAGnF,eAAO,MAAM,UAAU,GAAI,MAAM,MAAM,KAAG,OAAmC,CAAC;AAE9E,wCAAwC;AACxC,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG,OAC+D,CAAC;AAEhH,iEAAiE;AACjE,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG,OAA6E,CAAC"}
@@ -0,0 +1,9 @@
1
+ /** Checks whether a field type is an enum reference (not a primitive Edm type). */
2
+ // TODO(#222): duplicate of reso-common's isEnumType — consolidate to one shared helper and
3
+ // prefer the precomputed isEnumeration flag where a field is in hand. See reso-tools #222.
4
+ export const isEnumType = (type) => !type.startsWith('Edm.');
5
+ /** Checks if an Edm type is numeric. */
6
+ export const isNumericEdmType = (type) => ['Edm.Decimal', 'Edm.Int64', 'Edm.Int32', 'Edm.Int16', 'Edm.Double', 'Edm.Single', 'Edm.Byte'].includes(type);
7
+ /** Checks if an Edm type is an integer (no decimals allowed). */
8
+ export const isIntegerEdmType = (type) => ['Edm.Int64', 'Edm.Int32', 'Edm.Int16', 'Edm.Byte'].includes(type);
9
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers.js","sourceRoot":"","sources":["../../src/metadata/helpers.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,2FAA2F;AAC3F,2FAA2F;AAC3F,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,IAAY,EAAW,EAAE,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;AAE9E,wCAAwC;AACxC,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,IAAY,EAAW,EAAE,CACxD,CAAC,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;AAEhH,iEAAiE;AACjE,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,IAAY,EAAW,EAAE,CAAC,CAAC,WAAW,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC"}
@@ -0,0 +1,6 @@
1
+ export type { ResoAnnotation, ResoField, ValidationFailure } from './types.js';
2
+ export type { CrossFieldRule, FieldRule } from '../business-rules/index.js';
3
+ export { getBusinessRules, validateBusinessRules } from '../business-rules/index.js';
4
+ export { isEnumType, isIntegerEdmType, isNumericEdmType } from './helpers.js';
5
+ export { validateRecord } from './validate.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/metadata/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/E,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACrF,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC9E,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { getBusinessRules, validateBusinessRules } from '../business-rules/index.js';
2
+ export { isEnumType, isIntegerEdmType, isNumericEdmType } from './helpers.js';
3
+ export { validateRecord } from './validate.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/metadata/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACrF,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAC9E,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,24 @@
1
+ /** Annotation entry from RESO metadata (e.g., StandardName, Description, DDWikiUrl). */
2
+ export interface ResoAnnotation {
3
+ readonly term: string;
4
+ readonly value: string;
5
+ }
6
+ /** A field definition from the RESO Data Dictionary metadata. */
7
+ export interface ResoField {
8
+ readonly resourceName: string;
9
+ readonly fieldName: string;
10
+ readonly type: string;
11
+ readonly typeName?: string;
12
+ readonly nullable?: boolean;
13
+ readonly isCollection?: boolean;
14
+ readonly maxLength?: number;
15
+ readonly scale?: number;
16
+ readonly precision?: number;
17
+ readonly annotations: ReadonlyArray<ResoAnnotation>;
18
+ }
19
+ /** A validation failure for a single field. */
20
+ export interface ValidationFailure {
21
+ readonly field: string;
22
+ readonly reason: string;
23
+ }
24
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/metadata/types.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,iEAAiE;AACjE,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,cAAc,CAAC,CAAC;CACrD;AAED,+CAA+C;AAC/C,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/metadata/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,16 @@
1
+ import type { ResoField, ValidationFailure } from './types.js';
2
+ /**
3
+ * Validates a record against the resource's field definitions.
4
+ *
5
+ * Checks for:
6
+ * - Unknown fields (not in metadata)
7
+ * - Null/undefined/empty-string passthrough (no error)
8
+ * - Negative numeric values
9
+ * - MaxLength enforcement for strings
10
+ * - Integer-only enforcement for Int types
11
+ * - Basic type mismatches (string, number, boolean, date, enum, collection)
12
+ *
13
+ * Returns an array of failures (empty if valid).
14
+ */
15
+ export declare const validateRecord: (body: Readonly<Record<string, unknown>>, fields: ReadonlyArray<ResoField>, skipRequired?: boolean) => ReadonlyArray<ValidationFailure>;
16
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/metadata/validate.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAK/D;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,GACzB,MAAM,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EACvC,QAAQ,aAAa,CAAC,SAAS,CAAC,EAChC,sBAAoB,KACnB,aAAa,CAAC,iBAAiB,CAmDjC,CAAC"}
@@ -0,0 +1,121 @@
1
+ import { validateBusinessRules } from '../business-rules/index.js';
2
+ import { isEnumType, isNumericEdmType } from './helpers.js';
3
+ /** Fields that legitimately hold negative numeric values (e.g., coordinates). */
4
+ const ALLOW_NEGATIVE_FIELDS = new Set(['Latitude', 'Longitude']);
5
+ /**
6
+ * Validates a record against the resource's field definitions.
7
+ *
8
+ * Checks for:
9
+ * - Unknown fields (not in metadata)
10
+ * - Null/undefined/empty-string passthrough (no error)
11
+ * - Negative numeric values
12
+ * - MaxLength enforcement for strings
13
+ * - Integer-only enforcement for Int types
14
+ * - Basic type mismatches (string, number, boolean, date, enum, collection)
15
+ *
16
+ * Returns an array of failures (empty if valid).
17
+ */
18
+ export const validateRecord = (body, fields, skipRequired = false) => {
19
+ const fieldMap = new Map(fields.map(f => [f.fieldName, f]));
20
+ const failures = [];
21
+ for (const [key, value] of Object.entries(body)) {
22
+ // Skip OData annotations
23
+ if (key.startsWith('@'))
24
+ continue;
25
+ const field = fieldMap.get(key);
26
+ if (!field) {
27
+ failures.push({
28
+ field: key,
29
+ reason: `'${key}' is not a recognized field. Check field name spelling or consult the metadata.`
30
+ });
31
+ continue;
32
+ }
33
+ // Skip null, undefined, and empty string values
34
+ if (value === null || value === undefined || value === '')
35
+ continue;
36
+ // Check negative numerics (Latitude/Longitude are allowed to be negative)
37
+ if (typeof value === 'number' && value < 0 && isNumericEdmType(field.type) && !ALLOW_NEGATIVE_FIELDS.has(key)) {
38
+ failures.push({ field: key, reason: 'Value must be greater than or equal to 0.' });
39
+ continue;
40
+ }
41
+ // MaxLength for strings
42
+ if (typeof value === 'string' && field.maxLength && value.length > field.maxLength) {
43
+ failures.push({ field: key, reason: `Exceeds maximum length of ${field.maxLength} characters (currently ${value.length}).` });
44
+ continue;
45
+ }
46
+ // Basic type validation
47
+ const typeError = validateFieldType(key, value, field);
48
+ if (typeError) {
49
+ failures.push(typeError);
50
+ }
51
+ }
52
+ // Field-specific business rules (range constraints, etc.)
53
+ // Skip fields that already have type-level failures to avoid duplicate errors.
54
+ const resourceName = fields[0]?.resourceName;
55
+ if (resourceName) {
56
+ const failedFields = new Set(failures.map(f => f.field));
57
+ const ruleFailures = validateBusinessRules(resourceName, body, skipRequired);
58
+ for (const rf of ruleFailures) {
59
+ if (!failedFields.has(rf.field))
60
+ failures.push(rf);
61
+ }
62
+ }
63
+ return failures;
64
+ };
65
+ /** Validates a single field value against its expected Edm type. */
66
+ const validateFieldType = (fieldName, value, field) => {
67
+ if (field.isCollection) {
68
+ if (!Array.isArray(value)) {
69
+ return { field: fieldName, reason: 'Must be a list of values.' };
70
+ }
71
+ return undefined;
72
+ }
73
+ if (isEnumType(field.type)) {
74
+ if (typeof value !== 'string' && typeof value !== 'number') {
75
+ return { field: fieldName, reason: 'Please select a valid option from the list.' };
76
+ }
77
+ return undefined;
78
+ }
79
+ switch (field.type) {
80
+ case 'Edm.String':
81
+ if (typeof value !== 'string') {
82
+ return { field: fieldName, reason: 'Must be text.' };
83
+ }
84
+ break;
85
+ case 'Edm.Boolean':
86
+ if (typeof value !== 'boolean') {
87
+ return { field: fieldName, reason: 'Must be true or false.' };
88
+ }
89
+ break;
90
+ case 'Edm.Int64':
91
+ case 'Edm.Int32':
92
+ case 'Edm.Int16':
93
+ case 'Edm.Byte':
94
+ if (typeof value !== 'number') {
95
+ return { field: fieldName, reason: 'Must be a whole number.' };
96
+ }
97
+ if (!Number.isInteger(value)) {
98
+ return { field: fieldName, reason: 'Must be a whole number (no decimals).' };
99
+ }
100
+ break;
101
+ case 'Edm.Decimal':
102
+ case 'Edm.Double':
103
+ case 'Edm.Single':
104
+ if (typeof value !== 'number') {
105
+ return { field: fieldName, reason: 'Must be a number.' };
106
+ }
107
+ break;
108
+ case 'Edm.Date':
109
+ if (typeof value !== 'string') {
110
+ return { field: fieldName, reason: 'Must be a date (YYYY-MM-DD).' };
111
+ }
112
+ break;
113
+ case 'Edm.DateTimeOffset':
114
+ if (typeof value !== 'string') {
115
+ return { field: fieldName, reason: 'Must be a date and time.' };
116
+ }
117
+ break;
118
+ }
119
+ return undefined;
120
+ };
121
+ //# sourceMappingURL=validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../../src/metadata/validate.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@reso-standards/reso-validation",
3
+ "version": "0.1.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/RESOStandards/reso-tools.git",
7
+ "directory": "reso-validation"
8
+ },
9
+ "description": "RESO validation library — isomorphic, no Node/browser APIs.",
10
+ "license": "SEE LICENSE IN LICENSE",
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./metadata": {
20
+ "types": "./dist/metadata/index.d.ts",
21
+ "import": "./dist/metadata/index.js"
22
+ }
23
+ },
24
+ "sideEffects": false,
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "prepublishOnly": "npm run build",
34
+ "dev": "tsc --watch",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "lint": "tsc --noEmit"
38
+ },
39
+ "engines": {
40
+ "node": ">=22"
41
+ },
42
+ "devDependencies": {
43
+ "@vitest/coverage-v8": "^4.1.9",
44
+ "typescript": "^5.7.0",
45
+ "vitest": "^4.1.9"
46
+ }
47
+ }