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.