0.0.1Updated a month ago
import type { SchemaReference } from "./schema_reference.ts";

type ValidationFN = <T extends Schema.BasicType>(value: unknown, field: Schema.AdvancedType<T>) => string[]
type ValidatorCallback = (input: boolean, error: string) => void
type ValidatorChainFN = <T extends Schema.BasicType>(validation_fn: ValidationFN, value: unknown, field: Schema.AdvancedType<T>) => void
type ValidatorCallbackComplex = ValidatorCallback & { chain: ValidatorChainFN, addErrors: (errors: string[]) => void }

const Validator = (
  validationCallback: (
    validate: ValidatorCallbackComplex
  ) => void
) => {
  const errors: string[] = [];
  validationCallback(
    Object.apply({
      chain: ((validation_fn, value, field) => {
        errors.push(...validation_fn(value, field));
      }) as ValidatorChainFN,
      addErrors: (errors: string[]) => {
        errors.push(...errors);
      }
    }, 
      [
        (input, error) => {
          if(!input) errors.push(error);
        },
      ] as [ValidatorCallback]
    )
  )
  return errors;
}

type PluginValidatorFn<T extends Schema.BasicType> =
  (value: unknown, field: Schema.AdvancedType<T> & { required: boolean, type: T }) => string[];

type PluginValidators =
  { [K in Schema.BasicType]: PluginValidatorFn<K> } & {
    all: <T extends Schema.BasicType>(value: unknown, field: Schema.AdvancedType<T>) => string[];
  };


class PluginSchema<T extends Schema.Object> {
  readonly schema: Schema.Normalized<T>
  constructor(schema: Schema.Normalized<T>) {
    this.schema = Object.freeze(schema);
  }

  IsValid(input: Record<string, unknown>): input is T {
    return this.Validate(input).success;
  }

  Validate(input: Record<string, unknown>) {
    return PluginSchema.Validate(this, input);
  }

  private static ValidationMap: PluginValidators = {
    all: (value, field) => {
      return Validator(validate => {
        if(field.required) validate(value !== undefined, "this field is required");
      })
    },

    boolean(value, _field) {
      return Validator(validate => {
        validate(typeof value === 'boolean', "value must be a boolean");
      })
    },

    date(_value, _field) {
      return [];
    },

    email(_value) { return [] },
    number(_value) { return [] },

    password(value, field) {
      return Validator(validate => {
        if(typeof value === 'string') {
          if(field.length?.min) validate(value.length >= field.length.min, 'password requires a lowercase character');

          if(field.requirements?.lowercase) validate(!!value.match(/[a-z]/), 'password requires a lowercase character');
          if(field.requirements?.uppercase) validate(!!value.match(/[A-Z]/), 'password requires an uppercase character');
          if(field.requirements?.number) validate(!!value.match(/[0-9]/), 'password requires a number');
          if(field.requirements?.special_character) validate(!!value.match(/[^\w\s]/), 'password requires a special character');

          if(field.custom_validation) validate.addErrors(field.custom_validation(value));
        }
      })
    },

    string(_value) { return [] },
    msisdn() { return [] },
  }

  static Validate<T extends Schema.Object>(plugin_schema: PluginSchema<T>, input: Record<string, unknown>): Schema.Validation<T> {
    const errors: Record<string, string[]>[] = [];

    const entries = Object.entries(plugin_schema.schema) as [string, Schema.Field.Normalized][];
    for(const [key, field] of entries) {
      const field_errors: string[] = [];

      field_errors.push(...PluginSchema.ValidationMap.all(input[key], field));
      field_errors.push(...(PluginSchema.ValidationMap[field.type] as PluginValidatorFn<Schema.BasicType>)(input[key], field));

      if(field_errors.length > 0) errors.push({ [key]: field_errors });
    }

    return {
      success: errors.length === 0,
      errors: errors.length === 0 ? null : Object.assign({}, ...errors)
    };
  }

  static Normalize<T extends Schema.Object>(schema: T): Schema.Normalized<T> {
    return Object.assign({}, ...Object.entries(schema).map(([key, field]) => ({[key]: this.NormalizeField(field)})));
  }

  static NormalizeField<T extends Schema.Field>(field: T) {
    if (typeof field === "string") {
      return { type: (field as Schema.BasicType), required: true } as Schema.Field.Normalize<T>;
    }

    return {
      ...(field as Schema.AdvancedType),
      required: field.required ?? true,
    } as Schema.Field.Normalize<T>;
  }

  static Create = <S extends Schema.Object>(schema: S) => new PluginSchema(PluginSchema.Normalize(schema));
}

type SchemaModule = typeof PluginSchema & (<S extends Schema.Object>(schema: S) => PluginSchema<S>)

export const Schema = Object.apply(
  PluginSchema, 
  [PluginSchema.Create]
) as SchemaModule;


