Files
suaveplan/packages/foundation/data/validation/src/schemas/string/string.ts
2026-05-27 23:47:53 +01:00

215 lines
6.8 KiB
TypeScript

import {
addIssue,
BaseSchema,
type INVALID,
} from "../../core/schema/schema.js";
import type { ParseContext } from "../../types/common.js";
// ---------------------------------------------------------------------------
// Constraints stored on the schema
// ---------------------------------------------------------------------------
interface StringConstraints {
min?: number;
max?: number;
length?: number;
regex?: RegExp;
email?: boolean;
url?: boolean;
uuid?: boolean;
startsWith?: string;
endsWith?: string;
includes?: string;
trim?: boolean;
toLowerCase?: boolean;
toUpperCase?: boolean;
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// ---------------------------------------------------------------------------
// StringSchema
// ---------------------------------------------------------------------------
export class StringSchema extends BaseSchema<string, string> {
private readonly _constraints: StringConstraints;
constructor(constraints: StringConstraints = {}) {
super();
this._constraints = constraints;
}
_parse(input: unknown, ctx: ParseContext): string | INVALID {
if (typeof input !== "string") {
return addIssue(ctx, {
code: "VALIDATION.INVALID_TYPE",
path: [...ctx.path],
message: `Expected string, received ${typeof input}`,
input,
});
}
let value = input;
const c = this._constraints;
// Transforms (applied before constraint checks)
if (c.trim) value = value.trim();
if (c.toLowerCase) value = value.toLowerCase();
if (c.toUpperCase) value = value.toUpperCase();
// Constraints
if (c.min !== undefined && value.length < c.min) {
return addIssue(ctx, {
code: "VALIDATION.STRING.TOO_SHORT",
path: [...ctx.path],
message: `String must be at least ${c.min} character(s)`,
input,
});
}
if (c.max !== undefined && value.length > c.max) {
return addIssue(ctx, {
code: "VALIDATION.STRING.TOO_LONG",
path: [...ctx.path],
message: `String must be at most ${c.max} character(s)`,
input,
});
}
if (c.length !== undefined && value.length !== c.length) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String must be exactly ${c.length} character(s)`,
input,
});
}
if (c.regex !== undefined && !c.regex.test(value)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String does not match pattern ${c.regex.toString()}`,
input,
});
}
if (c.email && !EMAIL_RE.test(value)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: "Invalid email address",
input,
});
}
if (
c.url &&
!value.startsWith("http://") &&
!value.startsWith("https://")
) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: "Invalid URL",
input,
});
}
if (c.uuid && !UUID_RE.test(value)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: "Invalid UUID",
input,
});
}
if (c.startsWith !== undefined && !value.startsWith(c.startsWith)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String must start with "${c.startsWith}"`,
input,
});
}
if (c.endsWith !== undefined && !value.endsWith(c.endsWith)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String must end with "${c.endsWith}"`,
input,
});
}
if (c.includes !== undefined && !value.includes(c.includes)) {
return addIssue(ctx, {
code: "VALIDATION.STRING.INVALID_FORMAT",
path: [...ctx.path],
message: `String must include "${c.includes}"`,
input,
});
}
return value;
}
// -------------------------------------------------------------------------
// Constraint builders (return new instance for immutability)
// -------------------------------------------------------------------------
min(n: number): StringSchema {
return new StringSchema({ ...this._constraints, min: n });
}
max(n: number): StringSchema {
return new StringSchema({ ...this._constraints, max: n });
}
length(n: number): StringSchema {
return new StringSchema({ ...this._constraints, length: n });
}
regex(re: RegExp): StringSchema {
return new StringSchema({ ...this._constraints, regex: re });
}
email(): StringSchema {
return new StringSchema({ ...this._constraints, email: true });
}
url(): StringSchema {
return new StringSchema({ ...this._constraints, url: true });
}
uuid(): StringSchema {
return new StringSchema({ ...this._constraints, uuid: true });
}
startsWith(prefix: string): StringSchema {
return new StringSchema({ ...this._constraints, startsWith: prefix });
}
endsWith(suffix: string): StringSchema {
return new StringSchema({ ...this._constraints, endsWith: suffix });
}
includes(substr: string): StringSchema {
return new StringSchema({ ...this._constraints, includes: substr });
}
trim(): StringSchema {
return new StringSchema({ ...this._constraints, trim: true });
}
toLowerCase(): StringSchema {
return new StringSchema({ ...this._constraints, toLowerCase: true });
}
toUpperCase(): StringSchema {
return new StringSchema({ ...this._constraints, toUpperCase: true });
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function string(): StringSchema {
return new StringSchema();
}