Skip to content

Commit

Permalink
feat: add remaining string validations (#38)
Browse files Browse the repository at this point in the history
Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
Co-authored-by: Antonio Román <kyradiscord@gmail.com>
  • Loading branch information
3 people committed Feb 28, 2022
1 parent 945faf4 commit 1c2fd7b
Show file tree
Hide file tree
Showing 6 changed files with 451 additions and 6 deletions.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,13 @@ s.string.lengthGt(5);
s.string.lengthGe(5);
s.string.lengthEq(5);
s.string.lengthNe(5);
s.string.url; // TODO
s.string.uuid; // TODO
s.string.regex(regex); // TODO
s.string.email;
s.string.url();
s.string.uuid();
s.string.regex(regex);
s.string.ip();
s.string.ipv4;
s.string.ipv6;
```

#### Numbers
Expand Down
111 changes: 110 additions & 1 deletion src/constraints/StringConstraints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,27 @@ import { ConstraintError } from '../lib/errors/ConstraintError';
import { Result } from '../lib/Result';
import type { IConstraint } from './base/IConstraint';
import { Comparator, eq, ge, gt, le, lt, ne } from './util/operators';
import { isIP, isIPv4, isIPv6 } from 'node:net';
import { validateEmail } from './util/emailValidator';

export type StringConstraintName = `s.string.length${'Lt' | 'Le' | 'Gt' | 'Ge' | 'Eq' | 'Ne'}`;
export type StringConstraintName =
| `s.string.${`length${'Lt' | 'Le' | 'Gt' | 'Ge' | 'Eq' | 'Ne'}` | 'regex' | 'url' | 'uuid' | 'email' | `ip${'v4' | 'v6' | ''}`}`;

export type StringProtocol = `${string}:`;

export type StringDomain = `${string}.${string}`;

export interface UrlOptions {
allowedProtocols?: StringProtocol[];
allowedDomains?: StringDomain[];
}

export type UUIDVersion = 1 | 3 | 4 | 5;

export interface StringUuidOptions {
version?: UUIDVersion | `${UUIDVersion}-${UUIDVersion}` | null;
nullable?: boolean;
}

function stringLengthComparator(comparator: Comparator, name: StringConstraintName, expected: string, length: number): IConstraint<string> {
return {
Expand Down Expand Up @@ -44,3 +63,93 @@ export function stringLengthNe(length: number): IConstraint<string> {
const expected = `expected.length !== ${length}`;
return stringLengthComparator(ne, 's.string.lengthNe', expected, length);
}

export function stringEmail(): IConstraint<string> {
return {
run(input: string) {
return validateEmail(input)
? Result.ok(input)
: Result.err(new ConstraintError('s.string.email', 'Invalid email address', input, 'expected to be an email address'));
}
};
}

function stringRegexValidator(type: StringConstraintName, expected: string, regex: RegExp): IConstraint<string> {
return {
run(input: string) {
return regex.test(input) //
? Result.ok(input)
: Result.err(new ConstraintError(type, 'Invalid string format', input, expected));
}
};
}

export function stringUrl(options?: UrlOptions): IConstraint<string> {
return {
run(input: string) {
try {
const url = new URL(input);

// TODO(kyranet): optimize the option checks
if (options?.allowedProtocols && !options.allowedProtocols.includes(url.protocol as StringProtocol)) {
return Result.err(
new ConstraintError(
's.string.url',
'Invalid URL protocol',
input,
`expected ${url.protocol} to be one of: ${options.allowedProtocols.join(', ')}`
)
);
}
if (options?.allowedDomains && !options.allowedDomains.includes(url.hostname as StringDomain)) {
return Result.err(
new ConstraintError(
's.string.url',
'Invalid URL domain',
input,
`expected ${url.hostname} to be one of: ${options.allowedDomains.join(', ')}`
)
);
}

return Result.ok(input);
} catch {
return Result.err(new ConstraintError('s.string.url', 'Invalid URL', input, 'expected to match an URL'));
}
}
};
}

export function stringIp(version?: 4 | 6): IConstraint<string> {
const ipVersion = version ? `v${version}` : '';
return {
run(input: string) {
return (version === 4 ? isIPv4(input) : version === 6 ? isIPv6(input) : isIP(input)) //
? Result.ok(input)
: Result.err(
new ConstraintError(
`s.string.ip${ipVersion}` as StringConstraintName,
`Invalid ip${ipVersion} address`,
input,
`expected to be an ip${ipVersion} address`
)
);
}
};
}

export function stringRegex(regex: RegExp) {
return stringRegexValidator('s.string.regex', `expected ${regex}.test(expected) to be true`, regex);
}

export function stringUuid({ version = 4, nullable = false }: StringUuidOptions = {}) {
version ??= '1-5';
const regex = new RegExp(
`^(?:[0-9A-F]{8}-[0-9A-F]{4}-[${version}][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}${
nullable ? '|00000000-0000-0000-0000-000000000000' : ''
})$`,
'i'
);
const expected = `expected to match UUID${typeof version === 'number' ? `v${version}` : ` in range of ${version}`}`;
return stringRegexValidator('s.string.uuid', expected, regex);
}
9 changes: 8 additions & 1 deletion src/constraints/type-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,17 @@ export type {
} from './NumberConstraints';
export type {
StringConstraintName,
StringProtocol,
StringDomain,
UrlOptions,
stringLengthEq,
stringLengthGe,
stringLengthGt,
stringLengthLe,
stringLengthLt,
stringLengthNe
stringLengthNe,
stringEmail,
stringRegex,
stringUrl,
stringIp
} from './StringConstraints';
72 changes: 72 additions & 0 deletions src/constraints/util/emailValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* [RFC-5322](https://datatracker.ietf.org/doc/html/rfc5322)
* compliant {@link RegExp} to validate an email address
*
* @see https://stackoverflow.com/questions/201323/how-can-i-validate-an-email-address-using-a-regular-expression/201378#201378
*/
const accountRegex =
/^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")$/;

/**
* Validates an email address string based on various checks:
* - It must be a non nullish and non empty string
* - It must include at least an `@` symbol`
* - The account name may not exceed 64 characters
* - The domain name may not exceed 255 characters
* - The domain must include at least one `.` symbol
* - Each part of the domain, split by `.` must not exceed 63 characters
* - The email address must be [RFC-5322](https://datatracker.ietf.org/doc/html/rfc5322) compliant
* @param email The email to validate
* @returns `true` if the email is valid, `false` otherwise
*
* @remark Based on the following sources:
* - `email-validator` by [manisharaan](https://github.com/manishsaraan) ([code](https://github.com/manishsaraan/email-validator/blob/master/index.js))
* - [Comparing E-mail Address Validating Regular Expressions](http://fightingforalostcause.net/misc/2006/compare-email-regex.php)
* - [Validating Email Addresses by Derrick Pallas](http://thedailywtf.com/Articles/Validating_Email_Addresses.aspx)
* - [StackOverflow answer by bortzmeyer](http://stackoverflow.com/questions/201323/what-is-the-best-regular-expression-for-validating-email-addresses/201378#201378)
* - [The wikipedia page on Email addresses](https://en.wikipedia.org/wiki/Email_address)
*/

// TODO: refactor email validator
export function validateEmail(email: string): boolean {
// If a nullish or empty email was provided then do an early exit
if (!email) return false;

// Split the email at the @ symbol
const emailParts = email.split('@');

// If the email didn't have at least an @ symbol then the email address is invalid
if (emailParts.length !== 2) return false;

// Extract the account name of the email address
const account = emailParts[0];

// If the account name exceeds 64 characters then the email address is invalid
if (account.length > 64) return false;

// Extract the domain name of the email address
const domain = emailParts[1];

// If the domain name exceeds 255 characters then the email address is invalid
if (domain.length > 255) return false;

// Split the domain on a period
const domainParts = domain.split('.');

// If the domain name doesn't have at least one period then the email address is invalid
if (domainParts.length < 2) return false;

// If any of the parts of the domain name exceed 63 characters then the email address is invalid
if (domainParts.some((part) => part.length > 63)) return false;

// If all the checks above have passed then validate the entire email address against the email regex
return accountRegex.test(account) && validateEmailDomain(domain);
}

function validateEmailDomain(domain: string): boolean {
try {
return new URL(`http://${domain}`).hostname === domain;
} catch {
return false;
}
}
44 changes: 43 additions & 1 deletion src/validators/StringValidator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import type { IConstraint } from '../constraints/base/IConstraint';
import { stringLengthEq, stringLengthGe, stringLengthGt, stringLengthLe, stringLengthLt, stringLengthNe } from '../constraints/StringConstraints';
import {
stringEmail,
stringIp,
stringLengthEq,
stringLengthGe,
stringLengthGt,
stringLengthLe,
stringLengthLt,
stringLengthNe,
stringRegex,
stringUrl,
stringUuid,
StringUuidOptions,
type UrlOptions
} from '../constraints/StringConstraints';
import { ValidationError } from '../lib/errors/ValidationError';
import { Result } from '../lib/Result';
import { BaseValidator } from './imports';
Expand Down Expand Up @@ -29,6 +43,34 @@ export class StringValidator<T extends string> extends BaseValidator<T> {
return this.addConstraint(stringLengthNe(length) as IConstraint<T>);
}

public get email(): this {
return this.addConstraint(stringEmail() as IConstraint<T>);
}

public url(options?: UrlOptions): this {
return this.addConstraint(stringUrl(options) as IConstraint<T>);
}

public uuid(options?: StringUuidOptions): this {
return this.addConstraint(stringUuid(options) as IConstraint<T>);
}

public regex(regex: RegExp): this {
return this.addConstraint(stringRegex(regex) as IConstraint<T>);
}

public get ipv4(): this {
return this.ip(4);
}

public get ipv6(): this {
return this.ip(6);
}

public ip(version?: 4 | 6): this {
return this.addConstraint(stringIp(version) as IConstraint<T>);
}

protected handle(value: unknown): Result<T, ValidationError> {
return typeof value === 'string' //
? Result.ok(value as T)
Expand Down
Loading

0 comments on commit 1c2fd7b

Please sign in to comment.