JamesRandall / AccidentalFish.FSharp.Validation

Simple validator DSL / library for F#
MIT License
89 stars 10 forks source link

AccidentalFish.FSharp.Validation

A simple and extensible declarative validation framework for F#.

A sample console app is available demonstrating basic usage.

For issues and help please log them in the Issues area or contact me on Twitter.

You can also check out my blog for other .NET projects, articles, and cloud architecture and development.

Getting Started

Add the NuGet package AccidentalFish.FSharp.Validation to your project.

Consider the following model:

open AccidentalFish.FSharp.Validation
type Order = {
    id: string
    title: string
    cost: double
}

We can declare a validator for that model as follows:

let validateOrder = createValidatorFor<Order>() {
    validate (fun o -> o.id) [
        isNotEmpty
        hasLengthOf 36
    ]
    validate (fun o -> o.title) [
        isNotEmpty
        hasMaxLengthOf 128
    ]
    validate (fun o -> o.cost) [
        isGreaterThanOrEqualTo 0.
    ]    
}

The returned validator is a simple function that can be executed as follows:

let order = {
    id = "36467DC0-AC0F-43E9-A92A-AC22C68F25D1"
    title = "A valid order"
    cost = 55.
}

let validationResult = order |> validateOrder

The result is a discriminated union and will either be Ok for a valid model or Errors if issues were found. In the latter case this will be of type ValidationItem list. ValidationItem contains three properties:

Property Description
errorCode The error code, typically the name of the failed validation rule
message The validation message
property The path of the property that failed validation

The below shows an example of outputting errors to the console:

match validationResult with
| Ok -> printf "No validation errors\n\n"
| Errors errors -> printf "errors = %O" e

Validating Complex Types

Often your models will contain references to other record types and collections. Take the following model as an example:

type OrderItem =
    {
        productName: string
        quantity: int
    }

type Customer =
    {
        name: string        
    }

type Order =
    {
        id: string
        customer: Customer
        items: OrderItem list
    }

First if we look at validating the customer name we can do this one of two ways. Firstly we can simply express the full path to the customer name property:

let validateOrder = createValidatorFor<Order>() {
    validate (fun o -> o.customer.name) {
        isNotEmpty
        hasMaxLengthOf 128
    }
}

Or, if we want to reuse the customer validations, we can combine validators:

let validateCustomer = createValidatorFor<Customer>() {
    validate (fun c -> c.name) {
        isNotEmpty
        hasMaxLengthOf 128
    }
}

let validateOrder = createValidatorFor<Order>() {
    validate (fun o -> o.customer) {
        withValidator validateCustomer
    }
}

In both cases above the property field in the error items will be fully qualified e.g.:

customer.name

Validating items in collections are similar - we simply need to supply a validator for the items in the collection as shown below:

let validateOrderItem = createValidatorFor<OrderItem>() {
    validate (fun i -> i.productName) {
        isNotEmpty
        hasMaxLengthOf 128
    }
    validate (fun i -> i.quantity) {
        isGreaterThanOrEqualTo 1
    }
}

let validateOrder = createValidatorFor<Order>() {
    validate (fun o -> o.items) {
        isNotEmpty
        eachItemWith validateOrderItem
    }
}

Again the property fields in the error items will be fully qualified and contain the index e.g.:

items.[0].productName

Conditional Validation

I'm still playing around with this a little but doc's as it stands now

Using validateWhen

If you just have conditional logic that applies to one or two properties validateWhen can be used to specifiy which per property validations to use under which conditions. Given the order model below:

type DiscountOrder = {
    value: int
    discountPercentage: int
}

If we want to apply different validations to discountPercentage then we can do so using validateWhen as shown here:

let discountOrderValidator = createValidatorFor<DiscountOrder>() {
        validateWhen (fun w -> w.value < 100) (fun o -> o.discountPercentage) [
            isEqualTo 0
        ]
        validateWhen (fun w -> w.value >= 100) (fun o -> o.discountPercentage) [
            isEqualTo 10
        ]
        validate (fun o -> o.value) [
            isGreaterThan 0
        ]
    }

