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", 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/format.ts b/src/validation/format.ts new file mode 100644 index 0000000..39292e7 --- /dev/null +++ b/src/validation/format.ts @@ -0,0 +1,33 @@ +/** + * 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) { + 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 new file mode 100644 index 0000000..f8b6178 --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,271 @@ +import DefaultMessageFormatter, { MessageFormatter } from "./format"; +import { isNullOrWhiteSpace } from "../helpers/stringUtils"; +import type { + RuleConfiguration, + RuleExecutionOutcome, + RuleExecutionResult, + RuleOptions, + ValidationContext, + ValidationOptions, + ValidationResult, + ValidationRule, + ValidationRuleKey, + ValidationRuleSet, + ValidationSeverity, + 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; + // 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(outcome.message)) { + result.message = outcome.message; + } + // name + if (!isNullOrWhiteSpace(outcome.name)) { + result.name = outcome.name; + } + // value + if (typeof outcome.value !== "undefined") { + result.value = outcome.value; + } + // custom + 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; + 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 }; + } +} + +/** + * 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; + /** + * 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 ??= {}; + 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 ??= {}; + 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 ?? {}; + + let errors: number = 0; + const results: Record = {}; + + const missingRules: string[] = []; + for (const key in rules) { + const configuration: RuleConfiguration | undefined = this.rules.get(key); + if (!configuration) { + missingRules.push(key); + continue; + } + + const args: unknown = rules[key]; + if (!args) { + continue; + } + + const result: RuleExecutionResult = { + key, + severity: "error", + placeholders: { [key]: args }, + name, + value, + }; + + const outcome: boolean | ValidationSeverity | RuleExecutionOutcome = configuration.rule(value, args, context); + switch (typeof outcome) { + case "boolean": + result.severity = Boolean(outcome) ? "information" : "error"; + break; + case "string": + result.severity = outcome; + break; + default: + apply(result, outcome, configuration.options); + break; + } + + fillPlaceholders(result, typeof outcome === "object" ? outcome : undefined, configuration.options, options); + + this.formatMessage(result, options); + + if (this.isError(result.severity, options)) { + errors++; + } + + 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, + context, + }; + 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; + if (typeof result.message === "string") { + 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) { + case "error": + case "critical": + return true; + case "warning": + if (options.treatWarningsAsErrors ?? this.treatWarningsAsErrors) { + return true; + } + break; + } + return false; + } +} +export default Validator; 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..d0f5663 --- /dev/null +++ b/src/validation/rules/__tests__/email.spec.ts @@ -0,0 +1,36 @@ +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, {}, [], 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(); + }); + + 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__/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..1fcccde --- /dev/null +++ b/src/validation/rules/__tests__/maximumValue.spec.ts @@ -0,0 +1,54 @@ +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(); + }); + + 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__/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..5e739f9 --- /dev/null +++ b/src/validation/rules/__tests__/minimumValue.spec.ts @@ -0,0 +1,54 @@ +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(); + }); + + 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/__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/allowedCharacters.ts b/src/validation/rules/allowedCharacters.ts new file mode 100644 index 0000000..abd610c --- /dev/null +++ b/src/validation/rules/allowedCharacters.ts @@ -0,0 +1,27 @@ +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." }; + } 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..1b08c3e --- /dev/null +++ b/src/validation/rules/confirm.ts @@ -0,0 +1,17 @@ +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) { + 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..ba029b1 --- /dev/null +++ b/src/validation/rules/containsDigits.ts @@ -0,0 +1,28 @@ +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." }; + } + + 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..5bc9c1f --- /dev/null +++ b/src/validation/rules/containsLowercase.ts @@ -0,0 +1,28 @@ +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." }; + } + + 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..43eee83 --- /dev/null +++ b/src/validation/rules/containsNonAlphanumeric.ts @@ -0,0 +1,28 @@ +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." }; + } + + 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..7144896 --- /dev/null +++ b/src/validation/rules/containsUppercase.ts @@ -0,0 +1,28 @@ +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." }; + } + + 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 new file mode 100644 index 0000000..bede65a --- /dev/null +++ b/src/validation/rules/email.ts @@ -0,0 +1,37 @@ +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." }; + } + + 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" && typeof args !== "boolean") { + 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" }; +}; + +export default email; diff --git a/src/validation/rules/identifier.ts b/src/validation/rules/identifier.ts new file mode 100644 index 0000000..a740702 --- /dev/null +++ b/src/validation/rules/identifier.ts @@ -0,0 +1,22 @@ +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." }; + } 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/maximumLength.ts b/src/validation/rules/maximumLength.ts new file mode 100644 index 0000000..9db6cbe --- /dev/null +++ b/src/validation/rules/maximumLength.ts @@ -0,0 +1,30 @@ +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) { + 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}} character(s) long." }; + } + } else if (Array.isArray(value)) { + if (value.length > maximumLength) { + 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." }; + } + + 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..042f2d0 --- /dev/null +++ b/src/validation/rules/maximumValue.ts @@ -0,0 +1,20 @@ +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) { + 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..a356b88 --- /dev/null +++ b/src/validation/rules/minimumLength.ts @@ -0,0 +1,30 @@ +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) { + 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 {{minimumLength}} character(s) long." }; + } + } else if (Array.isArray(value)) { + if (value.length < minimumLength) { + 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." }; + } + + 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..06492df --- /dev/null +++ b/src/validation/rules/minimumValue.ts @@ -0,0 +1,20 @@ +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) { + 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..6390b5f --- /dev/null +++ b/src/validation/rules/pattern.ts @@ -0,0 +1,20 @@ +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." }; + } 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: "{{name}} must match the pattern {{pattern}}." }; + } + return { severity: "information" }; +}; + +export default pattern; diff --git a/src/validation/rules/required.ts b/src/validation/rules/required.ts new file mode 100644 index 0000000..0637c7c --- /dev/null +++ b/src/validation/rules/required.ts @@ -0,0 +1,34 @@ +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": + if (isNaN(value) || value === 0) { + return { severity: "error", message: "{{name}} must be a number different from 0." }; + } + break; + case "string": + if (isNullOrWhiteSpace(value)) { + return { severity: "error", message: "{{name}} cannot be an empty string." }; + } + break; + default: + if (Array.isArray(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: "information" }; +}; + +export default required; diff --git a/src/validation/rules/slug.ts b/src/validation/rules/slug.ts new file mode 100644 index 0000000..ce676ea --- /dev/null +++ b/src/validation/rules/slug.ts @@ -0,0 +1,18 @@ +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." }; + } 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/uniqueCharacters.ts b/src/validation/rules/uniqueCharacters.ts new file mode 100644 index 0000000..bc1cff9 --- /dev/null +++ b/src/validation/rules/uniqueCharacters.ts @@ -0,0 +1,27 @@ +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." }; + } + + 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 character(s)." }; + } + + return { severity: "information" }; +}; + +export default uniqueCharacters; diff --git a/src/validation/rules/url.ts b/src/validation/rules/url.ts new file mode 100644 index 0000000..f75c055 --- /dev/null +++ b/src/validation/rules/url.ts @@ -0,0 +1,64 @@ +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. + * @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(format(value))); + } + } + + let url: URL; + try { + url = new URL(value.trim()); + } catch (_) { + return { severity: "error", message: "{{name}} must be a valid URL." }; + } + + 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, a string containing the allowed protocols separated by commas, semicolons or pipes, or an array of allowed protocols.", + }; + } + + return { severity: "information" }; +}; + +export default url; diff --git a/src/validation/types.ts b/src/validation/types.ts new file mode 100644 index 0000000..37e365b --- /dev/null +++ b/src/validation/types.ts @@ -0,0 +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; + /** + * 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; + +/** + * 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; +};