From 7e34384efc136719e7a131bb729a7110833853ba Mon Sep 17 00:00:00 2001 From: Drew Immerman Date: Sun, 4 Oct 2026 10:20:07 -0400 Subject: [PATCH] #38 feat: add metadata support for Valsans with titles and descriptions - Introduced `title` and `description` fields in `ValSanOptions` for schema documentation and JSON Schema output. - Implemented `getTitle()` and `getDescription()` methods in `BaseValSan` to retrieve metadata. - Enhanced various Valsans (e.g., `MacAddressValSan`, `EmailValidator`, `ComposedValSan`, etc.) with default titles and descriptions. - Updated validation descriptions to include helper texts from rules. - Added tests to verify metadata functionality and ensure proper integration across built-in Valsans. --- docs/composed-valsan.md | 22 ++ docs/using-options.md | 28 ++ src/primitives/array/array-valsan.ts | 3 + src/primitives/auth/bearer-token-valsan.ts | 4 + src/primitives/bool/string-to-boolean.ts | 4 + src/primitives/color/hex-color-valsan.ts | 4 + .../date-time/iso8601-timestamp-valsan.ts | 3 + src/primitives/date-time/string-to-date.ts | 4 + src/primitives/encoding/semver-valsan.ts | 3 + src/primitives/encoding/uuid-valsan.ts | 3 + src/primitives/json/json-valsan.ts | 3 + src/primitives/network/fqdn-valsan.ts | 2 + src/primitives/network/ip-address-valsan.ts | 2 + src/primitives/network/mac-address-valsan.ts | 4 + src/primitives/network/port-number-valsan.ts | 4 + src/primitives/network/url-valsan.ts | 2 + src/primitives/number/decimal-validator.ts | 4 + src/primitives/number/integer-validator.ts | 4 + src/primitives/number/max-validator.ts | 5 +- src/primitives/number/min-validator.ts | 5 +- src/primitives/number/range-validator.ts | 7 +- src/primitives/number/string-to-number.ts | 2 + src/primitives/object/object-valsan.ts | 3 + src/primitives/person/email-validator.ts | 4 + src/primitives/string/alpha-validator.ts | 3 + .../string/alphanumeric-validator.ts | 3 + src/primitives/string/length-validator.ts | 3 + src/primitives/string/lowercase-sanitizer.ts | 2 + src/primitives/string/max-length-validator.ts | 5 +- src/primitives/string/min-length-validator.ts | 5 +- src/primitives/string/pattern-validator.ts | 3 + src/primitives/string/slug-validator.ts | 4 + src/primitives/string/trim-sanitizer.ts | 3 + src/primitives/string/uppercase-sanitizer.ts | 2 + src/primitives/utility/enum-validator.ts | 3 + src/valsan-base.ts | 45 +++ src/valsan-composed.ts | 12 + src/valsan.ts | 21 ++ test/spec/valsan/metadata.spec.ts | 265 ++++++++++++++++++ 39 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 test/spec/valsan/metadata.spec.ts diff --git a/docs/composed-valsan.md b/docs/composed-valsan.md index 46725a7..baa6c5c 100644 --- a/docs/composed-valsan.md +++ b/docs/composed-valsan.md @@ -68,3 +68,25 @@ const strict = new EmailAddressValSan({ minLength: 8, maxLength: 50 }); await standard.run('user@example.com'); // ✅ passes await strict.run('a@b.c'); // ❌ fails minLength ``` + +## Rules and Validation Descriptions + +`ComposedValSan.rules()` merges rules from its steps. Override `rules()` in a composed validator when you need a different user-facing summary: + +```typescript +class EmailAddressValSan extends ComposedValSan { + override rules() { + return { + email: { + code: 'email', + user: { + helperText: 'A valid email address', + errorMessage: 'Input must be a valid email address' + } + } + }; + } + + // Define the composition in the constructor. +} +``` diff --git a/docs/using-options.md b/docs/using-options.md index d58550e..59f6642 100644 --- a/docs/using-options.md +++ b/docs/using-options.md @@ -15,6 +15,34 @@ You can use options to: All options are optional and have sensible defaults. See validator docs for details. +## Schema Metadata + +Built-in Valsans provide a title and description through `getTitle()` and `getDescription()` for schema documentation and JSON Schema output. Instance options override those defaults: + +```typescript +import { MacAddressValSan } from 'valsan'; + +const macAddress = new MacAddressValSan({ + description: 'The MAC address of the Wi-Fi NIC' +}); + +console.log(macAddress.getTitle()); // "MAC Address" +console.log(macAddress.getDescription()); // "The MAC address of the Wi-Fi NIC" +``` + +`getValidationDescription()` combines the Valsan description with its rule helper text. Composed Valsans include helper text from each step. + +Custom Valsans can provide intrinsic defaults by overriding the `title` and `description` fields: + +```typescript +class MyValSan extends ValSan { + override title = 'My value'; + override description = 'A value with custom validation.'; + // Implement the remaining ValSan methods. +} +``` + + ## Optional Values ### isOptional diff --git a/src/primitives/array/array-valsan.ts b/src/primitives/array/array-valsan.ts index 87c1eca..c56de65 100644 --- a/src/primitives/array/array-valsan.ts +++ b/src/primitives/array/array-valsan.ts @@ -33,6 +33,9 @@ export interface ArrayValSanOptions extends ValSanOptions { */ export class ArrayValSan extends ValSan { override type: ValSanTypes = 'array'; + override title = 'Array'; + override description = + 'An array whose items each satisfy the configured schema.'; public get schema(): ArraySchema { return (this.options as ArrayValSanOptions).schema; diff --git a/src/primitives/auth/bearer-token-valsan.ts b/src/primitives/auth/bearer-token-valsan.ts index b6dd93e..182882f 100644 --- a/src/primitives/auth/bearer-token-valsan.ts +++ b/src/primitives/auth/bearer-token-valsan.ts @@ -30,6 +30,10 @@ export class BearerTokenValSan extends ValSan< string > { override type: ValSanTypes = 'string'; + override title = 'Bearer token'; + override description = + 'An HTTP authorization value using the Bearer scheme and a ' + + 'non-empty token.'; override example = 'Bearer mF_9.B5f-4.1JqM'; override rules() { diff --git a/src/primitives/bool/string-to-boolean.ts b/src/primitives/bool/string-to-boolean.ts index 9f9603a..54f1c7c 100644 --- a/src/primitives/bool/string-to-boolean.ts +++ b/src/primitives/bool/string-to-boolean.ts @@ -53,6 +53,10 @@ export interface StringToBooleanValSanOptions extends ValSanOptions { */ export class StringToBooleanValSan extends ValSan { override type: ValSanTypes = 'boolean'; + override title = 'Boolean string'; + override description = + 'A string representing true or false, such as "true", "false", "1", ' + + 'or "0".'; override example = 'true'; private readonly trueValues: string[]; diff --git a/src/primitives/color/hex-color-valsan.ts b/src/primitives/color/hex-color-valsan.ts index 92a17bb..634e5e9 100644 --- a/src/primitives/color/hex-color-valsan.ts +++ b/src/primitives/color/hex-color-valsan.ts @@ -11,6 +11,10 @@ const hexColorRegex = export class HexColorValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Hex color'; + override description = + 'A hexadecimal color in 3-, 4-, 6-, or 8-digit notation, ' + + 'prefixed with "#".'; override format = 'color-hex'; override example = '#FF0000'; diff --git a/src/primitives/date-time/iso8601-timestamp-valsan.ts b/src/primitives/date-time/iso8601-timestamp-valsan.ts index 7c866f2..b5ce1b8 100644 --- a/src/primitives/date-time/iso8601-timestamp-valsan.ts +++ b/src/primitives/date-time/iso8601-timestamp-valsan.ts @@ -8,6 +8,9 @@ import { parseIsoTimestamp } from './parse-iso-timestamp'; */ export class Iso8601TimestampValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'ISO 8601 timestamp'; + override description = + 'A valid Date object or ISO 8601 timestamp.'; override format = 'date-time'; override example = '2023-01-01T12:00:00Z'; diff --git a/src/primitives/date-time/string-to-date.ts b/src/primitives/date-time/string-to-date.ts index ec930d0..dd64cad 100644 --- a/src/primitives/date-time/string-to-date.ts +++ b/src/primitives/date-time/string-to-date.ts @@ -31,6 +31,10 @@ export class StringToDateValSan extends ValSan< Date > { override type: ValSanTypes = 'string'; + override title = 'Date'; + override description = + 'An ISO calendar date, timestamp, numeric millisecond timestamp, ' + + 'or valid Date object.'; override format = 'date'; override example = '2024-01-15'; diff --git a/src/primitives/encoding/semver-valsan.ts b/src/primitives/encoding/semver-valsan.ts index d35110a..61a68a2 100644 --- a/src/primitives/encoding/semver-valsan.ts +++ b/src/primitives/encoding/semver-valsan.ts @@ -11,6 +11,9 @@ const semverRegex = export class SemverValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Semantic version'; + override description = + 'A Semantic Versioning 2.0.0 version string.'; override format = 'semver'; override example = '1.2.3'; diff --git a/src/primitives/encoding/uuid-valsan.ts b/src/primitives/encoding/uuid-valsan.ts index 5ddcf61..cdbf070 100644 --- a/src/primitives/encoding/uuid-valsan.ts +++ b/src/primitives/encoding/uuid-valsan.ts @@ -11,6 +11,9 @@ const uuidRegex = export class UuidValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'UUID'; + override description = + 'A UUID string in version 1, 3, 4, or 5 format.'; override format = 'uuid'; override example = '550e8400-e29b-41d4-a716-446655440000'; diff --git a/src/primitives/json/json-valsan.ts b/src/primitives/json/json-valsan.ts index 3fd4c12..2aa6959 100644 --- a/src/primitives/json/json-valsan.ts +++ b/src/primitives/json/json-valsan.ts @@ -5,6 +5,9 @@ import { stringRule } from '../string/string-rules'; export class JsonValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'JSON'; + override description = + 'A string containing valid JSON.'; override format = 'json'; override example = '{"key": "value"}'; diff --git a/src/primitives/network/fqdn-valsan.ts b/src/primitives/network/fqdn-valsan.ts index 4a8e155..4be43f2 100644 --- a/src/primitives/network/fqdn-valsan.ts +++ b/src/primitives/network/fqdn-valsan.ts @@ -6,6 +6,8 @@ import { isFqdn } from './is-fqdn'; export class FqdnValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Fully qualified domain name'; + override description = 'A fully qualified domain name (FQDN).'; override format = 'hostname'; override example = 'host.example.com'; diff --git a/src/primitives/network/ip-address-valsan.ts b/src/primitives/network/ip-address-valsan.ts index 8d4943b..1651d01 100644 --- a/src/primitives/network/ip-address-valsan.ts +++ b/src/primitives/network/ip-address-valsan.ts @@ -54,6 +54,8 @@ function isIpv6(input: string): boolean { export class IpAddressValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'IP address'; + override description = 'A valid IPv4 or IPv6 address.'; override example = '192.168.0.1'; override rules() { diff --git a/src/primitives/network/mac-address-valsan.ts b/src/primitives/network/mac-address-valsan.ts index 20557a1..8e2bd96 100644 --- a/src/primitives/network/mac-address-valsan.ts +++ b/src/primitives/network/mac-address-valsan.ts @@ -10,6 +10,10 @@ const macRegex = export class MacAddressValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'MAC Address'; + override description = + 'A MAC address in colon-separated, hyphen-separated, or dotted ' + + 'notation.'; override example = '00:1A:2B:3C:4D:5E'; override rules() { diff --git a/src/primitives/network/port-number-valsan.ts b/src/primitives/network/port-number-valsan.ts index c7e27dc..22cf99a 100644 --- a/src/primitives/network/port-number-valsan.ts +++ b/src/primitives/network/port-number-valsan.ts @@ -7,6 +7,10 @@ import { normalizeNumber } from '../number/normalize-number'; export class PortNumberValSan extends ValSan { // Represent port numbers as integers in OpenAPI override type: ValSanTypes = 'integer'; + override title = 'Port number'; + override description = + 'An integer from 0 through 65535, supplied as a number or numeric ' + + 'string.'; override format = 'int32'; override example = '8080'; diff --git a/src/primitives/network/url-valsan.ts b/src/primitives/network/url-valsan.ts index f6563de..00137a8 100644 --- a/src/primitives/network/url-valsan.ts +++ b/src/primitives/network/url-valsan.ts @@ -5,6 +5,8 @@ import { isString } from '../string/is-string'; export class UrlValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'URL'; + override description = 'An absolute URL.'; override format = 'uri'; override example = 'https://example.com'; diff --git a/src/primitives/number/decimal-validator.ts b/src/primitives/number/decimal-validator.ts index 0e0133b..2d9ee1e 100644 --- a/src/primitives/number/decimal-validator.ts +++ b/src/primitives/number/decimal-validator.ts @@ -69,6 +69,10 @@ export class DecimalValidator extends ValSan< number > { override type: ValSanTypes = 'number'; + override title = 'Decimal number'; + override description = + 'A numeric value with decimal places, optionally limited to a ' + + 'configured number of decimal places.'; override example = '3.14'; private readonly maxDecimalPlaces?: number; diff --git a/src/primitives/number/integer-validator.ts b/src/primitives/number/integer-validator.ts index 15c86a9..d44aed2 100644 --- a/src/primitives/number/integer-validator.ts +++ b/src/primitives/number/integer-validator.ts @@ -29,6 +29,10 @@ export class IntegerValidator extends ValSan< number > { override type: ValSanTypes = 'integer'; + override title = 'Integer'; + override description = + 'An integer supplied as a number, numeric string, or exactly ' + + 'representable bigint.'; override example = '42'; override rules() { diff --git a/src/primitives/number/max-validator.ts b/src/primitives/number/max-validator.ts index 891cac0..2a353c8 100644 --- a/src/primitives/number/max-validator.ts +++ b/src/primitives/number/max-validator.ts @@ -37,6 +37,9 @@ export class MaxValidator extends ValSan< number > { override type: ValSanTypes = 'number'; + override title = 'Maximum value'; + override description = + 'A number no greater than the configured inclusive maximum.'; private readonly max: number; @@ -46,7 +49,7 @@ export class MaxValidator extends ValSan< max: { code: 'maximum', user: { - helperText: `Maximum: ${this.max}`, + helperText: `Maximum value: ${this.max}`, errorMessage: `Number must be at most ${this.max}`, }, context: { diff --git a/src/primitives/number/min-validator.ts b/src/primitives/number/min-validator.ts index 74f8908..599da28 100644 --- a/src/primitives/number/min-validator.ts +++ b/src/primitives/number/min-validator.ts @@ -37,6 +37,9 @@ export class MinValidator extends ValSan< number > { override type: ValSanTypes = 'number'; + override title = 'Minimum value'; + override description = + 'A number no less than the configured inclusive minimum.'; private readonly min: number; @@ -46,7 +49,7 @@ export class MinValidator extends ValSan< min: { code: 'minimum', user: { - helperText: `Minimum: ${this.min}`, + helperText: `Minimum value: ${this.min}`, errorMessage: `Number must be at least ${this.min}`, }, context: { diff --git a/src/primitives/number/range-validator.ts b/src/primitives/number/range-validator.ts index b957edd..a9b956e 100644 --- a/src/primitives/number/range-validator.ts +++ b/src/primitives/number/range-validator.ts @@ -42,6 +42,9 @@ export class RangeValidator extends ValSan< number > { override type: ValSanTypes = 'number'; + override title = 'Number range'; + override description = + 'A number within the configured inclusive minimum and maximum.'; private readonly min: number; private readonly max: number; @@ -52,13 +55,13 @@ export class RangeValidator extends ValSan< range: { code: 'number_range', user: { - helperText: 'Range', + helperText: `Value from ${this.min} through ${this.max}.`, errorMessage: `Number must be between ${this.min} ` + `and ${this.max}`, }, dev: { - helperText: 'Range', + helperText: `Number from ${this.min} through ${this.max}.`, errorMessage: `Number must be between ${this.min} and ` + `${this.max}`, diff --git a/src/primitives/number/string-to-number.ts b/src/primitives/number/string-to-number.ts index f0bacec..92a0441 100644 --- a/src/primitives/number/string-to-number.ts +++ b/src/primitives/number/string-to-number.ts @@ -25,6 +25,8 @@ import { normalizeNumber } from './normalize-number'; */ export class StringToNumberValSan extends ValSan { override type: ValSanTypes = 'number'; + override title = 'Number'; + override description = 'A string representing a valid number.'; override rules() { return { diff --git a/src/primitives/object/object-valsan.ts b/src/primitives/object/object-valsan.ts index 7d404ee..532df33 100644 --- a/src/primitives/object/object-valsan.ts +++ b/src/primitives/object/object-valsan.ts @@ -49,6 +49,9 @@ export class ObjectValSan extends ValSan< Record > { override type: ValSanTypes = 'object'; + override title = 'Object'; + override description = + 'An object whose configured properties each satisfy their schema.'; public get schema(): ObjectSchema { return (this.options as ObjectValSanOptions).schema; diff --git a/src/primitives/person/email-validator.ts b/src/primitives/person/email-validator.ts index 142a11b..9bd8a35 100644 --- a/src/primitives/person/email-validator.ts +++ b/src/primitives/person/email-validator.ts @@ -38,6 +38,10 @@ export interface EmailValidatorOptions extends ValSanOptions { */ export class EmailValidator extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Email address'; + override description = + 'An email address, optionally restricted to configured domains or ' + + 'excluding plus addressing.'; override format = 'email'; override example = 'test@example.com'; diff --git a/src/primitives/string/alpha-validator.ts b/src/primitives/string/alpha-validator.ts index 1e44364..40d5333 100644 --- a/src/primitives/string/alpha-validator.ts +++ b/src/primitives/string/alpha-validator.ts @@ -32,6 +32,9 @@ export interface AlphaValidatorOptions extends ValSanOptions { */ export class AlphaValidator extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Alphabetic string'; + override description = + 'A string containing only letters, optionally including spaces.'; override example = 'hello'; private readonly allowSpaces: boolean; diff --git a/src/primitives/string/alphanumeric-validator.ts b/src/primitives/string/alphanumeric-validator.ts index 51c5a0f..9e176c8 100644 --- a/src/primitives/string/alphanumeric-validator.ts +++ b/src/primitives/string/alphanumeric-validator.ts @@ -16,6 +16,9 @@ import { stringRule } from './string-rules'; */ export class AlphanumericValidator extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Alphanumeric string'; + override description = + 'A string containing only letters and numbers.'; override example = 'abc123'; override rules() { diff --git a/src/primitives/string/length-validator.ts b/src/primitives/string/length-validator.ts index c2889b0..8e809c3 100644 --- a/src/primitives/string/length-validator.ts +++ b/src/primitives/string/length-validator.ts @@ -42,6 +42,9 @@ export interface LengthValidatorOptions */ export class LengthValidator extends ComposedValSan { override type: ValSanTypes = 'string'; + override title = 'String length'; + override description = + 'A string whose length falls within the configured inclusive range.'; constructor(options: LengthValidatorOptions = {}) { const steps = [ diff --git a/src/primitives/string/lowercase-sanitizer.ts b/src/primitives/string/lowercase-sanitizer.ts index 7e7a8e0..ac48f25 100644 --- a/src/primitives/string/lowercase-sanitizer.ts +++ b/src/primitives/string/lowercase-sanitizer.ts @@ -17,6 +17,8 @@ import { stringRule } from './string-rules'; */ export class LowercaseSanitizer extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Lowercase string'; + override description = 'A string with all letters in lowercase.'; public override rules() { return { diff --git a/src/primitives/string/max-length-validator.ts b/src/primitives/string/max-length-validator.ts index 597eb54..aadeee0 100644 --- a/src/primitives/string/max-length-validator.ts +++ b/src/primitives/string/max-length-validator.ts @@ -32,6 +32,9 @@ export interface MaxLengthValidatorOptions extends ValSanOptions { */ export class MaxLengthValidator extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Maximum string length'; + override description = + 'A string no longer than the configured maximum length.'; private readonly maxLength: number; @@ -46,7 +49,7 @@ export class MaxLengthValidator extends ValSan { maxLength: { code: 'string_max_len', user: { - helperText: `Max length: ${this.maxLength}`, + helperText: `Maximum length: ${this.maxLength}`, errorMessage: `Input must be at most ${this.maxLength} ` + 'character(s)', diff --git a/src/primitives/string/min-length-validator.ts b/src/primitives/string/min-length-validator.ts index 5c61eb4..d5476e7 100644 --- a/src/primitives/string/min-length-validator.ts +++ b/src/primitives/string/min-length-validator.ts @@ -33,6 +33,9 @@ export interface MinLengthValidatorOptions extends ValSanOptions { */ export class MinLengthValidator extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Minimum string length'; + override description = + 'A string at least as long as the configured minimum length.'; private readonly minLength: number; constructor(options: MinLengthValidatorOptions = {}) { @@ -46,7 +49,7 @@ export class MinLengthValidator extends ValSan { minLength: { code: 'string_min_len', user: { - helperText: `Min length: ${this.minLength}`, + helperText: `Minimum length: ${this.minLength}`, errorMessage: `Input must be at least ${this.minLength} ` + 'character(s)', diff --git a/src/primitives/string/pattern-validator.ts b/src/primitives/string/pattern-validator.ts index 1f0e2b3..ea7b71b 100644 --- a/src/primitives/string/pattern-validator.ts +++ b/src/primitives/string/pattern-validator.ts @@ -39,6 +39,9 @@ export interface PatternValidatorOptions extends ValSanOptions { */ export class PatternValidator extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Pattern-matched string'; + override description = + 'A string matching the configured regular expression.'; private readonly pattern: RegExp; private readonly errorMessage?: string; diff --git a/src/primitives/string/slug-validator.ts b/src/primitives/string/slug-validator.ts index fe90132..dc9fe40 100644 --- a/src/primitives/string/slug-validator.ts +++ b/src/primitives/string/slug-validator.ts @@ -47,6 +47,10 @@ export interface SlugValSanOptions extends ValSanOptions { */ export class SlugValSan extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Slug'; + override description = + 'A lowercase slug containing letters, numbers, and single hyphen ' + + 'separators.'; override example = 'hello-world'; private readonly autoConvert: boolean; diff --git a/src/primitives/string/trim-sanitizer.ts b/src/primitives/string/trim-sanitizer.ts index a468784..fc4f5ef 100644 --- a/src/primitives/string/trim-sanitizer.ts +++ b/src/primitives/string/trim-sanitizer.ts @@ -18,6 +18,9 @@ import { stringRule } from './string-rules'; */ export class TrimSanitizer extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Trimmed string'; + override description = + 'A string without leading or trailing whitespace.'; public override rules() { return { diff --git a/src/primitives/string/uppercase-sanitizer.ts b/src/primitives/string/uppercase-sanitizer.ts index 40b2920..0283e53 100644 --- a/src/primitives/string/uppercase-sanitizer.ts +++ b/src/primitives/string/uppercase-sanitizer.ts @@ -16,6 +16,8 @@ import { stringRule } from './string-rules'; */ export class UppercaseSanitizer extends ValSan { override type: ValSanTypes = 'string'; + override title = 'Uppercase string'; + override description = 'A string with all letters in uppercase.'; public override rules() { return { diff --git a/src/primitives/utility/enum-validator.ts b/src/primitives/utility/enum-validator.ts index 4ab0cb9..098fdc6 100644 --- a/src/primitives/utility/enum-validator.ts +++ b/src/primitives/utility/enum-validator.ts @@ -10,6 +10,9 @@ export interface EnumValidatorOptions extends ValSanOptions { export class EnumValidator extends ValSan { override type: ValSanTypes = 'unknown'; + override title = 'Enumeration'; + override description = + 'One of the configured allowed values.'; protected readonly allowedValues: readonly T[]; diff --git a/src/valsan-base.ts b/src/valsan-base.ts index dc067f8..fd86514 100644 --- a/src/valsan-base.ts +++ b/src/valsan-base.ts @@ -1,13 +1,58 @@ import { ValSanTypes } from './types/types'; +import { RuleSet } from './rules/rule'; import { SanitizeResult, ValSanOptions } from './valsan'; export class BaseValSan { public type: ValSanTypes = 'unknown'; + protected title: string | undefined = undefined; + protected description: string | undefined = undefined; public example = ''; public format?: string; public options: ValSanOptions; + public getTitle(): string { + return this.options?.title ?? this.title ?? this.constructor.name; + } + + public getDescription(): string | undefined { + return this.options?.description ?? this.description ?? undefined; + } + + protected buildValidationDescription( + supplementalDescriptions: readonly (string | undefined)[] + ): string | undefined { + const descriptions = [ + this.getDescription(), + ...supplementalDescriptions, + ] + .map((description) => description?.trim()) + .filter( + (description): description is string => Boolean(description) + ); + const uniqueDescriptions = [...new Set(descriptions)]; + + return uniqueDescriptions.length > 0 + ? uniqueDescriptions.join('\n') + : undefined; + } + + protected collectRuleHelperTexts(rules: RuleSet): string[] { + const ignoreRuleKeys = new Set([ + 'string' + ]); + + return Object.entries(rules).flatMap(([key, rule]) => { + if (ignoreRuleKeys.has(key)) { + return []; + } + + return [rule.user.helperText, rule.dev?.helperText].filter( + (helperText): helperText is string => Boolean(helperText) + ); + }); + } + public checkRequired(input: unknown): SanitizeResult { const isNullable = this.options.isNullable ?? this.options.isOptional ?? false; diff --git a/src/valsan-composed.ts b/src/valsan-composed.ts index 20b1c4c..0f44eec 100644 --- a/src/valsan-composed.ts +++ b/src/valsan-composed.ts @@ -43,6 +43,10 @@ export interface ComposedValSanOptions extends ValSanOptions { export class ComposedValSan extends BaseValSan implements RunsLikeAValSan { + protected override title = 'Composed value'; + protected override description = + 'A value that satisfies each configured Valsan step in sequence.'; + /** * Creates a composed validator from an array of ValSan steps. * @@ -99,6 +103,14 @@ export class ComposedValSan return combinedRules; } + public getValidationDescription(): string | undefined { + return this.buildValidationDescription(this.getRuleHelperTexts()); + } + + public getRuleHelperTexts(): string[] { + return this.collectRuleHelperTexts(this.rules()); + } + async run(input: TInput): Promise> { // Handle optional fields if (input === undefined || input === null) { diff --git a/src/valsan.ts b/src/valsan.ts index d11de39..51cf05d 100644 --- a/src/valsan.ts +++ b/src/valsan.ts @@ -34,6 +34,15 @@ export type SanitizeResult = }; export interface ValSanOptions { + /** + * Human-readable title for schema documentation and JSON Schema output. + */ + title?: string; + /** + * Human-readable description for schema documentation and JSON Schema + * output. + */ + description?: string; /** * If true, null values will pass validation without running validation or * sanitization steps. @@ -59,6 +68,10 @@ export interface RunsLikeAValSan { readonly type: ValSanTypes; readonly format?: string; readonly example: string; + readonly getTitle: () => string; + readonly getDescription: () => string | undefined; + readonly getValidationDescription?: () => string | undefined; + readonly getRuleHelperTexts?: () => string[]; readonly options: ValSanOptions; rules(): RuleSet; @@ -80,6 +93,14 @@ export abstract class ValSan< return {}; } + public getValidationDescription(): string | undefined { + return this.buildValidationDescription(this.getRuleHelperTexts()); + } + + public getRuleHelperTexts(): string[] { + return this.collectRuleHelperTexts(this.rules()); + } + public copy(options: ValSanOptions): ValSan { const constructor = this.constructor as new ( options: ValSanOptions diff --git a/test/spec/valsan/metadata.spec.ts b/test/spec/valsan/metadata.spec.ts new file mode 100644 index 0000000..a7ce34f --- /dev/null +++ b/test/spec/valsan/metadata.spec.ts @@ -0,0 +1,265 @@ +import 'jasmine'; +import { + AlphaValidator, + AlphanumericValidator, + ArrayValSan, + BearerTokenValSan, + ComposedValSan, + DecimalValidator, + EmailValidator, + EnumValidator, + FqdnValSan, + HexColorValSan, + IntegerValidator, + IpAddressValSan, + Iso8601TimestampValSan, + JsonValSan, + LengthValidator, + LowercaseSanitizer, + MaxLengthValidator, + MaxValidator, + MacAddressValSan, + MinLengthValidator, + MinValidator, + ObjectValSan, + PatternValidator, + PortNumberValSan, + RangeValidator, + SemverValSan, + SlugValSan, + StringToBooleanValSan, + StringToDateValSan, + StringToNumberValSan, + TrimSanitizer, + UppercaseSanitizer, + UrlValSan, + UuidValSan, + ValSan, + ValidationResult, +} from '../../../src'; +import { TestValSan } from './test-implementations'; + +class EmptyMetadataValSan extends ValSan { + override rules() { + return { + custom: { + code: 'custom', + user: { + helperText: '', + errorMessage: 'Invalid value', + }, + }, + }; + } + + protected override async validate(): Promise { + return this.pass(); + } + + protected override async sanitize(input: string): Promise { + return input; + } +} + +describe('ValSan metadata', () => { + it( + 'provides optional metadata overrides and preserves Valsan defaults', + () => { + const defaults = new MacAddressValSan(); + expect(defaults.getTitle()).toBe('MAC Address'); + expect(defaults.getDescription()).toContain('colon-separated'); + + const customized = new MacAddressValSan({ + title: 'Wireless adapter', + description: 'The MAC address of the Wi-Fi NIC', + }); + expect(customized.getTitle()).toBe('Wireless adapter'); + expect(customized.getDescription()).toBe( + 'The MAC address of the Wi-Fi NIC' + ); + expect(new TestValSan().getTitle()).toBe('TestValSan'); + expect(new TestValSan().getDescription()).toBeUndefined(); + } + ); + + it('preserves metadata overrides when copying', () => { + const original = new MacAddressValSan(); + const copy = original.copy({ + title: 'Network interface address', + description: 'The MAC address of the Wi-Fi NIC', + }); + + expect(copy.getTitle()).toBe('Network interface address'); + expect(copy.getDescription()).toBe('The MAC address of the Wi-Fi NIC'); + expect(original.getTitle()).toBe('MAC Address'); + }); + + it( + 'combines explicit descriptions with rule helper text', + () => { + const valSan = new MinValidator({ + min: 4, + description: 'An item count.', + }); + + expect(valSan.getDescription()).toBe('An item count.'); + expect(valSan.getValidationDescription()).toBe( + 'An item count.\nNumber\nMinimum value: 4' + ); + } + ); + + it('includes helper text from every composed step', () => { + const valSan = new ComposedValSan( + [ + new MinLengthValidator({ minLength: 4 }), + new MaxLengthValidator({ maxLength: 12 }), + ], + { description: 'The person\'s preferred nickname' } + ); + + expect(valSan.getValidationDescription()).toEqual( + 'The person\'s preferred nickname\n' + + 'Minimum length: 4\n' + + 'Maximum length: 12' + ); + }); + + it( + 'uses the merged rules for composed validation descriptions', + () => { + const composed = new ComposedValSan([ + new MinLengthValidator({ minLength: 4 }), + new MinLengthValidator({ minLength: 8 }), + ]); + + expect(composed.getValidationDescription()).toBe( + 'A value that satisfies each configured Valsan ' + + 'step in sequence.\n' + + 'Minimum length: 4' + ); + } + ); + + it( + 'uses overridden rules for composed validation descriptions', + () => { + class CustomComposedValSan extends ComposedValSan< + string, + string + > { + override rules() { + return { + custom: { + code: 'custom', + user: { + helperText: 'A customized composed value', + errorMessage: 'Invalid customized value', + }, + }, + }; + } + } + + const composed = new CustomComposedValSan([ + new MinLengthValidator({ minLength: 4 }), + ]); + + expect(composed.getValidationDescription()).toBe( + 'A value that satisfies each configured Valsan ' + + 'step in sequence.\n' + + 'A customized composed value' + ); + } + ); + + it('excludes generic type helper text from validation descriptions', () => { + const valSan = new MinLengthValidator({ minLength: 4 }); + + expect(valSan.getValidationDescription()).toBe( + 'A string at least as long as the configured minimum length.\n' + + 'Minimum length: 4' + ); + }); + + it('returns undefined without description or useful helper text', () => { + expect(new EmptyMetadataValSan().getValidationDescription()) + .toBeUndefined(); + }); + + it('includes distinct user and developer helper text', () => { + const description = new FqdnValSan().getValidationDescription(); + + expect(description).toContain('Full domain name'); + expect(description).toContain('Fully Qualified Domain Name (FQDN)'); + }); + + it('supports metadata on Object, Array, and Composed Valsans', () => { + const options = { + title: 'Container', + description: 'A documented container', + }; + const object = new ObjectValSan({ + schema: { value: new TestValSan() }, + ...options, + }); + const array = new ArrayValSan({ + schema: new TestValSan(), + ...options, + }); + const composed = new ComposedValSan([new TestValSan()], options); + + for (const valSan of [object, array, composed]) { + expect(valSan.getTitle()).toBe('Container'); + expect(valSan.getDescription()).toBe('A documented container'); + } + }); + + it('provides titles and descriptions for every built-in Valsan', () => { + const schema = new TestValSan(); + const valsans = [ + new AlphaValidator(), + new AlphanumericValidator(), + new ArrayValSan({ schema }), + new BearerTokenValSan(), + new ComposedValSan([schema]), + new DecimalValidator(), + new EmailValidator(), + new EnumValidator({ allowedValues: ['one', 'two'] }), + new FqdnValSan(), + new HexColorValSan(), + new IntegerValidator(), + new IpAddressValSan(), + new Iso8601TimestampValSan(), + new JsonValSan(), + new LengthValidator(), + new LowercaseSanitizer(), + new MacAddressValSan(), + new MaxLengthValidator({ maxLength: 10 }), + new MaxValidator({ max: 10 }), + new MinLengthValidator(), + new MinValidator({ min: 0 }), + new ObjectValSan({ schema: { value: schema } }), + new PatternValidator({ pattern: /^value$/ }), + new PortNumberValSan(), + new RangeValidator({ min: 0, max: 10 }), + new SemverValSan(), + new SlugValSan(), + new StringToBooleanValSan(), + new StringToDateValSan(), + new StringToNumberValSan(), + new TrimSanitizer(), + new UppercaseSanitizer(), + new UrlValSan(), + new UuidValSan(), + ]; + + for (const valSan of valsans) { + expect(valSan.getTitle()).withContext(valSan.constructor.name) + .toBeTruthy(); + expect(valSan.getDescription()) + .withContext(valSan.constructor.name) + .toBeTruthy(); + } + }); +});