joiful-ts / joiful

TypeScript Declarative Validation for Joi
239 stars 18 forks source link

Custom validation function #115

Closed fider-apptimia closed 4 years ago

fider-apptimia commented 4 years ago

Is it possible to provide function as custom validator like below?:

function myValidationFunc(value, object, property): void | string {
  if ( isInvalid(value) ) {
    return 'Reason of validation error';
  }
  // no error
}

class C {
  @jf.custom(myValidationFunc)
  val: string
}

If no, then how to achieve it? (I know only how to create custom validator basing on predefined joiful decorators)

laurence-myers commented 4 years ago

Great question! Joiful was designed to make use of Joi's extend functionality, which is how you add custom validation.

However, it's a bit complex, and it looks like it's not currently possible (ever since joiful v1.0.0, when we changed our API from individual decorator functions to a fluent interface). (On further analysis, it should be possible with our current API.)

Leave it with us, and we will make the necessary changes and provide an example.

laurence-myers commented 4 years ago

Okay, here's a test I've come up with that demonstrates how to add a custom validation function. The summary is:

Example test

    /**
     * @see https://hapi.dev/family/joi/api/?v=15.1.1#extendextension
     */
    it('Extending Joi for custom validation', () => {
        // Custom validation functions must be added by using Joi's "extend" mechanism.

        // These are utility types you may find useful to replace the return of a function.
        // These types are derived from: https://stackoverflow.com/a/50014868
        type ArgumentTypes<T> = T extends (...args: infer U ) => infer _R ? U: never;
        type ReplaceReturnType<T, TNewReturn> = (...a: ArgumentTypes<T>) => TNewReturn;

        // We are going to create a new instance of Joi, with our extended functionality: a custom validation
        //  function that checks if each character in a string has "alternating case" (that is, each character has a
        //  case different to those either side of it).

        // For our own peace of mind, we're first going to update the type of the Joi instance to include our new
        //  schema.

        interface ExtendedStringSchema extends StringSchema {
            alternatingCase(): this; // We're adding this method, only for string schemas.
        }

        type IJoi = typeof Joi; // Need to alias this, because `interface Foo extends typeof Joi` doesn't work.
        interface CustomJoi extends IJoi {
            // This allows us to use our extended string schema, in place of Joi's original StringSchema.
            // E.g. instead of `Joi.string()` returning `StringSchema`, it now returns `ExtendedStringSchema`.
            string: ReplaceReturnType<IJoi['string'], ExtendedStringSchema>;
        }

        // This is our where we define our custom rule. Please read the Joi documentation for more info.
        // NOTE: we must explicitly provide the type annotation of `CustomJoi`.
        const customJoi: CustomJoi = Joi.extend({
            base: Joi.string(), // The base Joi schema
            language: { // This defines the error message returned when a rule fails validation.
                alternatingCase: 'must be in alternating case', // Used as 'string.alternatingCase'
            },
            name: 'string', // The type (can be an existing Joi type)
            rules: [
                {
                    name: 'alternatingCase', // This is the name of the new validation function
                    validate(
                        _params: void,
                        value: string,
                        state,
                        options,
                    ) { // Your validation implementation would go here.
                        if (value.length < 2) {
                            return true;
                        }
                        let lastCase = null;
                        for (let char of value) {
                            const charIsUppercase = /[A-Z]/.test(char);
                            if (charIsUppercase === lastCase) { // Not alternating case
                                // Validation failures must return a Joi error.
                                // You'll need to allow a suspicious use of "this" here, so that we can access the
                                //  Joi instance's `createError()` method.
                                // tslint:disable-next-line:no-invalid-this
                                return this.createError('string.case', { v: value }, state, options);
                            }
                            lastCase = charIsUppercase;
                        }
                        return value;
                    },
                },
            ],
        });

        // This function is how we're going to make use of our custom validator.
        function alternatingCase(options: { schema: Joi.Schema, joi: typeof Joi }): Joi.Schema {
            // (TODO: remove the `as CustomJoi` assertion. Requires making Joiful, JoifulOptions etc generic.)
            return (options.joi as CustomJoi).string().alternatingCase();
        }

        const customJoiful = new Joiful({
            joi: customJoi,
        });

        class ThingToValidate {
            // Note that we must _always_ use our own `customJoiful` for all decorators, instead of importing them
            //  directly from Joiful (e.g. `customJoiful.string()` vs `jf.string()`)
            // Failing to do so means Joiful will use the default instance of Joi, which could cause inconsistent
            //  behaviour, and prevent us from using our custom validator.
            @customJoiful.string().custom(alternatingCase)
            public propertyToValidate: string;

            constructor(
                propertyToValidate: string,
            ) {
                this.propertyToValidate = propertyToValidate;
            }
        }

        // Execute & verify
        let instance = new ThingToValidate(
            'aBcDeFgH',
        );
        expect(instance).toBeValid();

        instance = new ThingToValidate(
            'AbCdEfGh',
        );
        expect(instance).toBeValid();

        instance = new ThingToValidate(
            'abcdefgh',
        );
        expect(instance).not.toBeValid();
    });

Example wrapper module

// -------------
// jf.ts
// -------------

import Joiful from "joiful";

export = new Joiful();

// -------------
// app.ts
// -------------

// import * as jf from "joiful"; <-- we're replacing this
import * as jf from "./jf"; // <-- this is our custom wrapper module

class Foo {
  jf.string()
  bar: string;
}

I hope this helps, let me know if you have any more questions.

laurence-myers commented 4 years ago

I frogot to mention, you also need to pass your custom Joi instance to your validator instance.

        const validator = new Validator({
            joi: customJoi,
        });

There was a bug preventing this from working, which was fixed in v2.0.1.

I'll close this issue for now, please reach out if you have further questions.