This will always validate that the value of the order is greater than 0. If the value is less than 100 it will ensure that the discount percentage is 0 and if the value is greater than or equal to 100 it will ensure the discount percentage is 10.

Using withValidatorWhen

This validateWhen approach is fine if you have only single properties but if you have multiple properties bound by a condition then can result in a lot of repetition. In this scenario using withValidatorWhen can be a better approach. Lets extend our order model to include an explanation for a discount - that we only want to be set when the discount is set:

type DiscountOrder = {
    value: int
    discountPercentage: int
    discountExplanation: string
}

Now we'll declare three validators:

let orderWithDiscount = createValidatorFor<DiscountOrder>() {
    validate (fun o -> o.discountPercentage) [
        isEqualTo 10
    ]
    validate (fun o -> o.discountExplanation) [
        isNotEmpty
    ]
}

let orderWithNoDiscount = createValidatorFor<DiscountOrder>() {
    validate (fun o -> o.discountPercentage) [
        isEqualTo 0
    ]
    validate (fun o -> o.discountExplanation) [
        isEmpty
    ]
}

let discountOrderValidator = createValidatorFor<DiscountOrder>() {
    validate (fun o -> o) [
        withValidatorWhen (fun o -> o.value < 100) orderWithNoDiscount
        withValidatorWhen (fun o -> o.value >= 100) orderWithDiscount            
    ]
    validate (fun o -> o.value) [
        isGreaterThan 0
    ]
}

The above can also be expressed more concisely in one block:

let validator = createValidatorFor<DiscountOrder>() {
    validate (fun o -> o) [
        withValidatorWhen (fun o -> o.value < 100) (createValidatorFor<DiscountOrder>() {
            validate (fun o -> o) [
                withValidatorWhen (fun o -> o.value < 100) orderWithNoDiscount
                withValidatorWhen (fun o -> o.value >= 100) orderWithDiscount            
            ]
            validate (fun o -> o.value) [
                isGreaterThan 0
            ]
        })
        withValidatorWhen (fun o -> o.value >= 100) (createValidatorFor<DiscountOrder>() {
            validate (fun o -> o.discountPercentage) [
                isEqualTo 10
            ]
            validate (fun o -> o.discountExplanation) [
                isNotEmpty
            ]
        })
    ]
    validate (fun o -> o.value) [
        isGreaterThan 0
    ]
}

Using A Function

If your validation is particularly complex then you can simply use a function or custom validator (though you might want to consider if this kind of logic is best expressed in a non-declarative form).

Custom validators are described in a section below. A function example follows:

type DiscountOrder = {
    value: int
    discountPercentage: int
    discountExplanation: string
}

let validator = createValidatorFor<DiscountOrder>() {
    validate (fun o -> o) [
        withFunction (fun o ->
            match o.value < 100 with
            | true -> Ok
            | false -> Errors([
                {
                    errorCode="greaterThanEqualTo100"
                    message="Some error"
                    property = "value"
                }
            ])
        )
    ]
}

Discriminated Unions

Single Case

Its common to use single case unions for wrapping simple types and preventing, for example, misassignment. Consider the following model:

type CustomerId = CustomerId of string

type Customer =
    {
        customerId: CustomerId
    }

We might want to ensure the customer ID value is not empty and has a maximum length. One way to accomplish that would be to use a function (see Collections above) but the framework also has a validate command that supports unwrapping the value as shown below:

let unwrapCustomerId (CustomerId id) = id
let validator = createValidatorFor<Customer>() {
    validateSingleCaseUnion (fun c -> c.id) unwrapCustomerId [
        isNotEmpty
        hasMaxLengthOf 10
    ]
}

For an excellent article on single case union types see F# for Fun and Profit.

Multiple Case

We can handle multiple case discriminated unions using the validateUnion command. Consider the following model:

