diff --git a/examples/CRUD/Backend/Handlers.fs b/examples/CRUD/Backend/Handlers.fs index 4dbd539..3468a0e 100644 --- a/examples/CRUD/Backend/Handlers.fs +++ b/examples/CRUD/Backend/Handlers.fs @@ -27,8 +27,6 @@ type OperationEnv(env: Env) = interface IGetProducts with member this.GetProducts() = ProductRepository.getProducts env - - let getOrders env (ctx: HttpContext) = task { let operationEnv = OperationEnv(env) diff --git a/examples/ContactApp/ContactApp.fsproj b/examples/ContactApp/ContactApp.fsproj index a02f65e..194b65d 100644 --- a/examples/ContactApp/ContactApp.fsproj +++ b/examples/ContactApp/ContactApp.fsproj @@ -17,6 +17,7 @@ + diff --git a/examples/ContactApp/Handlers.fs b/examples/ContactApp/Handlers.fs index c991056..4c02ae4 100644 --- a/examples/ContactApp/Handlers.fs +++ b/examples/ContactApp/Handlers.fs @@ -6,6 +6,7 @@ open ContactApp.Tools open Microsoft.AspNetCore.Http open Oxpecker open Oxpecker.Htmx +open Oxpecker.ModelValidation let mutable archiver = Archiver(ResizeArray()) @@ -34,43 +35,43 @@ let getContactsCount: EndpointHandler = ctx.WriteText $"({count} total Contacts)" let getNewContact: EndpointHandler = - let newContact = { - id = 0 - first = "" - last = "" - email = "" - phone = "" - errors = dict [] - } - writeHtml (new'.html newContact) + writeHtml (new'.html ModelState.Empty) let insertContact: EndpointHandler = fun ctx -> task { - let! contact = ctx.BindForm() - let validatedContact = contact.Validate() - if validatedContact.errors.Count > 0 then - return! ctx |> writeHtml (new'.html validatedContact) - else + match! ctx.BindAndValidateForm() with + | ValidationResult.Valid validatedContact -> validatedContact.ToDomain() |> ContactService.add |> ignore flash "Created new Contact!" ctx return ctx.Response.Redirect("/contacts") + | ValidationResult.Invalid invalidModel -> + return! + invalidModel + |> ModelState.Invalid + |> new'.html + |> writeHtml + <| ctx } let updateContact id: EndpointHandler = fun ctx -> task { - let! contact = ctx.BindForm() - let validatedContact = contact.Validate() - if validatedContact.errors.Count > 0 then - return! ctx |> writeHtml (edit.html { validatedContact with id = id }) - else + match! ctx.BindAndValidateForm() with + | ValidationResult.Valid validatedContact -> let domainContact = validatedContact.ToDomain() ContactService.update({domainContact with Id = id}) flash "Updated Contact!" ctx return ctx.Response.Redirect($"/contacts/{id}") + | ValidationResult.Invalid (contactDto, errors) -> + return! + ({ contactDto with id = id }, errors) + |> ModelState.Invalid + |> edit.html + |> writeHtml + <| ctx } let viewContact id: EndpointHandler = @@ -78,8 +79,12 @@ let viewContact id: EndpointHandler = writeHtml <| show.html contact let getEditContact id: EndpointHandler = - let contact = ContactService.find id |> ContactDTO.FromDomain - writeHtml <| edit.html contact + id + |> ContactService.find + |> ContactDTO.FromDomain + |> ModelState.Valid + |> edit.html + |> writeHtml let deleteContact id: EndpointHandler = fun ctx -> diff --git a/examples/ContactApp/Models.fs b/examples/ContactApp/Models.fs index ad051f4..272bdb3 100644 --- a/examples/ContactApp/Models.fs +++ b/examples/ContactApp/Models.fs @@ -1,6 +1,5 @@ module ContactApp.Models -open System -open System.Collections.Generic +open System.ComponentModel.DataAnnotations type Contact = { @@ -14,24 +13,17 @@ type Contact = { [] type ContactDTO = { id: int + [] first: string + [] last: string + [] phone: string + [] + [] email: string - errors: IDictionary } with - member this.GetError key = - match this.errors.TryGetValue key with - | true, value -> value - | _ -> "" - member this.Validate() = - let errors = Dictionary() - if String.IsNullOrEmpty(this.first) then errors.Add("first", "First name is required") - if String.IsNullOrEmpty(this.last) then errors.Add("last", "Last name is required") - if String.IsNullOrEmpty(this.phone) then errors.Add("phone", "Phone is required") - if String.IsNullOrEmpty(this.email) then errors.Add("email", "Email is required") - { this with errors = errors } member this.ToDomain() = { Id = this.id; First = this.first; Last = this.last; Phone = this.phone; Email = this.email } static member FromDomain(contact: Contact) = - { id = contact.Id; first = contact.First; last = contact.Last; phone = contact.Phone; email = contact.Email; errors = dict [] } + { id = contact.Id; first = contact.First; last = contact.Last; phone = contact.Phone; email = contact.Email } diff --git a/examples/ContactApp/README.md b/examples/ContactApp/README.md index 5d47b24..721bb18 100644 --- a/examples/ContactApp/README.md +++ b/examples/ContactApp/README.md @@ -2,4 +2,6 @@ Here you can find an F# version of the contact app presented in [Hypermedia Systems](https://hypermedia.systems/) book. +It is also an example of `Oxpecker.ModelValidation` usage. + ![image](https://github.com/Lanayx/Oxpecker/assets/3329606/888dc44f-3fa5-43e1-9da7-5df1d255b584) diff --git a/examples/ContactApp/templates/edit.fs b/examples/ContactApp/templates/edit.fs index 6f17191..69483d3 100644 --- a/examples/ContactApp/templates/edit.fs +++ b/examples/ContactApp/templates/edit.fs @@ -1,13 +1,15 @@ module ContactApp.templates.edit +open Oxpecker.ModelValidation open Oxpecker.ViewEngine open Oxpecker.Htmx open ContactApp.Models open ContactApp.templates.shared -let html (contact: ContactDTO) = +let html (contact: ModelState) = + let contactId = contact.Value(_.id >> string) Fragment() { - form(action= $"/contacts/{contact.id}/edit", method="post") { + form(action= $"/contacts/{contactId}/edit", method="post") { fieldset() { legend() { "Contact Values" } contactFields.html contact @@ -16,7 +18,7 @@ let html (contact: ContactDTO) = } button(id="delete-btn", - hxDelete= $"/contacts/{contact.id}", + hxDelete= $"/contacts/{contactId}", hxPushUrl="true", hxConfirm="Are you sure you want to delete this contact?", hxTarget="body") { "Delete Contact" } diff --git a/examples/ContactApp/templates/new.fs b/examples/ContactApp/templates/new.fs index 92d1500..6e37d97 100644 --- a/examples/ContactApp/templates/new.fs +++ b/examples/ContactApp/templates/new.fs @@ -1,9 +1,10 @@ module ContactApp.templates.new' open ContactApp.Models +open Oxpecker.ModelValidation open Oxpecker.ViewEngine open ContactApp.templates.shared -let html (contact: ContactDTO) = +let html (contact: ModelState) = Fragment() { form(action="/contacts/new", method="post") { fieldset() { diff --git a/examples/ContactApp/templates/shared/contactFields.fs b/examples/ContactApp/templates/shared/contactFields.fs index 1343059..3bc31f2 100644 --- a/examples/ContactApp/templates/shared/contactFields.fs +++ b/examples/ContactApp/templates/shared/contactFields.fs @@ -1,34 +1,38 @@ namespace ContactApp.templates.shared +open ContactApp.templates.shared.errors open Oxpecker.Htmx +open Oxpecker.ModelValidation module contactFields = open ContactApp.Models open Oxpecker.ViewEngine - let html (contact: ContactDTO) = + let html (contact: ModelState) = + let x = Unchecked.defaultof + let showErrors = showErrors contact div() { p() { label(for'="email") { "Email" } - input(name="email", id="email", type'="email", placeholder="Email", value=contact.email, + input(name=nameof x.email, id="email", type'="email", placeholder="Email", value=contact.Value(_.email), hxTrigger="change, keyup delay:200ms changed", - hxGet= $"/contacts/{contact.id}/email", hxTarget="next .error") - span(class'="error") { contact.GetError("email") } + hxGet= $"/contacts/{contact.Value(_.id >> string)}/email", hxTarget="next .error") + showErrors <| nameof x.email } p() { label(for'="first") { "First Name" } - input(name="first", id="firs", type'="text", placeholder="First Name", value=contact.first) - span(class'="error") { contact.GetError("first") } + input(name=nameof x.first, id="first", type'="text", placeholder="First Name", value=contact.Value(_.first)) + showErrors <| nameof x.first } p() { label(for'="last") { "Last Name" } - input(name="last", id="last", type'="text", placeholder="Last Name", value=contact.last) - span(class'="error") { contact.GetError("last") } + input(name=nameof x.last, id="last", type'="text", placeholder="Last Name", value=contact.Value(_.last)) + showErrors <| nameof x.last } p() { label(for'="phone") { "Phone" } - input(name="phone", id="phone", type'="text", placeholder="Phone", value=contact.phone) - span(class'="error") { contact.GetError("phone") } + input(name=nameof x.phone, id="phone", type'="text", placeholder="Phone", value=contact.Value(_.phone)) + showErrors <| nameof x.phone } } diff --git a/examples/ContactApp/templates/shared/errors.fs b/examples/ContactApp/templates/shared/errors.fs new file mode 100644 index 0000000..de71d0e --- /dev/null +++ b/examples/ContactApp/templates/shared/errors.fs @@ -0,0 +1,13 @@ +module ContactApp.templates.shared.errors + +open Oxpecker.ViewEngine +open Oxpecker.ModelValidation + +let showErrors (modelState: ModelState<_>) (fieldName: string) = + match modelState with + | ModelState.Invalid (_, modelErrors) -> + span(class'="error"){ + System.String.Join(", ", modelErrors.ErrorMessagesFor(fieldName)) + } :> HtmlElement + | _ -> + Fragment() diff --git a/src/Oxpecker/ModelValidation.fs b/src/Oxpecker/ModelValidation.fs new file mode 100644 index 0000000..a31df4a --- /dev/null +++ b/src/Oxpecker/ModelValidation.fs @@ -0,0 +1,153 @@ +namespace Oxpecker + +[] +module ModelValidation = + + open System.Collections.Generic + open System.ComponentModel.DataAnnotations + open System.Runtime.CompilerServices + open Microsoft.AspNetCore.Http + + type ValidationErrors(errors: ResizeArray) = + let errorDict = + lazy + (let dict = Dictionary>() + for error in errors do + for memberName in error.MemberNames do + match dict.TryGetValue(memberName) with + | true, value -> value.Add(error.ErrorMessage) + | false, _ -> + let arrayList = ResizeArray(1) + arrayList.Add(error.ErrorMessage) + dict[memberName] <- arrayList + dict) + /// + /// Get all validation results for a model. + /// + member this.All: ValidationResult seq = errors + /// + /// Get all error messages for a specific model field (could to be used with `nameof` funciton). + /// + member this.ErrorMessagesFor(name) : seq = + match errorDict.Value.TryGetValue(name) with + | true, value -> value + | false, _ -> Array.empty + + type InvalidModel<'T> = 'T * ValidationErrors + + [] + type ModelState<'T> = + | Empty + | Valid of 'T + | Invalid of InvalidModel<'T> + /// + /// Pass an accessor function to get the string value of a model field (could be used with shorthand lambda). + /// + member this.Value(f: 'T -> string | null) = + match this with + | Empty -> null + | Valid model -> f model + | Invalid(model, _) -> f model + + /// + /// Pass an accessor function to get the boolean value of a model field (could be used with shorthand lambda). + /// + member this.BoolValue(f: 'T -> bool) = + match this with + | Empty -> false + | Valid model -> f model + | Invalid(model, _) -> f model + + [] + type ValidationResult<'T> = + | Valid of 'T + | Invalid of InvalidModel<'T> + + /// + /// Manually validate an object of type 'T`. + /// + let validateModel (model: 'T) = + let validationResults = ResizeArray() + match Validator.TryValidateObject(model, ValidationContext(model), validationResults, true) with + | true -> ValidationResult.Valid model + | false -> ValidationResult.Invalid(model, ValidationErrors(validationResults)) + + type HttpContextExtensions = + + /// + /// Uses the to deserialize the entire body of the asynchronously into an object of type 'T and then validate it. + /// + /// + /// Returns a + [] + static member BindAndValidateJson<'T>(ctx: HttpContext) = + task { + let! result = ctx.BindJson<'T>() + return validateModel result + } + + /// + /// Parses all input elements from an HTML form into an object of type 'T and then validates it. + /// + /// The current http context object. + /// + /// Returns a + [] + static member BindAndValidateForm<'T>(ctx: HttpContext) = + task { + let! result = ctx.BindForm<'T>() + return validateModel result + } + + /// + /// Parses all parameters of a request's query string into an object of type 'T and then validates it. + /// + /// The current http context object. + /// + /// Returns an instance of type 'T + [] + static member BindAndValidateQuery<'T>(ctx: HttpContext) = + let result = ctx.BindQuery<'T>() + validateModel result + + [] + module RequestHandlers = + /// + /// Parses a JSON payload into an instance of type 'T and validates it. + /// + /// A function which accepts an object of type ValidationResult<'T> and returns a function. + /// HttpContext + /// + /// An Oxpecker function which can be composed into a bigger web application. + let bindAndValidateJson<'T> (f: ValidationResult<'T> -> EndpointHandler) : EndpointHandler = + fun (ctx: HttpContext) -> + task { + let! model = ctx.BindJson<'T>() + return! f (validateModel model) ctx + } + + /// + /// Parses a HTTP form payload into an instance of type 'T and validates it. + /// + /// A function which accepts an object of type 'T and returns a function. + /// HttpContext + /// + /// An Oxpecker function which can be composed into a bigger web application. + let bindAndValidateForm<'T> (f: ValidationResult<'T> -> EndpointHandler) : EndpointHandler = + fun (ctx: HttpContext) -> + task { + let! model = ctx.BindForm<'T>() + return! f (validateModel model) ctx + } + + /// + /// Parses a HTTP query string into an instance of type 'T and validates it. + /// + /// A function which accepts an object of type 'T and returns a function. + /// HttpContext + /// + /// An Oxpecker function which can be composed into a bigger web application. + let bindAndValidateQuery<'T> (f: ValidationResult<'T> -> EndpointHandler) : EndpointHandler = + fun (ctx: HttpContext) -> + let model = ctx.BindQuery<'T>() + f (validateModel model) ctx diff --git a/src/Oxpecker/Oxpecker.fsproj b/src/Oxpecker/Oxpecker.fsproj index 061784f..ba134c9 100644 --- a/src/Oxpecker/Oxpecker.fsproj +++ b/src/Oxpecker/Oxpecker.fsproj @@ -20,14 +20,9 @@ README.md true snupkg - 1.0.0 - 1.0.0 - Major release - - - 3 - 3239;0025 - 3186;40 + 1.1.0 + 1.1.0 + Oxpecker.ModelValidation module added @@ -44,6 +39,7 @@ + diff --git a/src/Oxpecker/README.md b/src/Oxpecker/README.md index 3d5152a..bece380 100644 --- a/src/Oxpecker/README.md +++ b/src/Oxpecker/README.md @@ -45,6 +45,7 @@ An in depth functional reference to all of Oxpecker's features. - [Binding JSON](#binding-json) - [Binding Forms](#binding-forms) - [Binding Query Strings](#binding-query-strings) + - [Model validation](#model-validation) - [File Upload](#file-upload) - [WebSockets](#websockets) - [Grpc](#grpc) @@ -999,6 +1000,55 @@ Just like in the previous examples the record type must be decorated with the `[ The underlying model binder is configured as a dependency during application startup (see [Binding Forms](#binding-forms)) +### Model validation + +Oxpecker diverges from the Giraffe's approach to model validation and embraces the traditional ASP.NET Core model validation based on `System.ComponentModel.DataAnnotations.Validator` ([link](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.validator)). + +While you might still need to do complex validation inside your domain, the built-in DTO model validation is still useful for the API boundary. + +You have 3 ways to validate your model: +- Directly using `validateModel` function +- Using `ctx.BindAndValidate*` extension methods (similar to `ctx.Bind*`) +- Using `bindAndValidate*` handlers (similar to `bind*`) + +Inside handler you'll need to match `ValidationResult` to handle both valid and invalid cases: + +```fsharp +open System.ComponentModel.DataAnnotations + +[] +type Car = { + [] + Name: string + [] + Make: string + [] + Wheels: int + Built: DateTime +} + +let addCar : EndpointHandler = + fun (ctx: HttpContext) -> + task { + match! ctx.BindAndValidateJson() with + | ValidationResult.Valid car -> + return! ctx.Write <| Ok car + | ValidationResult.Invalid (invalidCar, errors) -> + return! ctx.Write <| BadRequest errors.All + } +``` + +If you are using server-side rendering using `Oxpecker.ViewEngine`, you can leverage special `ModelState` +```fsharp +[] +type ModelState<'T> = + | Empty + | Valid of 'T + | Invalid of InvalidModel<'T> +``` +This type is intended to be used for create/edit pages to simplify passing validation data to the view. An example of usage can be found in the [ContactApp example](https://github.com/Lanayx/Oxpecker/tree/develop/examples/ContactApp). + + ### File Upload ASP.NET Core makes it really easy to process uploaded files.