import { BadRequestException } from "@nestjs/common";
import {
createZodValidationPipe,
type ZodValidationException,
} from "nestjs-zod";
import { z } from "zod";
export interface ValidationIssue {
path: Array<string | number>;
message: string;
code: string;
}
type ZodIssueLike = {
path: ReadonlyArray<PropertyKey>;
message: string;
code: string;
};
const ENV_PLACEHOLDER_REGEX = /^\$\{([A-Z0-9_]+)\}$/;
function normalizeIssuePath(
path: ReadonlyArray<PropertyKey>,
): Array<string | number> {
return path.filter(
(segment): segment is string | number =>
typeof segment === "string" || typeof segment === "number",
);
}
export function withMeta<T extends z.ZodTypeAny>(
schema: T,
metadata: Record<string, unknown>,
): T {
return schema.meta(metadata);
}
function envPlaceholderSchema() {
return z.string().regex(ENV_PLACEHOLDER_REGEX);
}
function nonEmptyStringSchema() {
return z.string().trim().min(1);
}
function stringOrEnvPlaceholderSchema() {
return z.union([nonEmptyStringSchema(), envPlaceholderSchema()]);
}
function optionalStringOrEnvPlaceholderSchema() {
return stringOrEnvPlaceholderSchema().optional();
}
function urlSchema() {
return z.string().trim().url();
}
function urlOrEnvPlaceholderSchema() {
return z.union([urlSchema(), envPlaceholderSchema()]);
}
export function textField(description: string, example?: string) {
return withMeta(stringOrEnvPlaceholderSchema(), {
description,
...(example ? { examples: [example] } : {}),
});
}
export function optionalTextField(description: string, example?: string) {
return withMeta(optionalStringOrEnvPlaceholderSchema(), {
description,
...(example ? { examples: [example] } : {}),
});
}
export function urlField(description: string, example?: string) {
return withMeta(urlOrEnvPlaceholderSchema(), {
description,
...(example ? { examples: [example] } : {}),
});
}
export function booleanField(description: string, example?: boolean) {
return withMeta(z.boolean().optional(), {
description,
...(example !== undefined ? { examples: [example] } : {}),
});
}
export function toValidationIssues(
issues: ReadonlyArray<ZodIssueLike>,
): ValidationIssue[] {
return issues.map((issue) => ({
path: normalizeIssuePath(issue.path),
message: issue.message,
code: issue.code,
}));
}
function buildValidationBody(
issues: ValidationIssue[],
message = "Validation failed",
) {
return {
statusCode: 400,
message,
errors: issues,
timestamp: new Date().toISOString(),
};
}
export function createValidationException(
issues: ValidationIssue[],
message?: string,
) {
return new BadRequestException(buildValidationBody(issues, message));
}
function zodErrorToValidationIssues(error: unknown): ValidationIssue[] {
const zodError =
(error as ZodValidationException | undefined)?.getZodError?.() ?? error;
if (Array.isArray(zodError)) {
return zodError.map((issue: any) => ({
path: issue.path ?? [],
message: issue.message ?? "Invalid value",
code: issue.code ?? "custom",
}));
}
const issues = (
zodError as
| {
issues?: Array<{
path: Array<string | number>;
message: string;
code: string;
}>;
}
| undefined
)?.issues;
if (!Array.isArray(issues)) {
return [];
}
return issues.map((issue) => ({
path: issue.path,
message: issue.message,
code: issue.code,
}));
}
export function createAppValidationPipe() {
const ZodValidationPipeClass = createZodValidationPipe({
createValidationException: (error) =>
createValidationException(zodErrorToValidationIssues(error)),
});
return new ZodValidationPipeClass();
}
export function findMissingEnvPlaceholderIssues(
value: unknown,
path: Array<string | number> = [],
): ValidationIssue[] {
if (typeof value === "string") {
const issues: ValidationIssue[] = [];
for (const match of value.matchAll(/\$\{([A-Z0-9_]+)\}/g)) {
const variableName = match[1];
const variableValue = process.env[variableName];
if (variableValue === undefined || variableValue === "") {
issues.push({
path,
message: `Missing environment variable '${variableName}'`,
code: "missing_environment_variable",
});
}
}
return issues;
}
if (Array.isArray(value)) {
return value.flatMap((item, index) =>
findMissingEnvPlaceholderIssues(item, [...path, index]),
);
}
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, item]) =>
findMissingEnvPlaceholderIssues(item, [...path, key]),
);
}
return [];
}
export function resolveEnvPlaceholders<T>(value: T): T {
if (typeof value === "string") {
return value.replace(
/\$\{([A-Z0-9_]+)\}/g,
(_, name: string) => process.env[name] ?? "",
) as unknown as T;
}
if (Array.isArray(value)) {
return value.map((item) =>
resolveEnvPlaceholders(item),
) as unknown as T;
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
out[key] = resolveEnvPlaceholders(item);
}
return out as T;
}
return value;
}