type MultiCaseUnion =
    | NumericValue of double
    | StringValue of string

type UnionExample =
    {
        value: MultiCaseUnion
    }

To validate the contents of the union we need to unwrap and apply the appropriate validators based on the union case which we can do as shown below:

let unionValidator = createValidatorFor<UnionExample>() {
    validateUnion (fun o -> o.value) (fun v -> match v with | StringValue s -> Unwrapped(s) | _ -> Ignore) [
        isNotEmpty
        hasMinLengthOf 10
    ]

    validateUnion (fun o -> o.value) (fun v -> match v with | NumericValue n -> Unwrapped(n) | _ -> Ignore) [
        isGreaterThan 0.
    ]
}

Essentially the validateUnion command takes a parameter that supports a match and it, itself, returns a discriminated union. Return Unwrapped(value) to have the validation block run on the unwrapped value or return Ignore to have it skip that.

Option Types

To deal with option types in records use validateRequired, validateUnrequired, validateRequiredWhen and validateUnrequiredWhen instead of the already introduced validate and validateWhen commands.

validateRequired and validateRequiredWhen will apply the validators if the option type is Some. If the option type is None then a validation error will be generated.

validateUnrequired and validateUnrequiredWhen will apply the validators if the option type is Some but if the option type is None it will not generate a validation error, it simply won't run the validators.

Built-in Validators

The library includes a number of basic value validators (as seen in the examples above):

Validator Description
isEqualTo expected Is the tested value equal to the expected value
isNotEqualTo unexpected Is the tested value not equal to the unexpected value
isGreaterThan value Is the tested value greater than value
isGreaterThanOrEqualTo minValue Is the tested value greater than or equal to minValue
isLessThan value Is the tested value less than value
isLessThanOrEqualTo maxValue Is the tested value less than or equal to maxValue
isEmpty Is the tested value empty
isNotEmpty Is the sequence (including a string) not empty
isNotNull Ensure the value is not null
eachItemWith validator Apply validator to each item in a sequence
hasLengthOf length Is the sequence (including a string) of length length
hasMinLengthOf length Is the sequence (including a string) of a minimum length of length
hasMaxLengthOf length Is the sequence (including a string) of a maximum length of length
isNotEmptyOrWhitespace value Is the tested value not empty and not whitespace
withValidator validator Applies the specified validator to the property. Is an alias of withFunction
withValidatorWhen predicate validator Applies the specified validator when a condition is met. See conditional validations above.
withFunction function Apples the given function to the property. The function must have a signature of 'validatorTargetType -> ValidationState

I'll expand on this set over time. In the meantime it is easy to add additional validators as shown below.

Adding Custom Validators

Its easy to add custom validators as all they are are functions with the signature string -> 'a -> ValidationState. The first parameter is the name of the property that the validator is being applied to and the second the value. We then return the validation state.

For example lets say we want to write a validator function for a discriminated union and a model that uses it:

type TrafficLightColor = | Red | Green | Blue

type TrafficLight =
    {
        color: TrafficLightColor
    }

To check if the traffic light is green we could write a validator as follows:

let isGreen propertyName value =
    match value with
    | Green -> Ok
    | _ -> Errors([{ errorCode="isGreen" ; message="The light was not green" ; property = propertyName }])

And we could use it like any other validator:

let trafficLightValidator = createValidatorFor<TrafficLight>() {
    validate (fun r -> r.color) [
        isGreen
    ]
}

If we want to be able to supply parameters to the validator then we need to write a function that returns our validator function. For example if we want to be able to specify the color we could write a validator as follows:

let isColor color =
    let comparator propertyName value =
        match value = color with
        | true -> Ok
        | false -> Errors([{ errorCode="isColor" ; message=sprintf "The light was not %O" value ; property = propertyName }])
    comparator

And then we can use it like any other validator:

let trafficLightValidator = createValidatorFor<TrafficLight>() {
    validate (fun r -> r.color) [
        isColor Amber
    ]
}