From 07d6b0d3ed395062d03f4d5331fa5e4dd58f97d8 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 18:10:34 -0400 Subject: [PATCH 01/23] v1 --- src/validation/index.ts | 51 ++++++++++++++++++++++++++++++ src/validation/rules/email.ts | 8 +++++ src/validation/rules/identifier.ts | 7 ++++ src/validation/rules/required.ts | 17 ++++++++++ src/validation/rules/slug.ts | 7 ++++ src/validation/rules/url.ts | 17 ++++++++++ tsconfig.json | 2 +- 7 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/validation/index.ts create mode 100644 src/validation/rules/email.ts create mode 100644 src/validation/rules/identifier.ts create mode 100644 src/validation/rules/required.ts create mode 100644 src/validation/rules/slug.ts create mode 100644 src/validation/rules/url.ts diff --git a/src/validation/index.ts b/src/validation/index.ts new file mode 100644 index 0000000..46e6738 --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,51 @@ +export type ValidationRule = (value: unknown) => boolean; + +export type ValidationRuleKey = string; + +export type ValidationRuleSet = Record; + +class Validator { + private readonly rules: Map = new Map(); + + constructor() {} + + clearRules(): void { + this.rules.clear(); + } + getRule(key: ValidationRuleKey): ValidationRule | undefined { + return this.rules.get(key); + } + hasRule(key: ValidationRuleKey): boolean { + return this.rules.has(key); + } + listRules(): [ValidationRuleKey, ValidationRule][] { + return [...this.rules.entries()]; + } + removeRule(key: ValidationRuleKey): boolean { + return this.rules.delete(key); + } + setRule(key: ValidationRuleKey, rule: ValidationRule): void { + this.rules.set(key, rule); + } + + validate(value: unknown, rules: ValidationRuleSet): boolean { + let errors: number = 0; + + const missingRules: string[] = []; + for (const rule in rules) { + const validationRule: ValidationRule | undefined = this.rules.get(rule); + if (!validationRule) { + missingRules.push(rule); + continue; + } + + const outcome: boolean = validationRule(value); + if (!outcome) { + errors++; + } + } + + return errors === 0; + } +} +export default Validator; diff --git a/src/validation/rules/email.ts b/src/validation/rules/email.ts new file mode 100644 index 0000000..949b0a3 --- /dev/null +++ b/src/validation/rules/email.ts @@ -0,0 +1,8 @@ +import type { ValidationRule } from ".."; + +// https://github.com/colinhacks/zod/blob/40e72f9eaf576985f876d1afc2dbc22f73abc1ba/src/types.ts#L595 +const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; + +const email: ValidationRule = (value: unknown): boolean => typeof value === "string" && regex.test(value); + +export default email; diff --git a/src/validation/rules/identifier.ts b/src/validation/rules/identifier.ts new file mode 100644 index 0000000..81e47c7 --- /dev/null +++ b/src/validation/rules/identifier.ts @@ -0,0 +1,7 @@ +import type { ValidationRule } from ".."; +import { isDigit, isLetterOrDigit } from "../../helpers/stringUtils"; + +const identifier: ValidationRule = (value: unknown): boolean => + typeof value === "string" && value.length > 0 && !isDigit(value[0]) && [...value].every((c) => isLetterOrDigit(c) || c === "_"); + +export default identifier; diff --git a/src/validation/rules/required.ts b/src/validation/rules/required.ts new file mode 100644 index 0000000..0fffd36 --- /dev/null +++ b/src/validation/rules/required.ts @@ -0,0 +1,17 @@ +import type { ValidationRule } from ".."; +import { isNullOrWhiteSpace } from "../../helpers/stringUtils"; + +const required: ValidationRule = (value: unknown): boolean => { + switch (typeof value) { + case "number": + return !isNaN(value) && value !== 0; + case "string": + return !isNullOrWhiteSpace(value); + } + if (Array.isArray(value)) { + return value.length > 0; + } + return Boolean(value); +}; + +export default required; diff --git a/src/validation/rules/slug.ts b/src/validation/rules/slug.ts new file mode 100644 index 0000000..ab8b9d7 --- /dev/null +++ b/src/validation/rules/slug.ts @@ -0,0 +1,7 @@ +import type { ValidationRule } from ".."; +import { isLetterOrDigit } from "../../helpers/stringUtils"; + +const slug: ValidationRule = (value: unknown): boolean => + typeof value === "string" && value.split("-").every((word) => word.length > 0 && [...word].every(isLetterOrDigit)); + +export default slug; diff --git a/src/validation/rules/url.ts b/src/validation/rules/url.ts new file mode 100644 index 0000000..0581ed0 --- /dev/null +++ b/src/validation/rules/url.ts @@ -0,0 +1,17 @@ +import type { ValidationRule } from ".."; + +const url: ValidationRule = (value: unknown): boolean => { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) { + return false; + } + let url: URL; + try { + url = new URL(trimmed); + } catch (_) { + return false; + } + return url.protocol === "http:" || url.protocol === "https:"; +}; + +export default url; diff --git a/tsconfig.json b/tsconfig.json index 042b22a..53ceef5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "exclude": ["src/**/__tests__/*"], "compilerOptions": { "module": "CommonJS", - "target": "ES2015", + "target": "ES2020", "outDir": "./dist", "declaration": true } From 016876be7a90b72646ff0d19359c34b582d2ac61 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 18:11:17 -0400 Subject: [PATCH 02/23] tsconfig --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 53ceef5..042b22a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "exclude": ["src/**/__tests__/*"], "compilerOptions": { "module": "CommonJS", - "target": "ES2020", + "target": "ES2015", "outDir": "./dist", "declaration": true } From a2a957222d32592e29eeaf6dcb54e018f837a541 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 18:58:10 -0400 Subject: [PATCH 03/23] v2 --- src/validation/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index 46e6738..b72974e 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -2,6 +2,11 @@ export type ValidationRule = (value: unknown) => boolean; export type ValidationRuleKey = string; +export type ValidationResult = { + isValid: boolean; + rules: Record; +}; + export type ValidationRuleSet = Record; class Validator { @@ -28,8 +33,9 @@ class Validator { this.rules.set(key, rule); } - validate(value: unknown, rules: ValidationRuleSet): boolean { + validate(value: unknown, rules: ValidationRuleSet): ValidationResult { let errors: number = 0; + const results: Record = {}; const missingRules: string[] = []; for (const rule in rules) { @@ -43,9 +49,13 @@ class Validator { if (!outcome) { errors++; } + results[rule] = outcome; } - return errors === 0; + return { + isValid: errors === 0, + rules: results, + }; } } export default Validator; From 6291a7546fa18e3a8d7333190001c5abe43f1021 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 19:09:28 -0400 Subject: [PATCH 04/23] v3 --- src/validation/index.ts | 45 ++++++++++++++++++++++++------ src/validation/rules/email.ts | 7 +++-- src/validation/rules/identifier.ts | 8 ++++-- src/validation/rules/required.ts | 23 +++++++++------ src/validation/rules/slug.ts | 8 ++++-- src/validation/rules/url.ts | 21 +++++++------- 6 files changed, 77 insertions(+), 35 deletions(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index b72974e..8d9d344 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,18 +1,24 @@ -export type ValidationRule = (value: unknown) => boolean; +export type ValidationRule = (value: unknown) => boolean | ValidationSeverity; export type ValidationRuleKey = string; export type ValidationResult = { isValid: boolean; - rules: Record; + rules: Record; }; export type ValidationRuleSet = Record; +// https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=net-9.0-pp +export type ValidationSeverity = "trace" | "debug" | "information" | "warning" | "error" | "critical"; + class Validator { private readonly rules: Map = new Map(); + private readonly treatWarningsAsErrors: boolean; - constructor() {} + constructor(treatWarningsAsErrors?: boolean) { + this.treatWarningsAsErrors = treatWarningsAsErrors ?? false; + } clearRules(): void { this.rules.clear(); @@ -35,7 +41,7 @@ class Validator { validate(value: unknown, rules: ValidationRuleSet): ValidationResult { let errors: number = 0; - const results: Record = {}; + const results: Record = {}; const missingRules: string[] = []; for (const rule in rules) { @@ -45,11 +51,34 @@ class Validator { continue; } - const outcome: boolean = validationRule(value); - if (!outcome) { - errors++; + const outcome: boolean | ValidationSeverity = validationRule(value); + switch (outcome) { + case "trace": + case "debug": + case "information": + results[rule] = outcome; + break; + case "warning": + results[rule] = outcome; + if (this.treatWarningsAsErrors) { + errors++; + } + break; + case "error": + case "critical": + results[rule] = outcome; + errors++; + break; + case false: + results[rule] = "error"; + errors++; + break; + case true: + results[rule] = "information"; + break; + default: + throw new Error("not_implemented"); // TODO(fpion): implement } - results[rule] = outcome; } return { diff --git a/src/validation/rules/email.ts b/src/validation/rules/email.ts index 949b0a3..836ab25 100644 --- a/src/validation/rules/email.ts +++ b/src/validation/rules/email.ts @@ -1,8 +1,11 @@ -import type { ValidationRule } from ".."; +import type { ValidationRule, ValidationSeverity } from ".."; // https://github.com/colinhacks/zod/blob/40e72f9eaf576985f876d1afc2dbc22f73abc1ba/src/types.ts#L595 const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; -const email: ValidationRule = (value: unknown): boolean => typeof value === "string" && regex.test(value); +const email: ValidationRule = (value: unknown): ValidationSeverity => { + const isValid: boolean = typeof value === "string" && regex.test(value); + return isValid ? "information" : "error"; +}; export default email; diff --git a/src/validation/rules/identifier.ts b/src/validation/rules/identifier.ts index 81e47c7..8f305b2 100644 --- a/src/validation/rules/identifier.ts +++ b/src/validation/rules/identifier.ts @@ -1,7 +1,9 @@ -import type { ValidationRule } from ".."; +import type { ValidationRule, ValidationSeverity } from ".."; import { isDigit, isLetterOrDigit } from "../../helpers/stringUtils"; -const identifier: ValidationRule = (value: unknown): boolean => - typeof value === "string" && value.length > 0 && !isDigit(value[0]) && [...value].every((c) => isLetterOrDigit(c) || c === "_"); +const identifier: ValidationRule = (value: unknown): ValidationSeverity => { + const isValid: boolean = typeof value === "string" && value.length > 0 && !isDigit(value[0]) && [...value].every((c) => isLetterOrDigit(c) || c === "_"); + return isValid ? "information" : "error"; +}; export default identifier; diff --git a/src/validation/rules/required.ts b/src/validation/rules/required.ts index 0fffd36..e1b2f24 100644 --- a/src/validation/rules/required.ts +++ b/src/validation/rules/required.ts @@ -1,17 +1,24 @@ -import type { ValidationRule } from ".."; +import type { ValidationRule, ValidationSeverity } from ".."; import { isNullOrWhiteSpace } from "../../helpers/stringUtils"; -const required: ValidationRule = (value: unknown): boolean => { +const required: ValidationRule = (value: unknown): ValidationSeverity => { + let isValid: boolean = false; switch (typeof value) { case "number": - return !isNaN(value) && value !== 0; + isValid = !isNaN(value) && value !== 0; + break; case "string": - return !isNullOrWhiteSpace(value); + isValid = !isNullOrWhiteSpace(value); + break; + default: + if (Array.isArray(value)) { + isValid = value.length > 0; + } else { + isValid = Boolean(value); + } + break; } - if (Array.isArray(value)) { - return value.length > 0; - } - return Boolean(value); + return isValid ? "information" : "error"; }; export default required; diff --git a/src/validation/rules/slug.ts b/src/validation/rules/slug.ts index ab8b9d7..9c750c5 100644 --- a/src/validation/rules/slug.ts +++ b/src/validation/rules/slug.ts @@ -1,7 +1,9 @@ -import type { ValidationRule } from ".."; +import type { ValidationRule, ValidationSeverity } from ".."; import { isLetterOrDigit } from "../../helpers/stringUtils"; -const slug: ValidationRule = (value: unknown): boolean => - typeof value === "string" && value.split("-").every((word) => word.length > 0 && [...word].every(isLetterOrDigit)); +const slug: ValidationRule = (value: unknown): ValidationSeverity => { + const isValid: boolean = typeof value === "string" && value.split("-").every((word) => word.length > 0 && [...word].every(isLetterOrDigit)); + return isValid ? "information" : "error"; +}; export default slug; diff --git a/src/validation/rules/url.ts b/src/validation/rules/url.ts index 0581ed0..ec43cc4 100644 --- a/src/validation/rules/url.ts +++ b/src/validation/rules/url.ts @@ -1,17 +1,16 @@ -import type { ValidationRule } from ".."; +import type { ValidationRule, ValidationSeverity } from ".."; -const url: ValidationRule = (value: unknown): boolean => { +const url: ValidationRule = (value: unknown): ValidationSeverity => { + let isValid: boolean = false; const trimmed = typeof value === "string" ? value.trim() : ""; - if (!trimmed) { - return false; + if (trimmed) { + let url: URL; + try { + url = new URL(trimmed); + isValid = url.protocol === "http:" || url.protocol === "https:"; + } catch (_) {} } - let url: URL; - try { - url = new URL(trimmed); - } catch (_) { - return false; - } - return url.protocol === "http:" || url.protocol === "https:"; + return isValid ? "information" : "error"; }; export default url; From 369390526e942c4b56be7d1430bf7ae6a37f2f30 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 19:20:42 -0400 Subject: [PATCH 05/23] RuleExecutionResult --- src/validation/index.ts | 52 +++++++++++++++++------------- src/validation/rules/email.ts | 6 ++-- src/validation/rules/identifier.ts | 6 ++-- src/validation/rules/required.ts | 6 ++-- src/validation/rules/slug.ts | 6 ++-- src/validation/rules/url.ts | 6 ++-- 6 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index 8d9d344..d6ea7a0 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,12 +1,20 @@ -export type ValidationRule = (value: unknown) => boolean | ValidationSeverity; +export type RuleExecutionOutcome = { + severity: ValidationSeverity; +}; -export type ValidationRuleKey = string; +export type RuleExecutionResult = { + severity: ValidationSeverity; +}; export type ValidationResult = { isValid: boolean; - rules: Record; + rules: Record; }; +export type ValidationRule = (value: unknown) => boolean | ValidationSeverity | RuleExecutionOutcome; + +export type ValidationRuleKey = string; + export type ValidationRuleSet = Record; // https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=net-9.0-pp @@ -41,7 +49,7 @@ class Validator { validate(value: unknown, rules: ValidationRuleSet): ValidationResult { let errors: number = 0; - const results: Record = {}; + const results: Record = {}; const missingRules: string[] = []; for (const rule in rules) { @@ -51,40 +59,40 @@ class Validator { continue; } - const outcome: boolean | ValidationSeverity = validationRule(value); - switch (outcome) { - case "trace": - case "debug": - case "information": - results[rule] = outcome; + const result: RuleExecutionResult = { + severity: "error", + }; + const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = validationRule(value); + switch (typeof outcome) { + case "boolean": + result.severity = Boolean(outcome) ? "information" : "error"; + break; + case "string": + result.severity = outcome; break; + default: + result.severity = outcome.severity; + break; + } + switch (result.severity) { case "warning": - results[rule] = outcome; if (this.treatWarningsAsErrors) { errors++; } break; case "error": case "critical": - results[rule] = outcome; errors++; break; - case false: - results[rule] = "error"; - errors++; - break; - case true: - results[rule] = "information"; - break; - default: - throw new Error("not_implemented"); // TODO(fpion): implement } + results[rule] = result; } - return { + const result: ValidationResult = { isValid: errors === 0, rules: results, }; + return result; } } export default Validator; diff --git a/src/validation/rules/email.ts b/src/validation/rules/email.ts index 836ab25..0b9f347 100644 --- a/src/validation/rules/email.ts +++ b/src/validation/rules/email.ts @@ -1,11 +1,11 @@ -import type { ValidationRule, ValidationSeverity } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from ".."; // https://github.com/colinhacks/zod/blob/40e72f9eaf576985f876d1afc2dbc22f73abc1ba/src/types.ts#L595 const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; -const email: ValidationRule = (value: unknown): ValidationSeverity => { +const email: ValidationRule = (value: unknown): RuleExecutionOutcome => { const isValid: boolean = typeof value === "string" && regex.test(value); - return isValid ? "information" : "error"; + return { severity: isValid ? "information" : "error" }; }; export default email; diff --git a/src/validation/rules/identifier.ts b/src/validation/rules/identifier.ts index 8f305b2..d52a969 100644 --- a/src/validation/rules/identifier.ts +++ b/src/validation/rules/identifier.ts @@ -1,9 +1,9 @@ -import type { ValidationRule, ValidationSeverity } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from ".."; import { isDigit, isLetterOrDigit } from "../../helpers/stringUtils"; -const identifier: ValidationRule = (value: unknown): ValidationSeverity => { +const identifier: ValidationRule = (value: unknown): RuleExecutionOutcome => { const isValid: boolean = typeof value === "string" && value.length > 0 && !isDigit(value[0]) && [...value].every((c) => isLetterOrDigit(c) || c === "_"); - return isValid ? "information" : "error"; + return { severity: isValid ? "information" : "error" }; }; export default identifier; diff --git a/src/validation/rules/required.ts b/src/validation/rules/required.ts index e1b2f24..fe3c281 100644 --- a/src/validation/rules/required.ts +++ b/src/validation/rules/required.ts @@ -1,7 +1,7 @@ -import type { ValidationRule, ValidationSeverity } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from ".."; import { isNullOrWhiteSpace } from "../../helpers/stringUtils"; -const required: ValidationRule = (value: unknown): ValidationSeverity => { +const required: ValidationRule = (value: unknown): RuleExecutionOutcome => { let isValid: boolean = false; switch (typeof value) { case "number": @@ -18,7 +18,7 @@ const required: ValidationRule = (value: unknown): ValidationSeverity => { } break; } - return isValid ? "information" : "error"; + return { severity: isValid ? "information" : "error" }; }; export default required; diff --git a/src/validation/rules/slug.ts b/src/validation/rules/slug.ts index 9c750c5..edae11b 100644 --- a/src/validation/rules/slug.ts +++ b/src/validation/rules/slug.ts @@ -1,9 +1,9 @@ -import type { ValidationRule, ValidationSeverity } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from ".."; import { isLetterOrDigit } from "../../helpers/stringUtils"; -const slug: ValidationRule = (value: unknown): ValidationSeverity => { +const slug: ValidationRule = (value: unknown): RuleExecutionOutcome => { const isValid: boolean = typeof value === "string" && value.split("-").every((word) => word.length > 0 && [...word].every(isLetterOrDigit)); - return isValid ? "information" : "error"; + return { severity: isValid ? "information" : "error" }; }; export default slug; diff --git a/src/validation/rules/url.ts b/src/validation/rules/url.ts index ec43cc4..b4f32eb 100644 --- a/src/validation/rules/url.ts +++ b/src/validation/rules/url.ts @@ -1,6 +1,6 @@ -import type { ValidationRule, ValidationSeverity } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from ".."; -const url: ValidationRule = (value: unknown): ValidationSeverity => { +const url: ValidationRule = (value: unknown): RuleExecutionOutcome => { let isValid: boolean = false; const trimmed = typeof value === "string" ? value.trim() : ""; if (trimmed) { @@ -10,7 +10,7 @@ const url: ValidationRule = (value: unknown): ValidationSeverity => { isValid = url.protocol === "http:" || url.protocol === "https:"; } catch (_) {} } - return isValid ? "information" : "error"; + return { severity: isValid ? "information" : "error" }; }; export default url; From d4cc7cbeb9d3d596df69485362d03bb23442dc19 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 19:22:16 -0400 Subject: [PATCH 06/23] options --- src/validation/index.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index d6ea7a0..0b49c49 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -6,6 +6,11 @@ export type RuleExecutionResult = { severity: ValidationSeverity; }; +export type ValidationOptions = { + treatWarningsAsErrors?: boolean; + throwOnFailure?: boolean; +}; + export type ValidationResult = { isValid: boolean; rules: Record; @@ -47,7 +52,10 @@ class Validator { this.rules.set(key, rule); } - validate(value: unknown, rules: ValidationRuleSet): ValidationResult { + validate(value: unknown, rules: ValidationRuleSet, options?: ValidationOptions): ValidationResult { + options = options ?? {}; + const treatWarningsAsErrors: boolean = options.treatWarningsAsErrors ?? this.treatWarningsAsErrors; + let errors: number = 0; const results: Record = {}; @@ -76,7 +84,7 @@ class Validator { } switch (result.severity) { case "warning": - if (this.treatWarningsAsErrors) { + if (treatWarningsAsErrors) { errors++; } break; @@ -92,6 +100,9 @@ class Validator { isValid: errors === 0, rules: results, }; + if (!result.isValid && options.throwOnFailure) { + throw result; + } return result; } } From 261dccfa0ab47d9455c5201e09b621a59214e7f6 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 19:25:09 -0400 Subject: [PATCH 07/23] result.value --- src/validation/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/validation/index.ts b/src/validation/index.ts index 0b49c49..fc1921f 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,9 +1,11 @@ export type RuleExecutionOutcome = { severity: ValidationSeverity; + value?: unknown; }; export type RuleExecutionResult = { severity: ValidationSeverity; + value: unknown; }; export type ValidationOptions = { @@ -69,6 +71,7 @@ class Validator { const result: RuleExecutionResult = { severity: "error", + value, }; const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = validationRule(value); switch (typeof outcome) { @@ -80,6 +83,9 @@ class Validator { break; default: result.severity = outcome.severity; + if (typeof outcome.value !== "undefined") { + result.value = outcome.value; + } break; } switch (result.severity) { From 7e2a354c9327f7aa77cb1fc24841bef677be56f8 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 19:33:16 -0400 Subject: [PATCH 08/23] name+value --- src/validation/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index fc1921f..acac940 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,10 +1,14 @@ +import { isNullOrWhiteSpace } from "../helpers/stringUtils"; + export type RuleExecutionOutcome = { severity: ValidationSeverity; + name?: string; value?: unknown; }; export type RuleExecutionResult = { severity: ValidationSeverity; + name: string; value: unknown; }; @@ -54,7 +58,7 @@ class Validator { this.rules.set(key, rule); } - validate(value: unknown, rules: ValidationRuleSet, options?: ValidationOptions): ValidationResult { + validate(name: string, value: unknown, rules: ValidationRuleSet, options?: ValidationOptions): ValidationResult { options = options ?? {}; const treatWarningsAsErrors: boolean = options.treatWarningsAsErrors ?? this.treatWarningsAsErrors; @@ -71,6 +75,7 @@ class Validator { const result: RuleExecutionResult = { severity: "error", + name, value, }; const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = validationRule(value); @@ -83,6 +88,9 @@ class Validator { break; default: result.severity = outcome.severity; + if (!isNullOrWhiteSpace(outcome.name)) { + result.name = outcome.name; + } if (typeof outcome.value !== "undefined") { result.value = outcome.value; } From f2f67f26e7c69b2800330400bde75c9002db7e95 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 19:51:57 -0400 Subject: [PATCH 09/23] key --- src/validation/index.ts | 43 +++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index acac940..44f2724 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -2,16 +2,27 @@ import { isNullOrWhiteSpace } from "../helpers/stringUtils"; export type RuleExecutionOutcome = { severity: ValidationSeverity; + key?: string; name?: string; value?: unknown; }; export type RuleExecutionResult = { + key: string; severity: ValidationSeverity; name: string; value: unknown; }; +export type RuleConfiguration = { + rule: ValidationRule; + options: RuleOptions; +}; + +export type RuleOptions = { + key?: string; +}; + export type ValidationOptions = { treatWarningsAsErrors?: boolean; throwOnFailure?: boolean; @@ -32,7 +43,7 @@ export type ValidationRuleSet = Record; export type ValidationSeverity = "trace" | "debug" | "information" | "warning" | "error" | "critical"; class Validator { - private readonly rules: Map = new Map(); + private readonly rules: Map = new Map(); private readonly treatWarningsAsErrors: boolean; constructor(treatWarningsAsErrors?: boolean) { @@ -42,43 +53,46 @@ class Validator { clearRules(): void { this.rules.clear(); } - getRule(key: ValidationRuleKey): ValidationRule | undefined { + getRule(key: ValidationRuleKey): RuleConfiguration | undefined { return this.rules.get(key); } hasRule(key: ValidationRuleKey): boolean { return this.rules.has(key); } - listRules(): [ValidationRuleKey, ValidationRule][] { + listRules(): [ValidationRuleKey, RuleConfiguration][] { return [...this.rules.entries()]; } removeRule(key: ValidationRuleKey): boolean { return this.rules.delete(key); } - setRule(key: ValidationRuleKey, rule: ValidationRule): void { - this.rules.set(key, rule); + setRule(key: ValidationRuleKey, rule: ValidationRule, options?: RuleOptions): void { + options ??= options; + const configuration: RuleConfiguration = { rule, options }; + this.rules.set(key, configuration); } validate(name: string, value: unknown, rules: ValidationRuleSet, options?: ValidationOptions): ValidationResult { - options = options ?? {}; + options ??= {}; const treatWarningsAsErrors: boolean = options.treatWarningsAsErrors ?? this.treatWarningsAsErrors; let errors: number = 0; const results: Record = {}; const missingRules: string[] = []; - for (const rule in rules) { - const validationRule: ValidationRule | undefined = this.rules.get(rule); - if (!validationRule) { - missingRules.push(rule); + for (const key in rules) { + const configuration: RuleConfiguration | undefined = this.rules.get(key); + if (!configuration) { + missingRules.push(key); continue; } const result: RuleExecutionResult = { + key, severity: "error", name, value, }; - const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = validationRule(value); + const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = configuration.rule(value); switch (typeof outcome) { case "boolean": result.severity = Boolean(outcome) ? "information" : "error"; @@ -88,6 +102,11 @@ class Validator { break; default: result.severity = outcome.severity; + if (!isNullOrWhiteSpace(configuration.options.key)) { + result.key = configuration.options.key; + } else if (!isNullOrWhiteSpace(outcome.key)) { + result.key = outcome.key; + } if (!isNullOrWhiteSpace(outcome.name)) { result.name = outcome.name; } @@ -107,7 +126,7 @@ class Validator { errors++; break; } - results[rule] = result; + results[key] = result; } const result: ValidationResult = { From a3df6e226ba96701119a91f0a5926ac86a31f4b3 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 19:53:05 -0400 Subject: [PATCH 10/23] ignore falsy args --- src/validation/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/validation/index.ts b/src/validation/index.ts index 44f2724..21879c8 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -86,6 +86,11 @@ class Validator { continue; } + const args: unknown = rules[key]; + if (!args) { + continue; + } + const result: RuleExecutionResult = { key, severity: "error", From 50ee89a4ba51e509219bca4e176d68dceba75f98 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 21:31:14 -0400 Subject: [PATCH 11/23] message --- src/validation/index.ts | 8 ++++++++ src/validation/rules/email.ts | 10 +++++++--- src/validation/rules/identifier.ts | 14 +++++++++++--- src/validation/rules/required.ts | 19 ++++++++++++------- src/validation/rules/slug.ts | 10 +++++++--- src/validation/rules/url.ts | 24 +++++++++++++++--------- 6 files changed, 60 insertions(+), 25 deletions(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index 21879c8..69e9cb8 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -3,6 +3,7 @@ import { isNullOrWhiteSpace } from "../helpers/stringUtils"; export type RuleExecutionOutcome = { severity: ValidationSeverity; key?: string; + message?: string; name?: string; value?: unknown; }; @@ -10,6 +11,7 @@ export type RuleExecutionOutcome = { export type RuleExecutionResult = { key: string; severity: ValidationSeverity; + message?: string; name: string; value: unknown; }; @@ -21,6 +23,7 @@ export type RuleConfiguration = { export type RuleOptions = { key?: string; + message?: string; }; export type ValidationOptions = { @@ -112,6 +115,11 @@ class Validator { } else if (!isNullOrWhiteSpace(outcome.key)) { result.key = outcome.key; } + if (!isNullOrWhiteSpace(configuration.options.message)) { + result.message = configuration.options.message; + } else if (!isNullOrWhiteSpace(configuration.options.message)) { + result.message = outcome.message; + } if (!isNullOrWhiteSpace(outcome.name)) { result.name = outcome.name; } diff --git a/src/validation/rules/email.ts b/src/validation/rules/email.ts index 0b9f347..6e8f640 100644 --- a/src/validation/rules/email.ts +++ b/src/validation/rules/email.ts @@ -1,11 +1,15 @@ import type { RuleExecutionOutcome, ValidationRule } from ".."; // https://github.com/colinhacks/zod/blob/40e72f9eaf576985f876d1afc2dbc22f73abc1ba/src/types.ts#L595 -const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; +const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; // TODO(fpion): override from args const email: ValidationRule = (value: unknown): RuleExecutionOutcome => { - const isValid: boolean = typeof value === "string" && regex.test(value); - return { severity: isValid ? "information" : "error" }; + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } else if (!regex.test(value)) { + return { severity: "error", message: "{{name}} must be a valid email address." }; + } + return { severity: "information" }; }; export default email; diff --git a/src/validation/rules/identifier.ts b/src/validation/rules/identifier.ts index d52a969..265a40e 100644 --- a/src/validation/rules/identifier.ts +++ b/src/validation/rules/identifier.ts @@ -1,9 +1,17 @@ import type { RuleExecutionOutcome, ValidationRule } from ".."; -import { isDigit, isLetterOrDigit } from "../../helpers/stringUtils"; +import { isDigit, isLetterOrDigit, isNullOrEmpty } from "../../helpers/stringUtils"; const identifier: ValidationRule = (value: unknown): RuleExecutionOutcome => { - const isValid: boolean = typeof value === "string" && value.length > 0 && !isDigit(value[0]) && [...value].every((c) => isLetterOrDigit(c) || c === "_"); - return { severity: isValid ? "information" : "error" }; + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } else if (isNullOrEmpty(value)) { + return { severity: "error", message: "{{name}} cannot be an empty string." }; + } else if (isDigit(value[0])) { + return { severity: "error", message: "{{name}} cannot start with a digit." }; + } else if ([...value].some((c) => !isLetterOrDigit(c) && c !== "_")) { + return { severity: "error", message: "{{name}} may only contain letters, digits and underscores (_)." }; + } + return { severity: "information" }; }; export default identifier; diff --git a/src/validation/rules/required.ts b/src/validation/rules/required.ts index fe3c281..b5033e4 100644 --- a/src/validation/rules/required.ts +++ b/src/validation/rules/required.ts @@ -2,23 +2,28 @@ import type { RuleExecutionOutcome, ValidationRule } from ".."; import { isNullOrWhiteSpace } from "../../helpers/stringUtils"; const required: ValidationRule = (value: unknown): RuleExecutionOutcome => { - let isValid: boolean = false; switch (typeof value) { case "number": - isValid = !isNaN(value) && value !== 0; + if (isNaN(value) || value === 0) { + return { severity: "error", message: "{{name}} must be a number different from 0." }; + } break; case "string": - isValid = !isNullOrWhiteSpace(value); + if (isNullOrWhiteSpace(value)) { + return { severity: "error", message: "{{name}} cannot be an empty string." }; + } break; default: if (Array.isArray(value)) { - isValid = value.length > 0; - } else { - isValid = Boolean(value); + if (value.length === 0) { + return { severity: "error", message: "{{name}} cannot be an empty array." }; + } + } else if (!Boolean(value)) { + return { severity: "error", message: "{{name}} is required." }; } break; } - return { severity: isValid ? "information" : "error" }; + return { severity: "information" }; }; export default required; diff --git a/src/validation/rules/slug.ts b/src/validation/rules/slug.ts index edae11b..803b0e5 100644 --- a/src/validation/rules/slug.ts +++ b/src/validation/rules/slug.ts @@ -1,9 +1,13 @@ import type { RuleExecutionOutcome, ValidationRule } from ".."; -import { isLetterOrDigit } from "../../helpers/stringUtils"; +import { isLetterOrDigit, isNullOrEmpty } from "../../helpers/stringUtils"; const slug: ValidationRule = (value: unknown): RuleExecutionOutcome => { - const isValid: boolean = typeof value === "string" && value.split("-").every((word) => word.length > 0 && [...word].every(isLetterOrDigit)); - return { severity: isValid ? "information" : "error" }; + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } else if (value.split("-").some((word) => isNullOrEmpty(word) || [...word].some((c) => !isLetterOrDigit(c)))) { + return { severity: "error", message: "{{name}} must be composed of non-empty alphanumeric words separated by hyphens (-)." }; + } + return { severity: "information" }; }; export default slug; diff --git a/src/validation/rules/url.ts b/src/validation/rules/url.ts index b4f32eb..b43d8e5 100644 --- a/src/validation/rules/url.ts +++ b/src/validation/rules/url.ts @@ -1,16 +1,22 @@ import type { RuleExecutionOutcome, ValidationRule } from ".."; +import { isNullOrWhiteSpace } from "../../helpers/stringUtils"; const url: ValidationRule = (value: unknown): RuleExecutionOutcome => { - let isValid: boolean = false; - const trimmed = typeof value === "string" ? value.trim() : ""; - if (trimmed) { - let url: URL; - try { - url = new URL(trimmed); - isValid = url.protocol === "http:" || url.protocol === "https:"; - } catch (_) {} + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } else if (isNullOrWhiteSpace(value)) { + return { severity: "error", message: "{{name}} cannot be an empty string." }; } - return { severity: isValid ? "information" : "error" }; + let url: URL; + try { + url = new URL(value.trim()); + } catch (_) { + return { severity: "error", message: "{{name}} must be a valid URL." }; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { severity: "error", message: "{{name}} must be an URL with one of the following schemes: http, https" }; + } + return { severity: "information" }; }; export default url; From adcb0370a1f85331da77c5e1b6113e9a2d2fb124 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 22:06:09 -0400 Subject: [PATCH 12/23] placeholders --- src/validation/index.ts | 99 +++++++++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 28 deletions(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index 69e9cb8..c4452eb 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -4,6 +4,7 @@ export type RuleExecutionOutcome = { severity: ValidationSeverity; key?: string; message?: string; + placeholders?: Record; name?: string; value?: unknown; }; @@ -12,6 +13,7 @@ export type RuleExecutionResult = { key: string; severity: ValidationSeverity; message?: string; + placeholders: Record; name: string; value: unknown; }; @@ -24,9 +26,11 @@ export type RuleConfiguration = { export type RuleOptions = { key?: string; message?: string; + placeholders?: Record; }; export type ValidationOptions = { + placeholders?: Record; treatWarningsAsErrors?: boolean; throwOnFailure?: boolean; }; @@ -45,6 +49,48 @@ export type ValidationRuleSet = Record; // https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=net-9.0-pp export type ValidationSeverity = "trace" | "debug" | "information" | "warning" | "error" | "critical"; +function apply(outcome: RuleExecutionOutcome, result: RuleExecutionResult, options: RuleOptions): void { + // severity + result.severity = outcome.severity; + // key + if (!isNullOrWhiteSpace(options.key)) { + result.key = options.key; + } else if (!isNullOrWhiteSpace(outcome.key)) { + result.key = outcome.key; + } + // message + if (!isNullOrWhiteSpace(options.message)) { + result.message = options.message; + } else if (!isNullOrWhiteSpace(options.message)) { + result.message = outcome.message; + } + // name + if (!isNullOrWhiteSpace(outcome.name)) { + result.name = outcome.name; + } + // value + if (typeof outcome.value !== "undefined") { + result.value = outcome.value; + } +} + +function fillPlaceholders(result: RuleExecutionResult, outcome?: RuleExecutionOutcome, rule?: RuleOptions, validation?: ValidationOptions): void { + result.placeholders.key = result.key; + result.placeholders.name = result.name; + result.placeholders.value = result.value; + result.placeholders.severity = result.severity; + + if (outcome && outcome.placeholders) { + result.placeholders = { ...result.placeholders, ...outcome.placeholders }; + } + if (rule && rule.placeholders) { + result.placeholders = { ...result.placeholders, ...rule.placeholders }; + } + if (validation && validation.placeholders) { + result.placeholders = { ...result.placeholders, ...validation.placeholders }; + } +} + class Validator { private readonly rules: Map = new Map(); private readonly treatWarningsAsErrors: boolean; @@ -76,7 +122,6 @@ class Validator { validate(name: string, value: unknown, rules: ValidationRuleSet, options?: ValidationOptions): ValidationResult { options ??= {}; - const treatWarningsAsErrors: boolean = options.treatWarningsAsErrors ?? this.treatWarningsAsErrors; let errors: number = 0; const results: Record = {}; @@ -97,9 +142,11 @@ class Validator { const result: RuleExecutionResult = { key, severity: "error", + placeholders: { [key]: args }, name, value, }; + const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = configuration.rule(value); switch (typeof outcome) { case "boolean": @@ -109,36 +156,18 @@ class Validator { result.severity = outcome; break; default: - result.severity = outcome.severity; - if (!isNullOrWhiteSpace(configuration.options.key)) { - result.key = configuration.options.key; - } else if (!isNullOrWhiteSpace(outcome.key)) { - result.key = outcome.key; - } - if (!isNullOrWhiteSpace(configuration.options.message)) { - result.message = configuration.options.message; - } else if (!isNullOrWhiteSpace(configuration.options.message)) { - result.message = outcome.message; - } - if (!isNullOrWhiteSpace(outcome.name)) { - result.name = outcome.name; - } - if (typeof outcome.value !== "undefined") { - result.value = outcome.value; - } + apply(outcome, result, configuration.options); break; } - switch (result.severity) { - case "warning": - if (treatWarningsAsErrors) { - errors++; - } - break; - case "error": - case "critical": - errors++; - break; + + fillPlaceholders(result, typeof outcome === "object" ? outcome : undefined, configuration.options, options); + + // TODO(fpion): format message + + if (this.isError(result.severity, options)) { + errors++; } + results[key] = result; } @@ -151,5 +180,19 @@ class Validator { } return result; } + private isError(severity: ValidationSeverity, options?: ValidationOptions): boolean { + options ??= {}; + switch (severity) { + case "error": + case "critical": + return true; + case "warning": + if (options.treatWarningsAsErrors ?? this.treatWarningsAsErrors) { + return true; + } + break; + } + return false; + } } export default Validator; From 141c4c628e14392891b85d654f0ec7b1b6230549 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 22:30:18 -0400 Subject: [PATCH 13/23] format --- src/validation/format.ts | 15 ++++++ src/validation/index.ts | 77 ++++++++++-------------------- src/validation/rules/email.ts | 2 +- src/validation/rules/identifier.ts | 2 +- src/validation/rules/required.ts | 2 +- src/validation/rules/slug.ts | 2 +- src/validation/rules/url.ts | 2 +- src/validation/types.ts | 56 ++++++++++++++++++++++ 8 files changed, 101 insertions(+), 57 deletions(-) create mode 100644 src/validation/format.ts create mode 100644 src/validation/types.ts diff --git a/src/validation/format.ts b/src/validation/format.ts new file mode 100644 index 0000000..7752b0c --- /dev/null +++ b/src/validation/format.ts @@ -0,0 +1,15 @@ +export interface MessageFormatter { + format(message: string, placeholders: Record): string; +} + +export default class DefaultMessageFormatter implements MessageFormatter { + format(message: string, placeholders: Record): string { + let formatted: string = message; + for (const key in placeholders) { + const pattern = `{{${key}}}`; + const replacement = String(placeholders[key]); + formatted = formatted.split(pattern).join(replacement); + } + return formatted; + } +} diff --git a/src/validation/index.ts b/src/validation/index.ts index c4452eb..e0df1a2 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -1,53 +1,18 @@ +import DefaultMessageFormatter, { MessageFormatter } from "./format"; import { isNullOrWhiteSpace } from "../helpers/stringUtils"; - -export type RuleExecutionOutcome = { - severity: ValidationSeverity; - key?: string; - message?: string; - placeholders?: Record; - name?: string; - value?: unknown; -}; - -export type RuleExecutionResult = { - key: string; - severity: ValidationSeverity; - message?: string; - placeholders: Record; - name: string; - value: unknown; -}; - -export type RuleConfiguration = { - rule: ValidationRule; - options: RuleOptions; -}; - -export type RuleOptions = { - key?: string; - message?: string; - placeholders?: Record; -}; - -export type ValidationOptions = { - placeholders?: Record; - treatWarningsAsErrors?: boolean; - throwOnFailure?: boolean; -}; - -export type ValidationResult = { - isValid: boolean; - rules: Record; -}; - -export type ValidationRule = (value: unknown) => boolean | ValidationSeverity | RuleExecutionOutcome; - -export type ValidationRuleKey = string; - -export type ValidationRuleSet = Record; - -// https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=net-9.0-pp -export type ValidationSeverity = "trace" | "debug" | "information" | "warning" | "error" | "critical"; +import type { + RuleConfiguration, + RuleExecutionOutcome, + RuleExecutionResult, + RuleOptions, + ValidationOptions, + ValidationResult, + ValidationRule, + ValidationRuleKey, + ValidationRuleSet, + ValidationSeverity, + ValidatorOptions, +} from "./types"; function apply(outcome: RuleExecutionOutcome, result: RuleExecutionResult, options: RuleOptions): void { // severity @@ -92,11 +57,14 @@ function fillPlaceholders(result: RuleExecutionResult, outcome?: RuleExecutionOu } class Validator { + private readonly messageFormatter: MessageFormatter; private readonly rules: Map = new Map(); private readonly treatWarningsAsErrors: boolean; - constructor(treatWarningsAsErrors?: boolean) { - this.treatWarningsAsErrors = treatWarningsAsErrors ?? false; + constructor(options?: ValidatorOptions) { + options ??= options; + this.messageFormatter = options.messageFormatter ?? new DefaultMessageFormatter(); + this.treatWarningsAsErrors = options.treatWarningsAsErrors ?? false; } clearRules(): void { @@ -162,7 +130,7 @@ class Validator { fillPlaceholders(result, typeof outcome === "object" ? outcome : undefined, configuration.options, options); - // TODO(fpion): format message + this.formatMessage(result, options); if (this.isError(result.severity, options)) { errors++; @@ -180,6 +148,11 @@ class Validator { } return result; } + private formatMessage(result: RuleExecutionResult, options?: ValidationOptions): void { + options ??= {}; + const messageFormatter: MessageFormatter = options.messageFormatter ?? this.messageFormatter; + result.message = messageFormatter.format(result.message, result.placeholders); + } private isError(severity: ValidationSeverity, options?: ValidationOptions): boolean { options ??= {}; switch (severity) { diff --git a/src/validation/rules/email.ts b/src/validation/rules/email.ts index 6e8f640..5ea818c 100644 --- a/src/validation/rules/email.ts +++ b/src/validation/rules/email.ts @@ -1,4 +1,4 @@ -import type { RuleExecutionOutcome, ValidationRule } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from "../types"; // https://github.com/colinhacks/zod/blob/40e72f9eaf576985f876d1afc2dbc22f73abc1ba/src/types.ts#L595 const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; // TODO(fpion): override from args diff --git a/src/validation/rules/identifier.ts b/src/validation/rules/identifier.ts index 265a40e..4729291 100644 --- a/src/validation/rules/identifier.ts +++ b/src/validation/rules/identifier.ts @@ -1,4 +1,4 @@ -import type { RuleExecutionOutcome, ValidationRule } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isDigit, isLetterOrDigit, isNullOrEmpty } from "../../helpers/stringUtils"; const identifier: ValidationRule = (value: unknown): RuleExecutionOutcome => { diff --git a/src/validation/rules/required.ts b/src/validation/rules/required.ts index b5033e4..111da43 100644 --- a/src/validation/rules/required.ts +++ b/src/validation/rules/required.ts @@ -1,4 +1,4 @@ -import type { RuleExecutionOutcome, ValidationRule } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isNullOrWhiteSpace } from "../../helpers/stringUtils"; const required: ValidationRule = (value: unknown): RuleExecutionOutcome => { diff --git a/src/validation/rules/slug.ts b/src/validation/rules/slug.ts index 803b0e5..a66e000 100644 --- a/src/validation/rules/slug.ts +++ b/src/validation/rules/slug.ts @@ -1,4 +1,4 @@ -import type { RuleExecutionOutcome, ValidationRule } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isLetterOrDigit, isNullOrEmpty } from "../../helpers/stringUtils"; const slug: ValidationRule = (value: unknown): RuleExecutionOutcome => { diff --git a/src/validation/rules/url.ts b/src/validation/rules/url.ts index b43d8e5..40b6c34 100644 --- a/src/validation/rules/url.ts +++ b/src/validation/rules/url.ts @@ -1,4 +1,4 @@ -import type { RuleExecutionOutcome, ValidationRule } from ".."; +import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isNullOrWhiteSpace } from "../../helpers/stringUtils"; const url: ValidationRule = (value: unknown): RuleExecutionOutcome => { diff --git a/src/validation/types.ts b/src/validation/types.ts new file mode 100644 index 0000000..1ab8471 --- /dev/null +++ b/src/validation/types.ts @@ -0,0 +1,56 @@ +import { MessageFormatter } from "./format"; + +export type RuleExecutionOutcome = { + severity: ValidationSeverity; + key?: string; + message?: string; + placeholders?: Record; + name?: string; + value?: unknown; +}; + +export type RuleExecutionResult = { + key: string; + severity: ValidationSeverity; + message?: string; + placeholders: Record; + name: string; + value: unknown; +}; + +export type RuleConfiguration = { + rule: ValidationRule; + options: RuleOptions; +}; + +export type RuleOptions = { + key?: string; + message?: string; + placeholders?: Record; +}; + +export type ValidationOptions = { + messageFormatter?: MessageFormatter; + placeholders?: Record; + treatWarningsAsErrors?: boolean; + throwOnFailure?: boolean; +}; + +export type ValidationResult = { + isValid: boolean; + rules: Record; +}; + +export type ValidationRule = (value: unknown) => boolean | ValidationSeverity | RuleExecutionOutcome; + +export type ValidationRuleKey = string; + +export type ValidationRuleSet = Record; + +// https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=net-9.0-pp +export type ValidationSeverity = "trace" | "debug" | "information" | "warning" | "error" | "critical"; + +export type ValidatorOptions = { + messageFormatter?: MessageFormatter; + treatWarningsAsErrors?: boolean; +}; From 0202422b6d9ff6db3f3cfafe306cab3c9002e970 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 22:32:07 -0400 Subject: [PATCH 14/23] reorder --- src/validation/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index e0df1a2..b1a6405 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -14,7 +14,7 @@ import type { ValidatorOptions, } from "./types"; -function apply(outcome: RuleExecutionOutcome, result: RuleExecutionResult, options: RuleOptions): void { +function apply(result: RuleExecutionResult, outcome: RuleExecutionOutcome, options: RuleOptions): void { // severity result.severity = outcome.severity; // key @@ -124,7 +124,7 @@ class Validator { result.severity = outcome; break; default: - apply(outcome, result, configuration.options); + apply(result, outcome, configuration.options); break; } From 4f2e0aa6aa2a0fe0f462c8829b0ec4f09b089c86 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 23:13:14 -0400 Subject: [PATCH 15/23] rules --- src/validation/index.ts | 2 +- src/validation/rules/allowedCharacters.ts | 21 ++++++++++++++++ src/validation/rules/confirm.ts | 11 +++++++++ src/validation/rules/containsDigits.ts | 22 +++++++++++++++++ src/validation/rules/containsLowercase.ts | 22 +++++++++++++++++ .../rules/containsNonAlphanumeric.ts | 22 +++++++++++++++++ src/validation/rules/containsUppercase.ts | 22 +++++++++++++++++ src/validation/rules/email.ts | 22 ++++++++++++++--- src/validation/rules/maximumLength.ts | 24 +++++++++++++++++++ src/validation/rules/maximumValue.ts | 14 +++++++++++ src/validation/rules/minimumLength.ts | 24 +++++++++++++++++++ src/validation/rules/minimumValue.ts | 14 +++++++++++ src/validation/rules/pattern.ts | 14 +++++++++++ src/validation/rules/uniqueCharacters.ts | 21 ++++++++++++++++ src/validation/types.ts | 2 +- 15 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 src/validation/rules/allowedCharacters.ts create mode 100644 src/validation/rules/confirm.ts create mode 100644 src/validation/rules/containsDigits.ts create mode 100644 src/validation/rules/containsLowercase.ts create mode 100644 src/validation/rules/containsNonAlphanumeric.ts create mode 100644 src/validation/rules/containsUppercase.ts create mode 100644 src/validation/rules/maximumLength.ts create mode 100644 src/validation/rules/maximumValue.ts create mode 100644 src/validation/rules/minimumLength.ts create mode 100644 src/validation/rules/minimumValue.ts create mode 100644 src/validation/rules/pattern.ts create mode 100644 src/validation/rules/uniqueCharacters.ts diff --git a/src/validation/index.ts b/src/validation/index.ts index b1a6405..28a67d0 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -115,7 +115,7 @@ class Validator { value, }; - const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = configuration.rule(value); + const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = configuration.rule(value, args); switch (typeof outcome) { case "boolean": result.severity = Boolean(outcome) ? "information" : "error"; diff --git a/src/validation/rules/allowedCharacters.ts b/src/validation/rules/allowedCharacters.ts new file mode 100644 index 0000000..b963162 --- /dev/null +++ b/src/validation/rules/allowedCharacters.ts @@ -0,0 +1,21 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; + +const allowedCharacters: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } else if (typeof args !== "string") { + return { severity: "warning", message: "The arguments must be a string containing the allowed characters." }; + } + + const prohibitedCharacters = new Set([...value].filter((c) => !args.includes(c))); + if (prohibitedCharacters.size > 0) { + return { + severity: "error", + message: `{{name}} contains the following prohibited characters: ${[...prohibitedCharacters].join("")}. Only the following characters are allowed: {{allowedCharacters}}`, + }; + } + + return { severity: "information" }; +}; + +export default allowedCharacters; diff --git a/src/validation/rules/confirm.ts b/src/validation/rules/confirm.ts new file mode 100644 index 0000000..4196f02 --- /dev/null +++ b/src/validation/rules/confirm.ts @@ -0,0 +1,11 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; + +const confirm: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + const isValid: boolean = typeof value === "object" || typeof args === "object" ? JSON.stringify(value) === JSON.stringify(args) : value === args; + if (!isValid) { + return { severity: "error", message: "{{name}} must equal {{confirm}}." }; + } + return { severity: "information" }; +}; + +export default confirm; diff --git a/src/validation/rules/containsDigits.ts b/src/validation/rules/containsDigits.ts new file mode 100644 index 0000000..a206676 --- /dev/null +++ b/src/validation/rules/containsDigits.ts @@ -0,0 +1,22 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; +import { isDigit } from "../../helpers/stringUtils"; + +const containsDigits: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } + + const requiredDigits: number = Number(args); + if (!isNaN(requiredDigits) || requiredDigits <= 0) { + return { severity: "warning", message: "The arguments should be a positive number." }; + } + + const digits: number = [...value].filter(isDigit).length; + if (digits < requiredDigits) { + return { severity: "error", message: "{{name}} must contain at least {{containsDigits}} digit(s)." }; + } + + return { severity: "information" }; +}; + +export default containsDigits; diff --git a/src/validation/rules/containsLowercase.ts b/src/validation/rules/containsLowercase.ts new file mode 100644 index 0000000..89bbdb4 --- /dev/null +++ b/src/validation/rules/containsLowercase.ts @@ -0,0 +1,22 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; +import { isLetter } from "../../helpers/stringUtils"; + +const containsLowercase: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } + + const requiredLowercase: number = Number(args); + if (!isNaN(requiredLowercase) || requiredLowercase <= 0) { + return { severity: "warning", message: "The arguments should be a positive number." }; + } + + const lowercase: number = [...value].filter((c) => isLetter(c) && c.toLowerCase() === c).length; + if (lowercase < requiredLowercase) { + return { severity: "error", message: "{{name}} must contain at least {{containsLowercase}} lowercase letter(s)." }; + } + + return { severity: "information" }; +}; + +export default containsLowercase; diff --git a/src/validation/rules/containsNonAlphanumeric.ts b/src/validation/rules/containsNonAlphanumeric.ts new file mode 100644 index 0000000..461ca46 --- /dev/null +++ b/src/validation/rules/containsNonAlphanumeric.ts @@ -0,0 +1,22 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; +import { isLetterOrDigit } from "../../helpers/stringUtils"; + +const containsNonAlphanumeric: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } + + const requiredNonAlphanumeric: number = Number(args); + if (!isNaN(requiredNonAlphanumeric) || requiredNonAlphanumeric <= 0) { + return { severity: "warning", message: "The arguments should be a positive number." }; + } + + const nonAlphanumeric: number = [...value].filter((c) => !isLetterOrDigit(c)).length; + if (nonAlphanumeric < requiredNonAlphanumeric) { + return { severity: "error", message: "{{name}} must contain at least {{containsNonAlphanumeric}} non-alphanumeric character(s)." }; + } + + return { severity: "information" }; +}; + +export default containsNonAlphanumeric; diff --git a/src/validation/rules/containsUppercase.ts b/src/validation/rules/containsUppercase.ts new file mode 100644 index 0000000..7600115 --- /dev/null +++ b/src/validation/rules/containsUppercase.ts @@ -0,0 +1,22 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; +import { isLetter } from "../../helpers/stringUtils"; + +const containsUppercase: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } + + const requiredUppercase: number = Number(args); + if (!isNaN(requiredUppercase) || requiredUppercase <= 0) { + return { severity: "warning", message: "The arguments should be a positive number." }; + } + + const uppercase: number = [...value].filter((c) => isLetter(c) && c.toUpperCase() === c).length; + if (uppercase < requiredUppercase) { + return { severity: "error", message: "{{name}} must contain at least {{containsUppercase}} Uppercase letter(s)." }; + } + + return { severity: "information" }; +}; + +export default containsUppercase; diff --git a/src/validation/rules/email.ts b/src/validation/rules/email.ts index 5ea818c..44190b3 100644 --- a/src/validation/rules/email.ts +++ b/src/validation/rules/email.ts @@ -1,14 +1,30 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; // https://github.com/colinhacks/zod/blob/40e72f9eaf576985f876d1afc2dbc22f73abc1ba/src/types.ts#L595 -const regex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; // TODO(fpion): override from args +const defaultRegex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; -const email: ValidationRule = (value: unknown): RuleExecutionOutcome => { +const email: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; - } else if (!regex.test(value)) { + } + + let isArgsValid: boolean = true; + let regex: RegExp; + if (typeof args === "string" || args instanceof RegExp) { + regex = new RegExp(args); + } else { + regex = new RegExp(defaultRegex); + if (typeof args !== "undefined") { + isArgsValid = false; + } + } + + if (!regex.test(value)) { return { severity: "error", message: "{{name}} must be a valid email address." }; + } else if (!isArgsValid) { + return { severity: "warning", message: "The arguments must be undefined, or a valid email address validation regular expression." }; } + return { severity: "information" }; }; diff --git a/src/validation/rules/maximumLength.ts b/src/validation/rules/maximumLength.ts new file mode 100644 index 0000000..83e1bdf --- /dev/null +++ b/src/validation/rules/maximumLength.ts @@ -0,0 +1,24 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; + +const maximumLength: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + const maximumLength: number = Number(args); + if (!isNaN(maximumLength) || maximumLength <= 0) { + return { severity: "warning", message: "The arguments should be a positive number." }; + } + + if (typeof value === "string") { + if (value.length > maximumLength) { + return { severity: "error", message: "{{name}} must be at most {{maximumLength}} characters long." }; + } + } else if (Array.isArray(value)) { + if (value.length > maximumLength) { + return { severity: "error", message: "{{name}} must contain at most {{maximumLength}} elements." }; + } + } else { + return { severity: "error", message: "{{name}} must be a string or an array." }; + } + + return { severity: "information" }; +}; + +export default maximumLength; diff --git a/src/validation/rules/maximumValue.ts b/src/validation/rules/maximumValue.ts new file mode 100644 index 0000000..f910f0a --- /dev/null +++ b/src/validation/rules/maximumValue.ts @@ -0,0 +1,14 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; + +const maximumValue: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + try { + if (value > args) { + return { severity: "error", message: "{{name}} must be at most {{maximumValue}}." }; + } + } catch (_) { + return { severity: "warning", message: `Could not compare {{name}} ({{value}} | ${typeof value}) with args ({{maximumValue}} | ${typeof args}).` }; + } + return { severity: "information" }; +}; + +export default maximumValue; diff --git a/src/validation/rules/minimumLength.ts b/src/validation/rules/minimumLength.ts new file mode 100644 index 0000000..6df9e59 --- /dev/null +++ b/src/validation/rules/minimumLength.ts @@ -0,0 +1,24 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; + +const minimumLength: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + const minimumLength: number = Number(args); + if (!isNaN(minimumLength) || minimumLength <= 0) { + return { severity: "warning", message: "The arguments should be a positive number." }; + } + + if (typeof value === "string") { + if (value.length < minimumLength) { + return { severity: "error", message: "{{name}} must be at least {{maximumLength}} characters long." }; + } + } else if (Array.isArray(value)) { + if (value.length < minimumLength) { + return { severity: "error", message: "{{name}} must contain at least {{maximumLength}} elements." }; + } + } else { + return { severity: "error", message: "{{name}} must be a string or an array." }; + } + + return { severity: "information" }; +}; + +export default minimumLength; diff --git a/src/validation/rules/minimumValue.ts b/src/validation/rules/minimumValue.ts new file mode 100644 index 0000000..19a7170 --- /dev/null +++ b/src/validation/rules/minimumValue.ts @@ -0,0 +1,14 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; + +const minimumValue: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + try { + if (value < args) { + return { severity: "error", message: "{{name}} must be at least {{minimumValue}}." }; + } + } catch (_) { + return { severity: "warning", message: `Could not compare {{name}} ({{value}} | ${typeof value}) with args ({{minimumValue}} | ${typeof args}).` }; + } + return { severity: "information" }; +}; + +export default minimumValue; diff --git a/src/validation/rules/pattern.ts b/src/validation/rules/pattern.ts new file mode 100644 index 0000000..3cad38f --- /dev/null +++ b/src/validation/rules/pattern.ts @@ -0,0 +1,14 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; + +const pattern: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } else if (typeof args !== "string" && !(args instanceof RegExp)) { + return { severity: "warning", message: "The arguments should be a regular expression." }; + } else if (!new RegExp(args).test(value)) { + return { severity: "error", message: "{{field}} must match the pattern {{pattern}}." }; + } + return { severity: "information" }; +}; + +export default pattern; diff --git a/src/validation/rules/uniqueCharacters.ts b/src/validation/rules/uniqueCharacters.ts new file mode 100644 index 0000000..d4ceae6 --- /dev/null +++ b/src/validation/rules/uniqueCharacters.ts @@ -0,0 +1,21 @@ +import type { RuleExecutionOutcome, ValidationRule } from "../types"; + +const uniqueCharacters: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { + if (typeof value !== "string") { + return { severity: "error", message: "{{name}} must be a string." }; + } + + const uniqueCharacters: number = Number(args); + if (!isNaN(uniqueCharacters) || uniqueCharacters <= 0) { + return { severity: "warning", message: "The arguments should be a positive number." }; + } + + const count: number = [...new Set(value)].length; + if (count < uniqueCharacters) { + return { severity: "error", message: "{{name}} must contain at least {{uniqueCharacters}} unique characters." }; + } + + return { severity: "information" }; +}; + +export default uniqueCharacters; diff --git a/src/validation/types.ts b/src/validation/types.ts index 1ab8471..d9d40ba 100644 --- a/src/validation/types.ts +++ b/src/validation/types.ts @@ -41,7 +41,7 @@ export type ValidationResult = { rules: Record; }; -export type ValidationRule = (value: unknown) => boolean | ValidationSeverity | RuleExecutionOutcome; +export type ValidationRule = (value: unknown, args?: unknown) => boolean | ValidationSeverity | RuleExecutionOutcome; export type ValidationRuleKey = string; From e06227f6e0495059429cf454afbd734027fe8286 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 23:14:15 -0400 Subject: [PATCH 16/23] custom --- src/validation/index.ts | 2 ++ src/validation/types.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/validation/index.ts b/src/validation/index.ts index 28a67d0..ede15a7 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -37,6 +37,8 @@ function apply(result: RuleExecutionResult, outcome: RuleExecutionOutcome, optio if (typeof outcome.value !== "undefined") { result.value = outcome.value; } + // custom + result.custom = outcome.custom; } function fillPlaceholders(result: RuleExecutionResult, outcome?: RuleExecutionOutcome, rule?: RuleOptions, validation?: ValidationOptions): void { diff --git a/src/validation/types.ts b/src/validation/types.ts index d9d40ba..341af59 100644 --- a/src/validation/types.ts +++ b/src/validation/types.ts @@ -7,6 +7,7 @@ export type RuleExecutionOutcome = { placeholders?: Record; name?: string; value?: unknown; + custom?: unknown; }; export type RuleExecutionResult = { @@ -16,6 +17,7 @@ export type RuleExecutionResult = { placeholders: Record; name: string; value: unknown; + custom?: unknown; }; export type RuleConfiguration = { From 4af2bd890075b02e02809c01223fcc710339c226 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 23:17:52 -0400 Subject: [PATCH 17/23] context --- src/validation/index.ts | 5 ++++- src/validation/types.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/validation/index.ts b/src/validation/index.ts index ede15a7..ae99f60 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -5,6 +5,7 @@ import type { RuleExecutionOutcome, RuleExecutionResult, RuleOptions, + ValidationContext, ValidationOptions, ValidationResult, ValidationRule, @@ -92,6 +93,7 @@ class Validator { validate(name: string, value: unknown, rules: ValidationRuleSet, options?: ValidationOptions): ValidationResult { options ??= {}; + const context: ValidationContext = options.context ?? {}; let errors: number = 0; const results: Record = {}; @@ -117,7 +119,7 @@ class Validator { value, }; - const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = configuration.rule(value, args); + const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = configuration.rule(value, args, context); switch (typeof outcome) { case "boolean": result.severity = Boolean(outcome) ? "information" : "error"; @@ -144,6 +146,7 @@ class Validator { const result: ValidationResult = { isValid: errors === 0, rules: results, + context, }; if (!result.isValid && options.throwOnFailure) { throw result; diff --git a/src/validation/types.ts b/src/validation/types.ts index 341af59..8cadc77 100644 --- a/src/validation/types.ts +++ b/src/validation/types.ts @@ -31,7 +31,10 @@ export type RuleOptions = { placeholders?: Record; }; +export type ValidationContext = Record; + export type ValidationOptions = { + context?: ValidationContext; messageFormatter?: MessageFormatter; placeholders?: Record; treatWarningsAsErrors?: boolean; @@ -41,9 +44,10 @@ export type ValidationOptions = { export type ValidationResult = { isValid: boolean; rules: Record; + context: ValidationContext; }; -export type ValidationRule = (value: unknown, args?: unknown) => boolean | ValidationSeverity | RuleExecutionOutcome; +export type ValidationRule = (value: unknown, args?: unknown, context?: ValidationContext) => boolean | ValidationSeverity | RuleExecutionOutcome; export type ValidationRuleKey = string; From 56df34a9ee3b4a14a74791519fc665b046d1d66d Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 23:31:41 -0400 Subject: [PATCH 18/23] docs --- src/validation/format.ts | 18 ++++++ src/validation/index.ts | 93 ++++++++++++++++++++++++++- src/validation/types.ts | 135 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 242 insertions(+), 4 deletions(-) diff --git a/src/validation/format.ts b/src/validation/format.ts index 7752b0c..39292e7 100644 --- a/src/validation/format.ts +++ b/src/validation/format.ts @@ -1,8 +1,26 @@ +/** + * Defines a message formatter. + */ export interface MessageFormatter { + /** + * Formats a message with the given placeholders. + * @param message The message to format. + * @param placeholders The placeholders to replace in the message. + * @returns The formatted message. + */ format(message: string, placeholders: Record): string; } +/** + * The default message formatter. This could use [mustache.js](https://github.com/janl/mustache.js), but we don't want to add a dependency for this. We simply replace occurrences of placeholder keys with their values, no other computation. + */ export default class DefaultMessageFormatter implements MessageFormatter { + /** + * Formats a message with the given placeholders. + * @param message The message to format. + * @param placeholders The placeholders to replace in the message. + * @returns The formatted message. + */ format(message: string, placeholders: Record): string { let formatted: string = message; for (const key in placeholders) { diff --git a/src/validation/index.ts b/src/validation/index.ts index ae99f60..a014697 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -15,6 +15,12 @@ import type { ValidatorOptions, } from "./types"; +/** + * Applies the execution outcome of a validation rule to a result. + * @param result The result to apply the execution outcome to. + * @param outcome The execution outcome of the validation rule execution. + * @param options The options of the validation rule execution. + */ function apply(result: RuleExecutionResult, outcome: RuleExecutionOutcome, options: RuleOptions): void { // severity result.severity = outcome.severity; @@ -42,6 +48,13 @@ function apply(result: RuleExecutionResult, outcome: RuleExecutionOutcome, optio result.custom = outcome.custom; } +/** + * Fills the placeholders of a validation rule execution. + * @param result The result to fill the placeholders of. + * @param outcome The execution outcome of the validation rule. + * @param rule The options of the validation rule execution. + * @param validation The options of the validation operation. + */ function fillPlaceholders(result: RuleExecutionResult, outcome?: RuleExecutionOutcome, rule?: RuleOptions, validation?: ValidationOptions): void { result.placeholders.key = result.key; result.placeholders.name = result.name; @@ -59,38 +72,101 @@ function fillPlaceholders(result: RuleExecutionResult, outcome?: RuleExecutionOu } } +/** + * A validator is a collection of validation rules that can be executed on a value. + */ class Validator { + /** + * The message formatter to use. + */ private readonly messageFormatter: MessageFormatter; - private readonly rules: Map = new Map(); + /** + * The rules registered to this validator. + */ + private readonly rules: Map; + /** + * A value indicating whether the validator should throw an error if the validation fails. + */ + private readonly throwOnFailure: boolean; + /** + * A value indicating whether warnings should be treated as errors. + */ private readonly treatWarningsAsErrors: boolean; + /** + * Initializes a new instance of the Validator class. + * @param options The options of the validator. + */ constructor(options?: ValidatorOptions) { options ??= options; this.messageFormatter = options.messageFormatter ?? new DefaultMessageFormatter(); + this.rules = new Map(); + this.throwOnFailure = options.throwOnFailure ?? false; this.treatWarningsAsErrors = options.treatWarningsAsErrors ?? false; } + /** + * Clears all the rules registered to this validator. + */ clearRules(): void { this.rules.clear(); } + + /** + * Gets a rule from the validator. + * @param key The key of the rule to get. + * @returns The rule configuration. + */ getRule(key: ValidationRuleKey): RuleConfiguration | undefined { return this.rules.get(key); } + + /** + * Checks if a rule is registered to this validator. + * @param key The key of the rule to check. + * @returns A value indicating whether the rule is registered to this validator. + */ hasRule(key: ValidationRuleKey): boolean { return this.rules.has(key); } + + /** + * Lists all the rules registered to this validator. + * @returns The rules registered to this validator. + */ listRules(): [ValidationRuleKey, RuleConfiguration][] { return [...this.rules.entries()]; } + + /** + * Removes a rule from the validator. + * @param key The key of the rule to remove. + * @returns A value indicating whether the rule was removed from the validator. + */ removeRule(key: ValidationRuleKey): boolean { return this.rules.delete(key); } + + /** + * Registers a rule to the validator. + * @param key The key of the rule to register. + * @param rule The rule to register. + * @param options The options of the rule. + */ setRule(key: ValidationRuleKey, rule: ValidationRule, options?: RuleOptions): void { options ??= options; const configuration: RuleConfiguration = { rule, options }; this.rules.set(key, configuration); } + /** + * Validates a field/property against a set of rules. + * @param name The name of the field/property to validate. + * @param value The value of the field/property to validate. + * @param rules The rule set to validate the value against. + * @param options The options of the validation operation. + * @returns The result of the validation operation. + */ validate(name: string, value: unknown, rules: ValidationRuleSet, options?: ValidationOptions): ValidationResult { options ??= {}; const context: ValidationContext = options.context ?? {}; @@ -148,16 +224,29 @@ class Validator { rules: results, context, }; - if (!result.isValid && options.throwOnFailure) { + if (!result.isValid && (options.throwOnFailure ?? this.throwOnFailure)) { throw result; } return result; } + + /** + * Formats a validation rule execution message. + * @param result The result to format the message of. + * @param options The options of the validation operation. + */ private formatMessage(result: RuleExecutionResult, options?: ValidationOptions): void { options ??= {}; const messageFormatter: MessageFormatter = options.messageFormatter ?? this.messageFormatter; result.message = messageFormatter.format(result.message, result.placeholders); } + + /** + * Checks if a severity is an error. + * @param severity The severity to check. + * @param options The options of the validation operation. + * @returns A value indicating whether the severity is an error. + */ private isError(severity: ValidationSeverity, options?: ValidationOptions): boolean { options ??= {}; switch (severity) { diff --git a/src/validation/types.ts b/src/validation/types.ts index 8cadc77..37e365b 100644 --- a/src/validation/types.ts +++ b/src/validation/types.ts @@ -1,62 +1,193 @@ import { MessageFormatter } from "./format"; +/** + * Defines the outcome of the execution of a validation rule. + */ export type RuleExecutionOutcome = { + /** + * The severity of the outcome. + */ severity: ValidationSeverity; + /** + * The key of the rule that was executed. + */ key?: string; + /** + * The message of the outcome. + */ message?: string; + /** + * The message placeholders of the outcome. + */ placeholders?: Record; + /** + * The name of the field/property that was validated. + */ name?: string; + /** + * The value of the field/property that was validated. + */ value?: unknown; + /** + * Custom state that was provided by the validation rule. + */ custom?: unknown; }; +/** + * Defines the result of the execution of a validation rule. + */ export type RuleExecutionResult = { + /** + * The key of the rule that was executed. + */ key: string; + /** + * The severity of the result. + */ severity: ValidationSeverity; + /** + * The message of the result. + */ message?: string; + /** + * The message placeholders of the result. + */ placeholders: Record; + /** + * The name of the field/property that was validated. + */ name: string; + /** + * The value of the field/property that was validated. + */ value: unknown; + /** + * Custom state that was provided by the validation rule. + */ custom?: unknown; }; +/** + * Defines the configuration of a validation rule. + */ export type RuleConfiguration = { + /** + * The validation rule to execute. + */ rule: ValidationRule; + /** + * The options of the rule execution. + */ options: RuleOptions; }; +/** + * Defines the options of a validation rule execution. + */ export type RuleOptions = { + /** + * Overrides the key of the rule. + */ key?: string; + /** + * Overrides the message of the rule. + */ message?: string; + /** + * Provides additional placeholders for the rule message. + */ placeholders?: Record; }; +/** + * Defines a validation context. The context is shared between validation rules and returned in the result. + */ export type ValidationContext = Record; +/** + * Defines the options of a validation operation. + */ export type ValidationOptions = { + /** + * The context of the validation opteration. + */ context?: ValidationContext; + /** + * The message formatter to use. + */ messageFormatter?: MessageFormatter; + /** + * Provides additional placeholders for the validation messages. + */ placeholders?: Record; - treatWarningsAsErrors?: boolean; + /** + * A value indicating whether the validation should throw an error if the validation fails. + */ throwOnFailure?: boolean; + /** + * A value indicating whether warnings should be treated as errors. + */ + treatWarningsAsErrors?: boolean; }; +/** + * Defines the result of a validation operation. + */ export type ValidationResult = { + /** + * A value indicating whether the validation was successful. + */ isValid: boolean; + /** + * The results of the executed validation rules. + */ rules: Record; + /** + * The context of the validation operation. + */ context: ValidationContext; }; +/** + * Defines a validation rule. + * @param value The value to validate. + * @param args Additional arguments to pass to the validation rule. + * @param context The context of the validation operation. + * @returns A value indicating whether the validation was successful. + */ export type ValidationRule = (value: unknown, args?: unknown, context?: ValidationContext) => boolean | ValidationSeverity | RuleExecutionOutcome; +/** + * Defines the key of a validation rule. + */ export type ValidationRuleKey = string; +/** + * Defines a set of validation rules. + */ export type ValidationRuleSet = Record; -// https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel?view=net-9.0-pp +/** + * Defines the severity of a validation execution outcome. + * Reference: https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.loglevel + */ export type ValidationSeverity = "trace" | "debug" | "information" | "warning" | "error" | "critical"; +/** + * Defines the options of a validator. + */ export type ValidatorOptions = { + /** + * The message formatter to use. + */ messageFormatter?: MessageFormatter; + /** + * A value indicating whether the validator should throw an error if the validation fails. + */ + throwOnFailure?: boolean; + /** + * A value indicating whether warnings should be treated as errors. + */ treatWarningsAsErrors?: boolean; }; From 701e3e20b1b13a86539c8cbee7b35804d007ff63 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Sun, 20 Apr 2025 23:49:22 -0400 Subject: [PATCH 19/23] docs + url protocol args --- src/validation/rules/allowedCharacters.ts | 6 +++ src/validation/rules/confirm.ts | 6 +++ src/validation/rules/containsDigits.ts | 6 +++ src/validation/rules/containsLowercase.ts | 6 +++ .../rules/containsNonAlphanumeric.ts | 6 +++ src/validation/rules/containsUppercase.ts | 6 +++ src/validation/rules/email.ts | 6 +++ src/validation/rules/identifier.ts | 5 +++ src/validation/rules/maximumLength.ts | 6 +++ src/validation/rules/maximumValue.ts | 6 +++ src/validation/rules/minimumLength.ts | 6 +++ src/validation/rules/minimumValue.ts | 6 +++ src/validation/rules/pattern.ts | 6 +++ src/validation/rules/required.ts | 5 +++ src/validation/rules/slug.ts | 5 +++ src/validation/rules/uniqueCharacters.ts | 6 +++ src/validation/rules/url.ts | 40 +++++++++++++++++-- 17 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/validation/rules/allowedCharacters.ts b/src/validation/rules/allowedCharacters.ts index b963162..abd610c 100644 --- a/src/validation/rules/allowedCharacters.ts +++ b/src/validation/rules/allowedCharacters.ts @@ -1,5 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; +/** + * A validation rule that checks if a string only contains allowed characters. + * @param value The value to validate. + * @param args The allowed characters. + * @returns The result of the validation rule execution. + */ const allowedCharacters: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/confirm.ts b/src/validation/rules/confirm.ts index 4196f02..1b08c3e 100644 --- a/src/validation/rules/confirm.ts +++ b/src/validation/rules/confirm.ts @@ -1,5 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; +/** + * A validation rule that checks if a value is equal to another value. + * @param value The value to validate. + * @param args The value to compare the value to. + * @returns The result of the validation rule execution. + */ const confirm: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { const isValid: boolean = typeof value === "object" || typeof args === "object" ? JSON.stringify(value) === JSON.stringify(args) : value === args; if (!isValid) { diff --git a/src/validation/rules/containsDigits.ts b/src/validation/rules/containsDigits.ts index a206676..4a7ad24 100644 --- a/src/validation/rules/containsDigits.ts +++ b/src/validation/rules/containsDigits.ts @@ -1,6 +1,12 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isDigit } from "../../helpers/stringUtils"; +/** + * A validation rule that checks if a string contains a minimum number of digits. + * @param value The value to validate. + * @param args The minimum number of digits. + * @returns The result of the validation rule execution. + */ const containsDigits: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/containsLowercase.ts b/src/validation/rules/containsLowercase.ts index 89bbdb4..a5a3a14 100644 --- a/src/validation/rules/containsLowercase.ts +++ b/src/validation/rules/containsLowercase.ts @@ -1,6 +1,12 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isLetter } from "../../helpers/stringUtils"; +/** + * A validation rule that checks if a string contains a minimum number of lowercase letters. + * @param value The value to validate. + * @param args The minimum number of lowercase letters. + * @returns The result of the validation rule execution. + */ const containsLowercase: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/containsNonAlphanumeric.ts b/src/validation/rules/containsNonAlphanumeric.ts index 461ca46..4a6c8c3 100644 --- a/src/validation/rules/containsNonAlphanumeric.ts +++ b/src/validation/rules/containsNonAlphanumeric.ts @@ -1,6 +1,12 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isLetterOrDigit } from "../../helpers/stringUtils"; +/** + * A validation rule that checks if a string contains a minimum number of non-alphanumeric characters. + * @param value The value to validate. + * @param args The minimum number of non-alphanumeric characters. + * @returns The result of the validation rule execution. + */ const containsNonAlphanumeric: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/containsUppercase.ts b/src/validation/rules/containsUppercase.ts index 7600115..d760699 100644 --- a/src/validation/rules/containsUppercase.ts +++ b/src/validation/rules/containsUppercase.ts @@ -1,6 +1,12 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isLetter } from "../../helpers/stringUtils"; +/** + * A validation rule that checks if a string contains a minimum number of uppercase letters. + * @param value The value to validate. + * @param args The minimum number of uppercase letters. + * @returns The result of the validation rule execution. + */ const containsUppercase: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/email.ts b/src/validation/rules/email.ts index 44190b3..a1a5aaf 100644 --- a/src/validation/rules/email.ts +++ b/src/validation/rules/email.ts @@ -3,6 +3,12 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; // https://github.com/colinhacks/zod/blob/40e72f9eaf576985f876d1afc2dbc22f73abc1ba/src/types.ts#L595 const defaultRegex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; +/** + * A validation rule that checks if a string is a valid email address. + * @param value The value to validate. + * @param args The regular expression to validate the email address against. + * @returns The result of the validation rule execution. + */ const email: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/identifier.ts b/src/validation/rules/identifier.ts index 4729291..a740702 100644 --- a/src/validation/rules/identifier.ts +++ b/src/validation/rules/identifier.ts @@ -1,6 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isDigit, isLetterOrDigit, isNullOrEmpty } from "../../helpers/stringUtils"; +/** + * A validation rule that checks if a string is a valid identifier. + * @param value The value to validate. + * @returns The result of the validation rule execution. + */ const identifier: ValidationRule = (value: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/maximumLength.ts b/src/validation/rules/maximumLength.ts index 83e1bdf..4548efb 100644 --- a/src/validation/rules/maximumLength.ts +++ b/src/validation/rules/maximumLength.ts @@ -1,5 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; +/** + * A validation rule that checks if a string or an array is shorter than a maximum length. + * @param value The value to validate. + * @param args The maximum length. + * @returns The result of the validation rule execution. + */ const maximumLength: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { const maximumLength: number = Number(args); if (!isNaN(maximumLength) || maximumLength <= 0) { diff --git a/src/validation/rules/maximumValue.ts b/src/validation/rules/maximumValue.ts index f910f0a..042f2d0 100644 --- a/src/validation/rules/maximumValue.ts +++ b/src/validation/rules/maximumValue.ts @@ -1,5 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; +/** + * A validation rule that checks if a value is less than or equal to a maximum value. + * @param value The value to validate. + * @param args The maximum value. + * @returns The result of the validation rule execution. + */ const maximumValue: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { try { if (value > args) { diff --git a/src/validation/rules/minimumLength.ts b/src/validation/rules/minimumLength.ts index 6df9e59..9ed0f01 100644 --- a/src/validation/rules/minimumLength.ts +++ b/src/validation/rules/minimumLength.ts @@ -1,5 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; +/** + * A validation rule that checks if a string or an array is longer than a minimum length. + * @param value The value to validate. + * @param args The minimum length. + * @returns The result of the validation rule execution. + */ const minimumLength: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { const minimumLength: number = Number(args); if (!isNaN(minimumLength) || minimumLength <= 0) { diff --git a/src/validation/rules/minimumValue.ts b/src/validation/rules/minimumValue.ts index 19a7170..06492df 100644 --- a/src/validation/rules/minimumValue.ts +++ b/src/validation/rules/minimumValue.ts @@ -1,5 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; +/** + * A validation rule that checks if a value is greater than or equal to a minimum value. + * @param value The value to validate. + * @param args The minimum value. + * @returns The result of the validation rule execution. + */ const minimumValue: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { try { if (value < args) { diff --git a/src/validation/rules/pattern.ts b/src/validation/rules/pattern.ts index 3cad38f..03fff29 100644 --- a/src/validation/rules/pattern.ts +++ b/src/validation/rules/pattern.ts @@ -1,5 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; +/** + * A validation rule that checks if a string matches a regular expression. + * @param value The value to validate. + * @param args The regular expression to validate the string against. + * @returns The result of the validation rule execution. + */ const pattern: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/required.ts b/src/validation/rules/required.ts index 111da43..0637c7c 100644 --- a/src/validation/rules/required.ts +++ b/src/validation/rules/required.ts @@ -1,6 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isNullOrWhiteSpace } from "../../helpers/stringUtils"; +/** + * A validation rule that checks if a required value is provided. + * @param value The value to validate. + * @returns The result of the validation rule execution. + */ const required: ValidationRule = (value: unknown): RuleExecutionOutcome => { switch (typeof value) { case "number": diff --git a/src/validation/rules/slug.ts b/src/validation/rules/slug.ts index a66e000..ce676ea 100644 --- a/src/validation/rules/slug.ts +++ b/src/validation/rules/slug.ts @@ -1,6 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isLetterOrDigit, isNullOrEmpty } from "../../helpers/stringUtils"; +/** + * A validation rule that checks if a string is a valid slug. + * @param value The value to validate. + * @returns The result of the validation rule execution. + */ const slug: ValidationRule = (value: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/uniqueCharacters.ts b/src/validation/rules/uniqueCharacters.ts index d4ceae6..7378bb4 100644 --- a/src/validation/rules/uniqueCharacters.ts +++ b/src/validation/rules/uniqueCharacters.ts @@ -1,5 +1,11 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; +/** + * A validation rule that checks if a string contains a minimum number of unique characters. + * @param value The value to validate. + * @param args The minimum number of unique characters. + * @returns The result of the validation rule execution. + */ const uniqueCharacters: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; diff --git a/src/validation/rules/url.ts b/src/validation/rules/url.ts index 40b6c34..e71824c 100644 --- a/src/validation/rules/url.ts +++ b/src/validation/rules/url.ts @@ -1,21 +1,53 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; -import { isNullOrWhiteSpace } from "../../helpers/stringUtils"; +import { isNullOrWhiteSpace, trimEnd } from "../../helpers/stringUtils"; -const url: ValidationRule = (value: unknown): RuleExecutionOutcome => { +/** + * A validation rule that checks if a string is a valid URL. + * @param value The value to validate. + * @param args The allowed protocols. + * @returns The result of the validation rule execution. + */ +const url: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { if (typeof value !== "string") { return { severity: "error", message: "{{name}} must be a string." }; } else if (isNullOrWhiteSpace(value)) { return { severity: "error", message: "{{name}} cannot be an empty string." }; } + + let isArgsValid: boolean = true; + const protocols: Set = new Set(["http", "https"]); + if (typeof args !== "undefined") { + let values: string[] = []; + if (typeof args === "string") { + values = args.split(/[,;\|]/); + } else if (Array.isArray(args)) { + values = args; + } + if (values.length === 0) { + isArgsValid = false; + } else { + values.forEach((value) => protocols.add(trimEnd(value.trim().toLowerCase(), ":"))); + } + } + let url: URL; try { url = new URL(value.trim()); } catch (_) { return { severity: "error", message: "{{name}} must be a valid URL." }; } - if (url.protocol !== "http:" && url.protocol !== "https:") { - return { severity: "error", message: "{{name}} must be an URL with one of the following schemes: http, https" }; + + if (!protocols.has(url.protocol.toLowerCase())) { + return { severity: "error", message: `{{name}} must be an URL with one of the following protocols: ${[...protocols].join(", ")}.` }; } + + if (!isArgsValid) { + return { + severity: "warning", + message: "The arguments must be undefined, or a string containing the allowed protocols separated by commas, semicolons or pipes.", + }; + } + return { severity: "information" }; }; From bb74281e42cf9fe7a83113ae61598dbc316418d4 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 21 Apr 2025 01:02:29 -0400 Subject: [PATCH 20/23] test --- .../rules/__tests__/allowedCharacters.spec.ts | 32 +++++++++++ .../rules/__tests__/confirm.spec.ts | 46 ++++++++++++++++ .../rules/__tests__/containsDigits.spec.ts | 39 ++++++++++++++ .../rules/__tests__/containsLowercase.spec.ts | 39 ++++++++++++++ .../__tests__/containsNonAlphanumeric.spec.ts | 39 ++++++++++++++ .../rules/__tests__/containsUppercase.spec.ts | 39 ++++++++++++++ src/validation/rules/__tests__/email.spec.ts | 30 +++++++++++ .../rules/__tests__/identifier.spec.ts | 36 +++++++++++++ .../rules/__tests__/maximumLength.spec.ts | 42 +++++++++++++++ .../rules/__tests__/maximumValue.spec.ts | 42 +++++++++++++++ .../rules/__tests__/minimumLength.spec.ts | 42 +++++++++++++++ .../rules/__tests__/minimumValue.spec.ts | 42 +++++++++++++++ .../rules/__tests__/pattern.spec.ts | 32 +++++++++++ .../rules/__tests__/required.spec.ts | 54 +++++++++++++++++++ src/validation/rules/__tests__/slug.spec.ts | 30 +++++++++++ .../rules/__tests__/uniqueCharacters.spec.ts | 30 +++++++++++ src/validation/rules/__tests__/url.spec.ts | 52 ++++++++++++++++++ src/validation/rules/containsDigits.ts | 2 +- src/validation/rules/containsLowercase.ts | 2 +- .../rules/containsNonAlphanumeric.ts | 2 +- src/validation/rules/containsUppercase.ts | 4 +- src/validation/rules/maximumLength.ts | 6 +-- src/validation/rules/minimumLength.ts | 6 +-- src/validation/rules/pattern.ts | 2 +- src/validation/rules/uniqueCharacters.ts | 4 +- src/validation/rules/url.ts | 16 ++++-- 26 files changed, 693 insertions(+), 17 deletions(-) create mode 100644 src/validation/rules/__tests__/allowedCharacters.spec.ts create mode 100644 src/validation/rules/__tests__/confirm.spec.ts create mode 100644 src/validation/rules/__tests__/containsDigits.spec.ts create mode 100644 src/validation/rules/__tests__/containsLowercase.spec.ts create mode 100644 src/validation/rules/__tests__/containsNonAlphanumeric.spec.ts create mode 100644 src/validation/rules/__tests__/containsUppercase.spec.ts create mode 100644 src/validation/rules/__tests__/email.spec.ts create mode 100644 src/validation/rules/__tests__/identifier.spec.ts create mode 100644 src/validation/rules/__tests__/maximumLength.spec.ts create mode 100644 src/validation/rules/__tests__/maximumValue.spec.ts create mode 100644 src/validation/rules/__tests__/minimumLength.spec.ts create mode 100644 src/validation/rules/__tests__/minimumValue.spec.ts create mode 100644 src/validation/rules/__tests__/pattern.spec.ts create mode 100644 src/validation/rules/__tests__/required.spec.ts create mode 100644 src/validation/rules/__tests__/slug.spec.ts create mode 100644 src/validation/rules/__tests__/uniqueCharacters.spec.ts create mode 100644 src/validation/rules/__tests__/url.spec.ts diff --git a/src/validation/rules/__tests__/allowedCharacters.spec.ts b/src/validation/rules/__tests__/allowedCharacters.spec.ts new file mode 100644 index 0000000..52f25c4 --- /dev/null +++ b/src/validation/rules/__tests__/allowedCharacters.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, test } from "vitest"; + +import rule from "../allowedCharacters"; +import { RuleExecutionOutcome } from "../../types"; + +const allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + +describe("allowedCharacters", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = rule(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + test.each([undefined, null, {}, [], true, 0, 0n])("should return warning when the args are not a string", (args) => { + const outcome = rule("valid", args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments must be a string containing the allowed characters."); + }); + + it.concurrent("should return invalid when the value contains prohibited characters", () => { + const outcome = rule("invalid!", allowedCharacters) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} contains the following prohibited characters: !. Only the following characters are allowed: {{allowedCharacters}}"); + }); + + it.concurrent("should return valid when the value only contains allowed characters", () => { + const outcome = rule("valid", allowedCharacters) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/confirm.spec.ts b/src/validation/rules/__tests__/confirm.spec.ts new file mode 100644 index 0000000..19178b4 --- /dev/null +++ b/src/validation/rules/__tests__/confirm.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, test } from "vitest"; + +import confirm from "../confirm"; +import { RuleExecutionOutcome } from "../../types"; + +describe("confirm", () => { + test.each([ + [undefined, "undefined"], + [null, {}], + [{ age: 20 }, { name: "John" }], + [ + [1, 2], + [3, 4], + ], + [false, true], + [-1, 1], + [-1n, 1n], + ["hello", "world"], + ])("return invalid when the value does not equal the args", (value, args) => { + const outcome = confirm(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must equal {{confirm}}."); + }); + + test.each([ + [undefined, undefined], + [null, null], + [ + { age: 20, name: "John" }, + { age: 20, name: "John" }, + ], + [ + [1, 2, 3], + [1, 2, 3], + ], + [false, false], + [true, true], + [0, 0], + [0n, 0n], + ["hello world", "hello world"], + ])("return valid when the value equals the args", (value, args) => { + const outcome = confirm(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/containsDigits.spec.ts b/src/validation/rules/__tests__/containsDigits.spec.ts new file mode 100644 index 0000000..c76e036 --- /dev/null +++ b/src/validation/rules/__tests__/containsDigits.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, test } from "vitest"; + +import containsDigits from "../containsDigits"; +import { RuleExecutionOutcome } from "../../types"; + +describe("containsDigits", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = containsDigits(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + test.each([undefined, null, {}, [], ["1", "b"], false, -1.23, 0, -1n, 0n, "invalid", "-10", "0"])( + "should return warning when the args is not a positive number", + (args) => { + const outcome = containsDigits("AAaa!!11", args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments should be a positive number."); + }, + ); + + it.concurrent("should return invalid when the value does not contain enough digits", () => { + const outcome = containsDigits("AAaa!!11", 3) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must contain at least {{containsDigits}} digit(s)."); + }); + + test.each([ + ["AAaa!!11", true], + ["AAaa!!11", ["2"]], + ["AAaa!!11", 2n], + ["AAaa!!11", 2], + ["AAaa!!11", "2"], + ])("should return valid when the value contains enough digits", (value, args) => { + const outcome = containsDigits(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/containsLowercase.spec.ts b/src/validation/rules/__tests__/containsLowercase.spec.ts new file mode 100644 index 0000000..b430b67 --- /dev/null +++ b/src/validation/rules/__tests__/containsLowercase.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, test } from "vitest"; + +import containsLowercase from "../containsLowercase"; +import { RuleExecutionOutcome } from "../../types"; + +describe("containsLowercase", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = containsLowercase(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + test.each([undefined, null, {}, [], ["1", "b"], false, -1.23, 0, -1n, 0n, "invalid", "-10", "0"])( + "should return warning when the args is not a positive number", + (args) => { + const outcome = containsLowercase("AAaa!!11", args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments should be a positive number."); + }, + ); + + it.concurrent("should return invalid when the value does not contain enough lowercase letters", () => { + const outcome = containsLowercase("AAaa!!11", 3) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must contain at least {{containsLowercase}} lowercase letter(s)."); + }); + + test.each([ + ["AAaa!!11", true], + ["AAaa!!11", ["2"]], + ["AAaa!!11", 2n], + ["AAaa!!11", 2], + ["AAaa!!11", "2"], + ])("should return valid when the value contains enough lowercase letters", (value, args) => { + const outcome = containsLowercase(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/containsNonAlphanumeric.spec.ts b/src/validation/rules/__tests__/containsNonAlphanumeric.spec.ts new file mode 100644 index 0000000..8ee34ef --- /dev/null +++ b/src/validation/rules/__tests__/containsNonAlphanumeric.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, test } from "vitest"; + +import containsNonAlphanumeric from "../containsNonAlphanumeric"; +import { RuleExecutionOutcome } from "../../types"; + +describe("containsNonAlphanumerics", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = containsNonAlphanumeric(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + test.each([undefined, null, {}, [], ["1", "b"], false, -1.23, 0, -1n, 0n, "invalid", "-10", "0"])( + "should return warning when the args is not a positive number", + (args) => { + const outcome = containsNonAlphanumeric("AAaa!!11", args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments should be a positive number."); + }, + ); + + it.concurrent("should return invalid when the value does not contain enough nonalphanumerics", () => { + const outcome = containsNonAlphanumeric("AAaa!!11", 3) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must contain at least {{containsNonAlphanumeric}} non-alphanumeric character(s)."); + }); + + test.each([ + ["AAaa!!11", true], + ["AAaa!!11", ["2"]], + ["AAaa!!11", 2n], + ["AAaa!!11", 2], + ["AAaa!!11", "2"], + ])("should return valid when the value contains enough nonalphanumerics", (value, args) => { + const outcome = containsNonAlphanumeric(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/containsUppercase.spec.ts b/src/validation/rules/__tests__/containsUppercase.spec.ts new file mode 100644 index 0000000..caf1434 --- /dev/null +++ b/src/validation/rules/__tests__/containsUppercase.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, test } from "vitest"; + +import containsUppercase from "../containsUppercase"; +import { RuleExecutionOutcome } from "../../types"; + +describe("containsUppercase", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = containsUppercase(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + test.each([undefined, null, {}, [], ["1", "b"], false, -1.23, 0, -1n, 0n, "invalid", "-10", "0"])( + "should return warning when the args is not a positive number", + (args) => { + const outcome = containsUppercase("AAaa!!11", args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments should be a positive number."); + }, + ); + + it.concurrent("should return invalid when the value does not contain enough uppercase letters", () => { + const outcome = containsUppercase("AAaa!!11", 3) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must contain at least {{containsUppercase}} uppercase letter(s)."); + }); + + test.each([ + ["AAaa!!11", true], + ["AAaa!!11", ["2"]], + ["AAaa!!11", 2n], + ["AAaa!!11", 2], + ["AAaa!!11", "2"], + ])("should return valid when the value contains enough uppercase letters", (value, args) => { + const outcome = containsUppercase(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/email.spec.ts b/src/validation/rules/__tests__/email.spec.ts new file mode 100644 index 0000000..d3527e5 --- /dev/null +++ b/src/validation/rules/__tests__/email.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, test } from "vitest"; + +import email from "../email"; +import { RuleExecutionOutcome } from "../../types"; + +describe("email", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = email(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + test.each([null, {}, [], true, 0, 0n])("should return warning when the args are not valid", (args) => { + const outcome = email("test@example.com", args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments must be undefined, or a valid email address validation regular expression."); + }); + + it.concurrent("should return invalid when the value is not a valid email address", () => { + const outcome = email("aa@@bb..cc") as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a valid email address."); + }); + + it.concurrent("should return valid when the value is a valid email address", () => { + const outcome = email("test@example.com") as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/identifier.spec.ts b/src/validation/rules/__tests__/identifier.spec.ts new file mode 100644 index 0000000..fb6a763 --- /dev/null +++ b/src/validation/rules/__tests__/identifier.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, test } from "vitest"; + +import identifier from "../identifier"; +import { RuleExecutionOutcome } from "../../types"; + +describe("identifier", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = identifier(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + it.concurrent("should return invalid when the value is an empty string", () => { + const outcome = identifier("") as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} cannot be an empty string."); + }); + + it.concurrent("should return invalid when the value starts with a digit", () => { + const outcome = identifier("123") as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} cannot start with a digit."); + }); + + it.concurrent("should return invalid when the value contains non-alphanumeric characters", () => { + const outcome = identifier("invalid_123!") as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} may only contain letters, digits and underscores (_)."); + }); + + test.each(["_valid", "valid_123", "valid"])("should return valid when the value is a valid identifier", (value) => { + const outcome = identifier(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/maximumLength.spec.ts b/src/validation/rules/__tests__/maximumLength.spec.ts new file mode 100644 index 0000000..c4e22bd --- /dev/null +++ b/src/validation/rules/__tests__/maximumLength.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, test } from "vitest"; + +import maximumLength from "../maximumLength"; +import { RuleExecutionOutcome } from "../../types"; + +describe("maximumLength", () => { + test.each([undefined, null, {}, [], false, 0, 0n])("should return warning when the args is not a positive number", (args) => { + const outcome = maximumLength("test", args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments should be a positive number."); + }); + + it.concurrent("should return invalid when the value is a string that is too long", () => { + const outcome = maximumLength("test", true) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be at most {{maximumLength}} character(s) long."); + }); + + it.concurrent("should return invalid when the value is an array that is too long", () => { + const outcome = maximumLength([1, 2, 3], 2) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must contain at most {{maximumLength}} element(s)."); + }); + + test.each([undefined, null, {}, false, 0, 0n])("should return invalid when the value is not a string or an array", (value) => { + const outcome = maximumLength(value, 1) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string or an array."); + }); + + it.concurrent("should return valid when the value is a string that is not too long", () => { + const outcome = maximumLength("test", 5) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); + + it.concurrent("should return valid when the value is an array that is not too long", () => { + const outcome = maximumLength([1, 2, 3], 3) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/maximumValue.spec.ts b/src/validation/rules/__tests__/maximumValue.spec.ts new file mode 100644 index 0000000..8fc2646 --- /dev/null +++ b/src/validation/rules/__tests__/maximumValue.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, test } from "vitest"; + +import maximumValue from "../maximumValue"; +import { RuleExecutionOutcome } from "../../types"; + +describe("maximumValue", () => { + test.each([ + [true, false], + [1, -1], + [10n, -10n], + ["def", "abc"], + ["456", 123], + [new Date("2010-01-01"), new Date("2000-01-01")], + [new Date(), null], + [Infinity, -Infinity], + ])("should return invalid when the value is greater than args", (value, args) => { + const outcome = maximumValue(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be at most {{maximumValue}}."); + }); + + test.each([ + [false, true], + [false, false], + [-1, 1], + [0, 0], + [-10n, 10n], + [100n, 100n], + ["456", 789], + ["abc", "def"], + ["ghi", "ghi"], + [new Date("2010-01-01"), new Date("2020-01-01")], + [new Date("2000-01-01"), new Date("2000-01-01")], + [null, new Date()], + [-Infinity, NaN], + [-Infinity, undefined], + ])("should return valid when the value is lower than or equal to args", (value, args) => { + const outcome = maximumValue(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/minimumLength.spec.ts b/src/validation/rules/__tests__/minimumLength.spec.ts new file mode 100644 index 0000000..ad540db --- /dev/null +++ b/src/validation/rules/__tests__/minimumLength.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, test } from "vitest"; + +import minimumLength from "../minimumLength"; +import { RuleExecutionOutcome } from "../../types"; + +describe("minimumLength", () => { + test.each([undefined, null, {}, [], false, 0, 0n])("should return warning when the args is not a positive number", (args) => { + const outcome = minimumLength("test", args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments should be a positive number."); + }); + + it.concurrent("should return invalid when the value is a string that is too short", () => { + const outcome = minimumLength("", true) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be at least {{minimumLength}} character(s) long."); + }); + + it.concurrent("should return invalid when the value is an array that is too short", () => { + const outcome = minimumLength([1], 2) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must contain at least {{minimumLength}} element(s)."); + }); + + test.each([undefined, null, {}, false, 0, 0n])("should return invalid when the value is not a string or an array", (value) => { + const outcome = minimumLength(value, 1) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string or an array."); + }); + + it.concurrent("should return valid when the value is a string that is not too short", () => { + const outcome = minimumLength("AAaa!!11", 5) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); + + it.concurrent("should return valid when the value is an array that is not too short", () => { + const outcome = minimumLength([1, 2, 3], 2) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/minimumValue.spec.ts b/src/validation/rules/__tests__/minimumValue.spec.ts new file mode 100644 index 0000000..5ac7927 --- /dev/null +++ b/src/validation/rules/__tests__/minimumValue.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, test } from "vitest"; + +import minimumValue from "../minimumValue"; +import { RuleExecutionOutcome } from "../../types"; + +describe("minimumValue", () => { + test.each([ + [false, true], + [-1, 1], + [-10n, 10n], + ["abc", "def"], + [123, "456"], + [new Date("2000-01-01"), new Date("2010-01-01")], + [null, new Date()], + [-Infinity, Infinity], + ])("should return invalid when the value is lower than args", (value, args) => { + const outcome = minimumValue(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be at least {{minimumValue}}."); + }); + + test.each([ + [true, false], + [true, true], + [1, -1], + [0, 0], + [10n, -10n], + [100n, 100n], + [789, "456"], + ["def", "abc"], + ["ghi", "ghi"], + [new Date("2020-01-01"), new Date("2010-01-01")], + [new Date("2000-01-01"), new Date("2000-01-01")], + [new Date(), null], + [NaN, -Infinity], + [undefined, -Infinity], + ])("should return valid when the value is greater than or equal to args", (value, args) => { + const outcome = minimumValue(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/pattern.spec.ts b/src/validation/rules/__tests__/pattern.spec.ts new file mode 100644 index 0000000..50a6b4b --- /dev/null +++ b/src/validation/rules/__tests__/pattern.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, test } from "vitest"; + +import rule from "../pattern"; +import { RuleExecutionOutcome } from "../../types"; + +const pattern = /^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z][ -]?\d[ABCEGHJ-NPRSTV-Z]\d$/; + +describe("pattern", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = rule(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + test.each([undefined, null, {}, [], true, 0, 0n])("should return warning when the args is not a regular expression", () => { + const outcome = rule("") as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments should be a regular expression."); + }); + + test.each(["h2x3y2", "H2X -3Y2", "H2U 3Y2"])("should return invalid when the value does not match the pattern", (value) => { + const outcome = rule(value, pattern) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must match the pattern {{pattern}}."); + }); + + test.each(["H2X3Y2", "H2X 3Y2", "H2X-3Y2"])("should return valid when the value matches the pattern", (value) => { + const outcome = rule(value, pattern) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/required.spec.ts b/src/validation/rules/__tests__/required.spec.ts new file mode 100644 index 0000000..40673e2 --- /dev/null +++ b/src/validation/rules/__tests__/required.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, test } from "vitest"; + +import required from "../required"; +import { RuleExecutionOutcome } from "../../types"; + +describe("required", () => { + test.each([NaN, 0])("should return invalid when the number is NaN or 0", (value) => { + const outcome = required(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a number different from 0."); + }); + + test.each(["", " "])("should return invalid when the string is empty or white-space", (value) => { + const outcome = required(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} cannot be an empty string."); + }); + + it.concurrent("should return invalid when the array is empty", () => { + const outcome = required([]) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} cannot be an empty array."); + }); + + test.each([undefined, null, false, 0n])("should return invalid when the value is falsy", (value) => { + const outcome = required(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} is required."); + }); + + test.each([1, 123, 123.456])("should return valid when the number is not NaN or 0", (value) => { + const outcome = required(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); + + it.concurrent("should return valid when the string is not empty", () => { + const outcome = required("hello world") as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); + + it.concurrent("should return valid when the array is not empty", () => { + const outcome = required([1, 2, 3]) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); + + test.each([{}, true, 1n])("should return valid when the value is not falsy", (value) => { + const outcome = required(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/slug.spec.ts b/src/validation/rules/__tests__/slug.spec.ts new file mode 100644 index 0000000..4e31407 --- /dev/null +++ b/src/validation/rules/__tests__/slug.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, test } from "vitest"; + +import slug from "../slug"; +import { RuleExecutionOutcome } from "../../types"; + +describe("slug", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = slug(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + it.concurrent("should return invalid when the value contains an empty word", () => { + const outcome = slug("aa--bb") as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be composed of non-empty alphanumeric words separated by hyphens (-)."); + }); + + it.concurrent("should return invalid when the value contains non-alphanumeric characters", () => { + const outcome = slug("invalid!") as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be composed of non-empty alphanumeric words separated by hyphens (-)."); + }); + + test.each(["valid", "valid-123"])("should return valid when the value is a valid slug", (value) => { + const outcome = slug(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/uniqueCharacters.spec.ts b/src/validation/rules/__tests__/uniqueCharacters.spec.ts new file mode 100644 index 0000000..927c2c1 --- /dev/null +++ b/src/validation/rules/__tests__/uniqueCharacters.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, test } from "vitest"; + +import uniqueCharacters from "../uniqueCharacters"; +import { RuleExecutionOutcome } from "../../types"; + +describe("uniqueCharacters", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = uniqueCharacters(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + test.each([undefined, null, {}, [], false, 0, 0n])("should return warning when the args is not a positive number", (args) => { + const outcome = uniqueCharacters("test", args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("The arguments should be a positive number."); + }); + + it.concurrent("should return invalid when the value does not have enough unique characters", () => { + const outcome = uniqueCharacters("AAaa!!11", 5) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must contain at least {{uniqueCharacters}} unique character(s)."); + }); + + it.concurrent("should return valid when the value has enough unique characters", () => { + const outcome = uniqueCharacters("AAaa!!11", 4) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); +}); diff --git a/src/validation/rules/__tests__/url.spec.ts b/src/validation/rules/__tests__/url.spec.ts new file mode 100644 index 0000000..7966918 --- /dev/null +++ b/src/validation/rules/__tests__/url.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, test } from "vitest"; + +import url from "../url"; +import { RuleExecutionOutcome } from "../../types"; + +describe("url", () => { + test.each([undefined, null, {}, [], true, 0, 0n])("should return invalid when the value is not a string", (value) => { + const outcome = url(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a string."); + }); + + test.each(["", " "])("should return invalid when the value is empty or white-space", (value) => { + const outcome = url(value) as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} cannot be an empty string."); + }); + + it.concurrent("should return invalid when the value is not a valid URL", () => { + const outcome = url("invalid-url") as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be a valid URL."); + }); + + it.concurrent("should return invalid when the value is not an URL with a valid protocol", () => { + const outcome = url("ftp://example.com") as RuleExecutionOutcome; + expect(outcome.severity).toBe("error"); + expect(outcome.message).toBe("{{name}} must be an URL with one of the following protocols: http, https."); + }); + + it.concurrent("should return warning when the args are not valid", () => { + const outcome = url("http://example.com", 123) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe( + "The arguments must be undefined, a string containing the allowed protocols separated by commas, semicolons or pipes, or an array of allowed protocols.", + ); + }); + + test.each([ + ["http://example.com", undefined], + ["http://example.com", "http,https"], + ["http://example.com", "http;https"], + ["https://example.com", "http|https"], + ["ftp://example.com", [" FTP: ", " HTTP: "]], + ])("should return valid when the value is a valid URL with an allowed protocol", (value, args) => { + const outcome = url(value, args) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); + + test.each([])("should return valid when the value is a valid URL with an allowed protocol", (value) => {}); +}); diff --git a/src/validation/rules/containsDigits.ts b/src/validation/rules/containsDigits.ts index 4a7ad24..ba029b1 100644 --- a/src/validation/rules/containsDigits.ts +++ b/src/validation/rules/containsDigits.ts @@ -13,7 +13,7 @@ const containsDigits: ValidationRule = (value: unknown, args: unknown): RuleExec } const requiredDigits: number = Number(args); - if (!isNaN(requiredDigits) || requiredDigits <= 0) { + if (isNaN(requiredDigits) || requiredDigits <= 0) { return { severity: "warning", message: "The arguments should be a positive number." }; } diff --git a/src/validation/rules/containsLowercase.ts b/src/validation/rules/containsLowercase.ts index a5a3a14..5bc9c1f 100644 --- a/src/validation/rules/containsLowercase.ts +++ b/src/validation/rules/containsLowercase.ts @@ -13,7 +13,7 @@ const containsLowercase: ValidationRule = (value: unknown, args: unknown): RuleE } const requiredLowercase: number = Number(args); - if (!isNaN(requiredLowercase) || requiredLowercase <= 0) { + if (isNaN(requiredLowercase) || requiredLowercase <= 0) { return { severity: "warning", message: "The arguments should be a positive number." }; } diff --git a/src/validation/rules/containsNonAlphanumeric.ts b/src/validation/rules/containsNonAlphanumeric.ts index 4a6c8c3..43eee83 100644 --- a/src/validation/rules/containsNonAlphanumeric.ts +++ b/src/validation/rules/containsNonAlphanumeric.ts @@ -13,7 +13,7 @@ const containsNonAlphanumeric: ValidationRule = (value: unknown, args: unknown): } const requiredNonAlphanumeric: number = Number(args); - if (!isNaN(requiredNonAlphanumeric) || requiredNonAlphanumeric <= 0) { + if (isNaN(requiredNonAlphanumeric) || requiredNonAlphanumeric <= 0) { return { severity: "warning", message: "The arguments should be a positive number." }; } diff --git a/src/validation/rules/containsUppercase.ts b/src/validation/rules/containsUppercase.ts index d760699..7144896 100644 --- a/src/validation/rules/containsUppercase.ts +++ b/src/validation/rules/containsUppercase.ts @@ -13,13 +13,13 @@ const containsUppercase: ValidationRule = (value: unknown, args: unknown): RuleE } const requiredUppercase: number = Number(args); - if (!isNaN(requiredUppercase) || requiredUppercase <= 0) { + if (isNaN(requiredUppercase) || requiredUppercase <= 0) { return { severity: "warning", message: "The arguments should be a positive number." }; } const uppercase: number = [...value].filter((c) => isLetter(c) && c.toUpperCase() === c).length; if (uppercase < requiredUppercase) { - return { severity: "error", message: "{{name}} must contain at least {{containsUppercase}} Uppercase letter(s)." }; + return { severity: "error", message: "{{name}} must contain at least {{containsUppercase}} uppercase letter(s)." }; } return { severity: "information" }; diff --git a/src/validation/rules/maximumLength.ts b/src/validation/rules/maximumLength.ts index 4548efb..9db6cbe 100644 --- a/src/validation/rules/maximumLength.ts +++ b/src/validation/rules/maximumLength.ts @@ -8,17 +8,17 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; */ const maximumLength: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { const maximumLength: number = Number(args); - if (!isNaN(maximumLength) || maximumLength <= 0) { + if (isNaN(maximumLength) || maximumLength <= 0) { return { severity: "warning", message: "The arguments should be a positive number." }; } if (typeof value === "string") { if (value.length > maximumLength) { - return { severity: "error", message: "{{name}} must be at most {{maximumLength}} characters long." }; + return { severity: "error", message: "{{name}} must be at most {{maximumLength}} character(s) long." }; } } else if (Array.isArray(value)) { if (value.length > maximumLength) { - return { severity: "error", message: "{{name}} must contain at most {{maximumLength}} elements." }; + return { severity: "error", message: "{{name}} must contain at most {{maximumLength}} element(s)." }; } } else { return { severity: "error", message: "{{name}} must be a string or an array." }; diff --git a/src/validation/rules/minimumLength.ts b/src/validation/rules/minimumLength.ts index 9ed0f01..a356b88 100644 --- a/src/validation/rules/minimumLength.ts +++ b/src/validation/rules/minimumLength.ts @@ -8,17 +8,17 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; */ const minimumLength: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcome => { const minimumLength: number = Number(args); - if (!isNaN(minimumLength) || minimumLength <= 0) { + if (isNaN(minimumLength) || minimumLength <= 0) { return { severity: "warning", message: "The arguments should be a positive number." }; } if (typeof value === "string") { if (value.length < minimumLength) { - return { severity: "error", message: "{{name}} must be at least {{maximumLength}} characters long." }; + return { severity: "error", message: "{{name}} must be at least {{minimumLength}} character(s) long." }; } } else if (Array.isArray(value)) { if (value.length < minimumLength) { - return { severity: "error", message: "{{name}} must contain at least {{maximumLength}} elements." }; + return { severity: "error", message: "{{name}} must contain at least {{minimumLength}} element(s)." }; } } else { return { severity: "error", message: "{{name}} must be a string or an array." }; diff --git a/src/validation/rules/pattern.ts b/src/validation/rules/pattern.ts index 03fff29..6390b5f 100644 --- a/src/validation/rules/pattern.ts +++ b/src/validation/rules/pattern.ts @@ -12,7 +12,7 @@ const pattern: ValidationRule = (value: unknown, args: unknown): RuleExecutionOu } else if (typeof args !== "string" && !(args instanceof RegExp)) { return { severity: "warning", message: "The arguments should be a regular expression." }; } else if (!new RegExp(args).test(value)) { - return { severity: "error", message: "{{field}} must match the pattern {{pattern}}." }; + return { severity: "error", message: "{{name}} must match the pattern {{pattern}}." }; } return { severity: "information" }; }; diff --git a/src/validation/rules/uniqueCharacters.ts b/src/validation/rules/uniqueCharacters.ts index 7378bb4..bc1cff9 100644 --- a/src/validation/rules/uniqueCharacters.ts +++ b/src/validation/rules/uniqueCharacters.ts @@ -12,13 +12,13 @@ const uniqueCharacters: ValidationRule = (value: unknown, args: unknown): RuleEx } const uniqueCharacters: number = Number(args); - if (!isNaN(uniqueCharacters) || uniqueCharacters <= 0) { + if (isNaN(uniqueCharacters) || uniqueCharacters <= 0) { return { severity: "warning", message: "The arguments should be a positive number." }; } const count: number = [...new Set(value)].length; if (count < uniqueCharacters) { - return { severity: "error", message: "{{name}} must contain at least {{uniqueCharacters}} unique characters." }; + return { severity: "error", message: "{{name}} must contain at least {{uniqueCharacters}} unique character(s)." }; } return { severity: "information" }; diff --git a/src/validation/rules/url.ts b/src/validation/rules/url.ts index e71824c..f75c055 100644 --- a/src/validation/rules/url.ts +++ b/src/validation/rules/url.ts @@ -1,6 +1,15 @@ import type { RuleExecutionOutcome, ValidationRule } from "../types"; import { isNullOrWhiteSpace, trimEnd } from "../../helpers/stringUtils"; +/** + * Format a protocol string to be used in a set. + * @param protocol The protocol to format. + * @returns The formatted protocol. + */ +function format(protocol: string): string { + return trimEnd(protocol.trim().toLowerCase(), ":"); +} + /** * A validation rule that checks if a string is a valid URL. * @param value The value to validate. @@ -26,7 +35,7 @@ const url: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcom if (values.length === 0) { isArgsValid = false; } else { - values.forEach((value) => protocols.add(trimEnd(value.trim().toLowerCase(), ":"))); + values.forEach((value) => protocols.add(format(value))); } } @@ -37,14 +46,15 @@ const url: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutcom return { severity: "error", message: "{{name}} must be a valid URL." }; } - if (!protocols.has(url.protocol.toLowerCase())) { + if (!protocols.has(format(url.protocol))) { return { severity: "error", message: `{{name}} must be an URL with one of the following protocols: ${[...protocols].join(", ")}.` }; } if (!isArgsValid) { return { severity: "warning", - message: "The arguments must be undefined, or a string containing the allowed protocols separated by commas, semicolons or pipes.", + message: + "The arguments must be undefined, a string containing the allowed protocols separated by commas, semicolons or pipes, or an array of allowed protocols.", }; } From ffea970e9a040e3f5be015676afbd30b6c048caa Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 21 Apr 2025 01:04:31 -0400 Subject: [PATCH 21/23] Release 1.1.0 --- CHANGELOG.md | 9 ++++++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe98d7a..64e8e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Nothing yet. +## [1.1.0] - 2025-04-21 + +### Added + +- Implemented validation. + ## [1.0.1] - 2025-04-20 ### Changed @@ -63,7 +69,8 @@ Official release. - Helper functions for arrays, Dates, objects, parsing, strings and URLs. -[unreleased]: https://github.com/Logitar/js/compare/v1.0.1...HEAD +[unreleased]: https://github.com/Logitar/js/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/Logitar/js/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/Logitar/js/compare/v1.0.1...v1.0.1 [1.0.0]: https://github.com/Logitar/js/compare/v0.5.0...v1.0.0 [0.5.0]: https://github.com/Logitar/js/compare/v0.4.0...v0.5.0 diff --git a/package-lock.json b/package-lock.json index 5a4c58a..0384067 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "logitar-js", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "logitar-js", - "version": "1.0.1", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@vitest/coverage-v8": "^1.5.0", diff --git a/package.json b/package.json index 4c2a206..682eb61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "logitar-js", - "version": "1.0.1", + "version": "1.1.0", "description": "Helper functions distributed by Logitar.", "keywords": [ "logitar", From f4217cff53bc3db36897f43b7b84261824e935ae Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 21 Apr 2025 10:38:04 -0400 Subject: [PATCH 22/23] coverage --- src/validation/rules/__tests__/email.spec.ts | 8 +++++++- src/validation/rules/__tests__/maximumValue.spec.ts | 12 ++++++++++++ src/validation/rules/__tests__/minimumValue.spec.ts | 12 ++++++++++++ src/validation/rules/email.ts | 2 +- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/validation/rules/__tests__/email.spec.ts b/src/validation/rules/__tests__/email.spec.ts index d3527e5..d0f5663 100644 --- a/src/validation/rules/__tests__/email.spec.ts +++ b/src/validation/rules/__tests__/email.spec.ts @@ -10,7 +10,7 @@ describe("email", () => { expect(outcome.message).toBe("{{name}} must be a string."); }); - test.each([null, {}, [], true, 0, 0n])("should return warning when the args are not valid", (args) => { + test.each([null, {}, [], 0, 0n])("should return warning when the args are not valid", (args) => { const outcome = email("test@example.com", args) as RuleExecutionOutcome; expect(outcome.severity).toBe("warning"); expect(outcome.message).toBe("The arguments must be undefined, or a valid email address validation regular expression."); @@ -27,4 +27,10 @@ describe("email", () => { expect(outcome.severity).toBe("information"); expect(outcome.message).toBeUndefined(); }); + + it.concurrent("should return valid when the value matches the arguments pattern", () => { + const outcome = email("test@example.com", /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/i) as RuleExecutionOutcome; + expect(outcome.severity).toBe("information"); + expect(outcome.message).toBeUndefined(); + }); }); diff --git a/src/validation/rules/__tests__/maximumValue.spec.ts b/src/validation/rules/__tests__/maximumValue.spec.ts index 8fc2646..1fcccde 100644 --- a/src/validation/rules/__tests__/maximumValue.spec.ts +++ b/src/validation/rules/__tests__/maximumValue.spec.ts @@ -39,4 +39,16 @@ describe("maximumValue", () => { expect(outcome.severity).toBe("information"); expect(outcome.message).toBeUndefined(); }); + + it.concurrent("should return warning when the values could not be compared", () => { + const a = { + valueOf: function () { + throw new Error("Error during valueOf"); + }, + }; + const b = 5; + const outcome = maximumValue(a, b) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("Could not compare {{name}} ({{value}} | object) with args ({{maximumValue}} | number)."); + }); }); diff --git a/src/validation/rules/__tests__/minimumValue.spec.ts b/src/validation/rules/__tests__/minimumValue.spec.ts index 5ac7927..5e739f9 100644 --- a/src/validation/rules/__tests__/minimumValue.spec.ts +++ b/src/validation/rules/__tests__/minimumValue.spec.ts @@ -39,4 +39,16 @@ describe("minimumValue", () => { expect(outcome.severity).toBe("information"); expect(outcome.message).toBeUndefined(); }); + + it.concurrent("should return warning when the values could not be compared", () => { + const a = { + valueOf: function () { + throw new Error("Error during valueOf"); + }, + }; + const b = 5; + const outcome = minimumValue(a, b) as RuleExecutionOutcome; + expect(outcome.severity).toBe("warning"); + expect(outcome.message).toBe("Could not compare {{name}} ({{value}} | object) with args ({{minimumValue}} | number)."); + }); }); diff --git a/src/validation/rules/email.ts b/src/validation/rules/email.ts index a1a5aaf..bede65a 100644 --- a/src/validation/rules/email.ts +++ b/src/validation/rules/email.ts @@ -20,7 +20,7 @@ const email: ValidationRule = (value: unknown, args: unknown): RuleExecutionOutc regex = new RegExp(args); } else { regex = new RegExp(defaultRegex); - if (typeof args !== "undefined") { + if (typeof args !== "undefined" && typeof args !== "boolean") { isArgsValid = false; } } From 10360148181e9e955446b5595910d5eca0e85e50 Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Mon, 21 Apr 2025 12:22:35 -0400 Subject: [PATCH 23/23] test --- src/validation/__tests__/validator.spec.ts | 296 +++++++++++++++++++++ src/validation/index.ts | 14 +- 2 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 src/validation/__tests__/validator.spec.ts diff --git a/src/validation/__tests__/validator.spec.ts b/src/validation/__tests__/validator.spec.ts new file mode 100644 index 0000000..eb6f99d --- /dev/null +++ b/src/validation/__tests__/validator.spec.ts @@ -0,0 +1,296 @@ +import { describe, it, expect } from "vitest"; + +import Validator from ".."; +import containsNonAlphanumeric from "../rules/containsNonAlphanumeric"; +import email from "../rules/email"; +import required from "../rules/required"; +import type { + RuleConfiguration, + RuleExecutionOutcome, + RuleOptions, + ValidationContext, + ValidationResult, + ValidationRule, + ValidationRuleKey, + ValidationSeverity, +} from "../types"; + +const required_alt: ValidationRule = (value: unknown): boolean => { + return Boolean(value); +}; + +const emailRegex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; +const email_alt: ValidationRule = (value: unknown): ValidationSeverity => { + const isValid: boolean = typeof value === "string" && emailRegex.test(value); + return isValid ? "information" : "error"; +}; + +const not_empty: ValidationRule = (value: unknown, _, context: ValidationContext | undefined): RuleExecutionOutcome => { + const trimmed = typeof value === "string" ? value.trim() : ""; + let name: string = ""; + if (context && context.name) { + name = context.name as string; + } + return { + severity: trimmed.length > 0 ? "information" : "error", + key: "NotEmptyValidator", + message: "{{name}} ({{original}} → {{value}}) cannot be an empty string.", + placeholders: { original: value, value: trimmed }, + name: "'" + name + "'", + value: trimmed, + custom: { value, trimmed }, + }; +}; + +describe("Validator", () => { + it.concurrent("should clear all registered rules", () => { + const validator = new Validator(); + validator.setRule("email", email); + validator.setRule("required", required); + validator.clearRules(); + expect(validator.listRules().length).toBe(0); + }); + + it.concurrent("should get a specific rule", () => { + const validator = new Validator(); + validator.setRule("email", email); + validator.setRule("required", required); + const configuration: RuleConfiguration | undefined = validator.getRule("email"); + expect(configuration).toBeDefined(); + expect(configuration?.rule).toBe(email); + expect(configuration?.options).toEqual({}); + }); + + it.concurrent("should return undefined when a rule has not been registered", () => { + const validator = new Validator(); + validator.setRule("required", required); + const configuration: RuleConfiguration | undefined = validator.getRule("email"); + expect(configuration).toBeUndefined(); + }); + + it.concurrent("should check if a rule has been registered", () => { + const validator = new Validator(); + expect(validator.hasRule("required")).toBe(false); + validator.setRule("required", required); + expect(validator.hasRule("required")).toBe(true); + }); + + it.concurrent("should list all registered rules", () => { + const validator = new Validator(); + let rules: [ValidationRuleKey, RuleConfiguration][] = validator.listRules(); + expect(rules.length).toBe(0); + validator.setRule("email", email); + validator.setRule("required", required); + rules = validator.listRules(); + expect(rules.length).toBe(2); + expect(rules[0][0]).toBe("email"); + expect(rules[1][0]).toBe("required"); + }); + + it.concurrent("should remove a rule", () => { + const validator = new Validator(); + validator.setRule("email", email); + validator.setRule("required", required); + validator.removeRule("email"); + expect(validator.hasRule("email")).toBe(false); + expect(validator.hasRule("required")).toBe(true); + }); + + it.concurrent("should register a rule without options", () => { + const validator = new Validator(); + validator.setRule("email", email); + const configuration: RuleConfiguration | undefined = validator.getRule("email"); + expect(configuration).toBeDefined(); + expect(configuration?.rule).toBe(email); + expect(configuration?.options).toEqual({}); + }); + + it.concurrent("should register a rule with options", () => { + const validator = new Validator(); + const options: RuleOptions = { + key: "EmailAddressValidator", + message: "{{name}} n’est pas une adresse courriel valide.", + placeholders: { locale: "fr" }, + }; + validator.setRule("email", email, options); + const configuration: RuleConfiguration | undefined = validator.getRule("email"); + expect(configuration).toBeDefined(); + expect(configuration?.rule).toBe(email); + expect(JSON.stringify(configuration?.options)).toBe(JSON.stringify(options)); + }); + + it.concurrent("should not execute validation rules when args are falsy", () => { + const validator = new Validator(); + validator.setRule("email", email); + validator.setRule("required", required); + const result: ValidationResult = validator.validate("email", "test@example.com", { required: true, email: false }); + expect(result.isValid).toBe(true); + expect(Object.keys(result.rules).length).toBe(1); + expect(result.rules.required.severity).toBe("information"); + expect(result.context).toEqual({}); + }); + + it.concurrent("should throw an error when a rule has not been registered", () => { + const validator = new Validator(); + validator.setRule("required", required); + expect(() => validator.validate("email", "test@example.com", { required: true, email: false })).toThrowError(); + }); + + it.concurrent("should succeed when all validation rules are satisfied", () => { + const validator = new Validator(); + validator.setRule("required", required); + validator.setRule("email", email); + const result: ValidationResult = validator.validate("email", "test@example.com", { required: true, email: true }); + expect(result.isValid).toBe(true); + expect(result.rules.required.severity).toBe("information"); + expect(result.rules.email.severity).toBe("information"); + }); + + it.concurrent("should succeed when warnings are not treated as errors (ctor)", () => { + const validator = new Validator({ treatWarningsAsErrors: false }); + validator.setRule("required", required); + validator.setRule("email", email); + const result: ValidationResult = validator.validate("email", "test@example.com", { required: true, email: 1 }); + expect(result.isValid).toBe(true); + expect(result.rules.required.severity).toBe("information"); + expect(result.rules.email.severity).toBe("warning"); + }); + + it.concurrent("should succeed when warnings are not treated as errors (validate)", () => { + const validator = new Validator({ treatWarningsAsErrors: false }); + validator.setRule("required", required); + validator.setRule("email", email); + const result: ValidationResult = validator.validate("email", "test@example.com", { required: true, email: 1 }, { treatWarningsAsErrors: false }); + expect(result.isValid).toBe(true); + expect(result.rules.required.severity).toBe("information"); + expect(result.rules.email.severity).toBe("warning"); + }); + + it.concurrent("should throw an error when throwing on failure (ctor)", () => { + const validator = new Validator({ throwOnFailure: true }); + validator.setRule("required", required); + expect(() => validator.validate("email", undefined, { required: true }, { throwOnFailure: undefined })).toThrowError(); + }); + + it.concurrent("should throw an error when throwing on failure (validator)", () => { + const validator = new Validator({ throwOnFailure: false }); + validator.setRule("required", required); + expect(() => validator.validate("email", undefined, { required: true }, { throwOnFailure: true })).toThrowError(); + }); + + it.concurrent("should fail when some validation rules fail", () => { + const validator = new Validator(); + validator.setRule("required", required); + validator.setRule("email", email); + validator.setRule("containsNonAlphanumeric", containsNonAlphanumeric); + const result: ValidationResult = validator.validate("email", "test@example.com", { required: true, email: true, containsNonAlphanumeric: 3 }); + expect(result.isValid).toBe(false); + expect(result.rules.required.severity).toBe("information"); + expect(result.rules.email.severity).toBe("information"); + expect(result.rules.containsNonAlphanumeric.severity).toBe("error"); + expect(result.rules.containsNonAlphanumeric.message).toBe("email must contain at least 3 non-alphanumeric character(s)."); + }); + + it.concurrent("should fail when warnings are treated as errors (ctor)", () => { + const validator = new Validator({ treatWarningsAsErrors: true }); + validator.setRule("required", required); + validator.setRule("email", email); + const result: ValidationResult = validator.validate("email", "test@example.com", { required: true, email: 1 }, { treatWarningsAsErrors: undefined }); + expect(result.isValid).toBe(false); + expect(result.rules.required.severity).toBe("information"); + expect(result.rules.email.severity).toBe("warning"); + expect(result.rules.email.message).toBe("The arguments must be undefined, or a valid email address validation regular expression."); + }); + + it.concurrent("should fail when warnings are treated as errors (validate)", () => { + const validator = new Validator({ treatWarningsAsErrors: false }); + validator.setRule("required", required); + validator.setRule("email", email); + const result = validator.validate("email", "test@example.com", { required: true, email: 1 }, { treatWarningsAsErrors: true }); + expect(result.isValid).toBe(false); + expect(result.rules.required.severity).toBe("information"); + expect(result.rules.email.severity).toBe("warning"); + expect(result.rules.email.message).toBe("The arguments must be undefined, or a valid email address validation regular expression."); + }); + + it.concurrent("should use key and message rule override", () => { + const validator = new Validator(); + validator.setRule("required", required); + validator.setRule("email", email, { key: "EmailAddressValidator", message: "{{name}} doit être une adresse courriel valide." }); + const result: ValidationResult = validator.validate("email", "test@example.com", { required: true, email: true }); + expect(result.isValid).toBe(true); + expect(result.rules.required.severity).toBe("information"); + expect(result.rules.required.key).toBe("required"); + expect(result.rules.required.message).toBeUndefined(); + expect(result.rules.email.severity).toBe("information"); + expect(result.rules.email.key).toBe("EmailAddressValidator"); + expect(result.rules.email.message).toBe("email doit être une adresse courriel valide."); + }); + + it.concurrent("should use placeholders provided in the rule options", () => { + const validator = new Validator(); + validator.setRule("required", required, { placeholders: { name: "This field" } }); + const result: ValidationResult = validator.validate("email", " ", { required: true }); + expect(result.isValid).toBe(false); + expect(result.rules.required.message).toBe("This field cannot be an empty string."); + expect(result.rules.required.placeholders.name).toBe("This field"); + }); + + it.concurrent("should use placeholders provided in the validation options", () => { + const validator = new Validator(); + validator.setRule("required", required); + const result: ValidationResult = validator.validate("email", " ", { required: true }, { placeholders: { name: "This field" } }); + expect(result.isValid).toBe(false); + expect(result.rules.required.message).toBe("This field cannot be an empty string."); + expect(result.rules.required.placeholders.name).toBe("This field"); + }); + + it.concurrent("should handle rules returning a boolean value (invalid)", () => { + const validator = new Validator(); + validator.setRule("required", required_alt); + const result: ValidationResult = validator.validate("email", "", { required: true }); + expect(result.isValid).toBe(false); + expect(result.rules.required.severity).toBe("error"); + expect(result.rules.required.message).toBeUndefined(); + }); + + it.concurrent("should handle rules returning a boolean value (valid)", () => { + const validator = new Validator(); + validator.setRule("required", required_alt); + const result: ValidationResult = validator.validate("email", "test@example.com", { required: true }); + expect(result.isValid).toBe(true); + expect(result.rules.required.severity).toBe("information"); + expect(result.rules.required.message).toBeUndefined(); + }); + + it.concurrent("should handle rules returning a validation severity (invalid)", () => { + const validator = new Validator(); + validator.setRule("email", email_alt); + const result: ValidationResult = validator.validate("email", "", { email: true }); + expect(result.isValid).toBe(false); + expect(result.rules.email.severity).toBe("error"); + expect(result.rules.email.message).toBeUndefined(); + }); + + it.concurrent("should handle rules returning a validation severity (valid)", () => { + const validator = new Validator(); + validator.setRule("email", email_alt); + const result: ValidationResult = validator.validate("email", "test@example.com", { email: true }); + expect(result.isValid).toBe(true); + expect(result.rules.email.severity).toBe("information"); + expect(result.rules.email.message).toBeUndefined(); + }); + + it.concurrent("should handle rule execution outcome values", () => { + const validator = new Validator(); + validator.setRule("notEmpty", not_empty); + const result: ValidationResult = validator.validate("email", " ", { notEmpty: true }, { context: { name: "email" } }); + expect(result.isValid).toBe(false); + expect(result.rules.notEmpty.severity).toBe("error"); + expect(result.rules.notEmpty.key).toBe("NotEmptyValidator"); + expect(result.rules.notEmpty.message).toBe("'email' ( → ) cannot be an empty string."); + expect(result.rules.notEmpty.name).toBe("'email'"); + expect(result.rules.notEmpty.value).toBe(""); + expect(JSON.stringify(result.rules.notEmpty.custom)).toBe(JSON.stringify({ value: " ", trimmed: "" })); + }); +}); diff --git a/src/validation/index.ts b/src/validation/index.ts index a014697..f8b6178 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -33,7 +33,7 @@ function apply(result: RuleExecutionResult, outcome: RuleExecutionOutcome, optio // message if (!isNullOrWhiteSpace(options.message)) { result.message = options.message; - } else if (!isNullOrWhiteSpace(options.message)) { + } else if (!isNullOrWhiteSpace(outcome.message)) { result.message = outcome.message; } // name @@ -98,7 +98,7 @@ class Validator { * @param options The options of the validator. */ constructor(options?: ValidatorOptions) { - options ??= options; + options ??= {}; this.messageFormatter = options.messageFormatter ?? new DefaultMessageFormatter(); this.rules = new Map(); this.throwOnFailure = options.throwOnFailure ?? false; @@ -154,7 +154,7 @@ class Validator { * @param options The options of the rule. */ setRule(key: ValidationRuleKey, rule: ValidationRule, options?: RuleOptions): void { - options ??= options; + options ??= {}; const configuration: RuleConfiguration = { rule, options }; this.rules.set(key, configuration); } @@ -219,6 +219,10 @@ class Validator { results[key] = result; } + if (missingRules.length > 0) { + throw new Error(`The following rules are not registered: ${missingRules.join(", ")}`); + } + const result: ValidationResult = { isValid: errors === 0, rules: results, @@ -238,7 +242,9 @@ class Validator { private formatMessage(result: RuleExecutionResult, options?: ValidationOptions): void { options ??= {}; const messageFormatter: MessageFormatter = options.messageFormatter ?? this.messageFormatter; - result.message = messageFormatter.format(result.message, result.placeholders); + if (typeof result.message === "string") { + result.message = messageFormatter.format(result.message, result.placeholders); + } } /**