export declare namespace Schema {
  type Class<T extends Schema.Object> = typeof PluginSchema & { new: (...params: unknown[]) => PluginSchema<T> };

  type Base<T extends BasicType> = {
    type: T;
    required?: boolean;
    nullable?: boolean;
    default?: Maps.TypeInfer[T] | (() => Maps.TypeInfer[T]);

    placeholder?: string;
    description?: string;
  }

  namespace Reference {
    type Config = SchemaReference.Config
  }

  namespace Maps {
    type AdvancedType = {
      "string": {
        length?: number | { min?: number, max?: number }
      }

      // deno-lint-ignore ban-types
      "boolean": {}

      "number": {
        min?: number
        max?: number
        integer?: number
      }

      "date": {
        range?: boolean
        min?: Date | (() => Date)
        max?: Date | (() => Date)
      }

      "password": {
        length?: { min?: number, max?: number }

        requirements?: {
          uppercase?: boolean
          lowercase?: boolean
          number?: boolean
          special_character?: boolean
        }

        /** Custom validation steps
         * 
         * Should return an array of error messages. If the array is empty, validation will succeeded.
         * 
         * **Do not use custom validation for:**
         * - Missing values marked as required
         * - Min/Max length
         * - Character requirements
         * 
         *     *Standard validation will still run*.
         */
        custom_validation?: (input: string) => string[]
      }

      // deno-lint-ignore ban-types
      "email": {}
      // deno-lint-ignore ban-types
      "msisdn": {}
    }

    type TypeInfer = {
      string: string;
      boolean: boolean;
      number: number;
      date: Date;

      email: `${string}@${string}`;
      password: string;
      msisdn: string;
    };
  }

  namespace Field {
    type InferType<F> =
      F extends { type: infer T }
        ? T extends keyof Maps.TypeInfer
          ? Maps.TypeInfer[T]
          : never
      : F extends keyof Maps.TypeInfer
        ? Maps.TypeInfer[F]
        : never;

    type IsOptional<F> =
      F extends { required: false } ? true : false;

    type IsNullable<F> =
      F extends { nullable: true } ? true : false;

    type Normalize<F extends Field> =
      F extends Schema.BasicType
        ? Schema.AdvancedType<F> & { required: true }
        : F extends Schema.AdvancedType
          ? F & { required: boolean }
          : never;

    type Normalized =
      { [K in Schema.BasicType]:
          Schema.AdvancedType<K> & { required: boolean; type: K }
      }[Schema.BasicType];
  }

  // deno-lint-ignore no-explicit-any
  type InferClass<PS extends PluginSchema<any>, S = PS['schema']> = {
    [K in keyof S as Field.IsOptional<S[K]> extends true ? never : K]:
      Field.IsNullable<S[K]> extends true
        ? Field.InferType<S[K]> | null
        : Field.InferType<S[K]>
  } & {
    [K in keyof S as Field.IsOptional<S[K]> extends true ? K : never]?:
      Field.IsNullable<S[K]> extends true
        ? Field.InferType<S[K]> | null
        : Field.InferType<S[K]>
  };

  type Infer<S extends Schema.Object> = {
    [K in keyof S as Field.IsOptional<S[K]> extends true ? never : K]:
      Field.IsNullable<S[K]> extends true
        ? Field.InferType<S[K]> | null
        : Field.InferType<S[K]>
  } & {
    [K in keyof S as Field.IsOptional<S[K]> extends true ? K : never]?:
      Field.IsNullable<S[K]> extends true
        ? Field.InferType<S[K]> | null
        : Field.InferType<S[K]>
  };

  type Normalized<S extends Object> = {
    [F in keyof S]:
      S[F] extends Field
        ? Field.Normalize<S[F]>
        : never;
  }

  type BasicType = keyof Maps.AdvancedType;
  type AdvancedType<T extends BasicType = BasicType> = Base<T> & Maps.AdvancedType[T];
  type ReferenceType<T extends SchemaReference.Types> = SchemaReference.Base<T>;

  type AdvancedField<K extends Schema.BasicType = Schema.BasicType> =
    Schema.AdvancedType<K> & { type: K; $ref?: never };

  type ReferenceField<R extends SchemaReference.Types = SchemaReference.Types> =
    SchemaReference.Base<R> & { $ref: R; type?: never };

  type Field = Schema.BasicType | AdvancedField;

  type Object = { [key: string]: Field }

  type Validation<T> = Validation.Response<T>

  namespace Validation {
    type Response<T> = Success<T> | Fail<T>

    interface Base<T> {
      success: boolean
      errors: undefined | {[P in keyof T]: string[]}
    }

    type Success<T> = Base<T> & {
      success: true
      errors: undefined
    }
    type Fail<T> = Base<T> & {
      success: false
      errors: {[P in keyof T]: string[]}
    }
  }
}