prostgles-types
Version:
Shared TypeScript object definitions for prostgles-client and prostgles-server
292 lines (273 loc) • 9.66 kB
text/typescript
import type { JSONB } from "./JSONBSchema";
import { getKeys, isDefined, isEmpty, isObject } from "../util";
import { safeGetKeys, safeGetProperty, safeHasOwn } from "./utils";
type ValidationOptsions = {
allowExtraProperties?: boolean;
};
export const getFieldTypeObj = (rawFieldType: JSONB.FieldType): JSONB.FieldTypeObj => {
if (typeof rawFieldType === "string") return { type: rawFieldType };
return rawFieldType;
};
type DataType = JSONB.FieldTypeObj["type"];
type ElementType<T extends DataType> = T extends `${infer E}[]` ? E : never;
type ArrayTypes = Extract<DataType, `${string}[]`>;
type NonArrayTypes = Extract<Exclude<DataType, ArrayTypes>, string>;
const PRIMITIVE_VALIDATORS: Record<NonArrayTypes, (val: any) => boolean> = {
string: (val) => typeof val === "string",
number: (val) => typeof val === "number" && Number.isFinite(val),
integer: (val) => typeof val === "number" && Number.isInteger(val),
boolean: (val) => typeof val === "boolean",
time: (val) => typeof val === "string",
timestamp: (val) => typeof val === "string",
any: (val) => typeof val !== "function" && typeof val !== "symbol",
Date: (val) => typeof val === "string",
Lookup: () => {
throw new Error("Lookup type is not supported for validation");
},
};
const PRIMITIVE_VALIDATORS_KEYS = getKeys(PRIMITIVE_VALIDATORS);
const getElementType = <T extends DataType>(type: T): undefined | ElementType<T> => {
if (typeof type === "string" && type.endsWith("[]")) {
const elementType = type.slice(0, -2);
if (!PRIMITIVE_VALIDATORS_KEYS.includes(elementType as NonArrayTypes)) {
throw new Error(`Unknown array field type ${type}`);
}
return elementType as ElementType<T>;
}
};
const getValidator = (type: Extract<DataType, string>) => {
const elem = getElementType(type);
if (elem) {
const validator = PRIMITIVE_VALIDATORS[elem];
return {
isArray: true,
validator: (v: any) => Array.isArray(v) && v.every((v) => validator(v)),
};
}
const validator = PRIMITIVE_VALIDATORS[type as NonArrayTypes];
if (!(validator as any)) {
throw new Error(`Unknown field type ${type}`);
}
return { isArray: false, validator };
};
const getPropertyValidationError = (
value: any,
rawFieldType: JSONB.FieldType,
path: string[] = [],
opts: ValidationOptsions | undefined
): string | undefined => {
const err = `${path.join(".")} is of invalid type. Expecting ${getTypeDescription(rawFieldType).replaceAll("\n", "")}`;
const fieldType = getFieldTypeObj(rawFieldType);
const { type, allowedValues, nullable, optional } = fieldType;
if (nullable && value === null) return;
if (optional && value === undefined) return;
if (allowedValues) {
return `${path.join(".")} Allowed values are not supported for validation`;
}
if (type) {
if (isObject(type)) {
if (!isObject(value)) {
return err;
}
for (const subKey of safeGetKeys(type)) {
const subSchema = safeGetProperty(type, subKey);
const propIsOptional = isObject(subSchema) && subSchema.optional;
if (!propIsOptional && !safeHasOwn(value, subKey)) {
return `${[...path, subKey].join(".")} is missing but required`;
}
const error = getPropertyValidationError(
safeGetProperty(value, subKey),
subSchema,
[...path, subKey],
opts
);
if (error !== undefined) {
return error;
}
}
if (!opts?.allowExtraProperties) {
/** Check for extra properties */
const valueKeys = safeGetKeys(value);
const schemaKeys = safeGetKeys(type);
const extraKeys = valueKeys.filter((key) => !schemaKeys.includes(key));
if (extraKeys.length) {
return `${path.join(".")} has extra properties: ${extraKeys.join(", ")}`;
}
}
return;
}
const { validator } = getValidator(type);
const isValid = validator(value);
if (!isValid) {
return err;
}
return;
}
if (fieldType.enum) {
const otherOptions: any[] = [];
if (fieldType.nullable) otherOptions.push(null);
if (fieldType.optional) otherOptions.push(undefined);
// err += `one of: ${JSON.stringify([...fieldType.enum, ...otherOptions]).slice(1, -1)}`;
if (!fieldType.enum.includes(value)) return err;
return;
}
const arrayOf =
fieldType.arrayOf ?? (fieldType.arrayOfType ? { type: fieldType.arrayOfType } : undefined);
if (arrayOf) {
if (!Array.isArray(value)) {
return err + " an array";
}
const error = value
.map((element, i) => {
return getPropertyValidationError(element, arrayOf, [...path, `${i}`], opts);
})
.filter(isDefined)[0];
if (error !== undefined) {
return `${err}. Error at index ${path.length > 0 ? path.join(".") + "." : ""}\n\n${error}`;
}
return;
}
const oneOf = fieldType.oneOf ?? fieldType.oneOfType?.map((type) => ({ type }));
if (oneOf) {
if (!oneOf.length) {
return err + "to not be empty";
}
let firstError: string | undefined;
const validMember = oneOf.find((member) => {
const error = getPropertyValidationError(value, member, path, opts);
firstError ??= error;
return error === undefined;
});
if (validMember) {
return;
}
return err;
}
if (fieldType.record) {
const { keysEnum, partial, values: valuesSchema } = fieldType.record;
if (!isObject(value)) {
return err + "object";
}
if (partial && isEmpty(value)) {
return;
}
const valueKeys = getKeys(value);
const missingKey = partial ? undefined : keysEnum?.find((key) => !valueKeys.includes(key));
if (missingKey !== undefined) {
return `${err} to have key ${missingKey}`;
}
const extraKeys = keysEnum && valueKeys.filter((key) => !keysEnum.includes(key));
if (extraKeys?.length) {
return `${err} has extra keys: ${extraKeys}`;
}
if (valuesSchema) {
for (const propKey of safeGetKeys(value)) {
const propValue = safeGetProperty(value, propKey);
const valError = getPropertyValidationError(
propValue,
valuesSchema,
[...path, propKey],
opts
);
if (valError !== undefined) {
return `${valError}`;
}
}
}
return;
}
return `Could not validate field type. Some logic might be missing: ${JSON.stringify(fieldType)}`;
};
const getTypeDescription = (schema: JSONB.FieldType): string => {
const schemaObj = getFieldTypeObj(schema);
const { type, nullable, optional, record } = schemaObj;
const oneOf = schemaObj.oneOf ?? schemaObj.oneOfType?.map((type) => ({ type }));
const allowedTypes: any[] = [];
if (nullable) allowedTypes.push("null");
if (optional) allowedTypes.push("undefined");
if (typeof type === "string") {
allowedTypes.push(type);
} else if (type) {
if (isObject(type)) {
const keyOpts: string[] = [];
Object.entries(type).forEach(([key, value]) => {
keyOpts.push(`${key}: ${getTypeDescription(value)}`);
});
allowedTypes.push(`{ ${keyOpts.join("; ")} }`);
}
}
schemaObj.enum?.forEach((v) => {
if (v === null) {
allowedTypes.push("null");
} else if (v === undefined) {
allowedTypes.push("undefined");
} else if (typeof v === "string") {
allowedTypes.push(JSON.stringify(v));
} else {
allowedTypes.push(v);
}
});
oneOf?.forEach((v) => {
const type = getTypeDescription(v);
allowedTypes.push(type);
});
if (record) {
const { keysEnum, partial, values } = record;
const optional = partial ? "?" : "";
const valueType = !values ? "any" : getTypeDescription(values);
if (keysEnum) {
allowedTypes.push(`{ [${keysEnum.join(" | ")}]${optional}: ${valueType} }`);
} else {
allowedTypes.push(`{ [key: string]${optional}: ${valueType} }`);
}
}
return allowedTypes.join(" | ");
};
export const getJSONBObjectSchemaValidationError = <S extends JSONB.ObjectType["type"]>(
schema: S,
obj: any,
objName = "input",
optional = false,
opts?: ValidationOptsions
): { error: string; data?: undefined } | { error?: undefined; data: JSONB.GetObjectType<S> } => {
if (obj === undefined && !optional) return { error: `Expecting ${objName} to be defined` };
if (!isObject(obj)) {
return { error: `Expecting ${objName} to be an object` };
}
const error = getPropertyValidationError(obj, { type: schema }, [], opts);
if (error) {
return { error };
}
return { data: obj as JSONB.GetObjectType<S> };
};
export const getJSONBSchemaValidationError = <S extends JSONB.FieldType>(
schema: S,
obj: any,
opts?: ValidationOptsions
): { error: string; data?: undefined } | { error?: undefined; data: JSONB.GetType<S> } => {
const error = getPropertyValidationError(obj, schema, undefined, opts);
if (error) {
return { error };
}
return { data: obj as JSONB.GetType<S> };
};
export const validateJSONBObjectAgainstSchema = <S extends JSONB.ObjectType["type"]>(
schema: S,
obj: any,
objName: string,
optional = false
): obj is JSONB.GetObjectType<S> => {
const { error } = getJSONBObjectSchemaValidationError(schema, obj, objName, optional);
return error === undefined;
};
export const assertJSONBObjectAgainstSchema = <S extends JSONB.ObjectType["type"]>(
schema: S,
obj: any,
objName: string,
optional = false
): asserts obj is JSONB.GetObjectType<S> => {
const { error } = getJSONBObjectSchemaValidationError(schema, obj, objName, optional);
if (error) {
throw new Error(error);
}
};