Skip to content

Commit

Permalink
Introduce Oxpecker.ModelValidation (#33)
Browse files Browse the repository at this point in the history
* Introduce Oxpecker.ModelValidation

* Fantomas formatting

* Fixed NRT warnings

* Updated validation doc

* Updated validation doc

* Removed redundant section

* Added several documentation comments

* Release notes updated

* Changed Seq.empty to Array.empty to avoid serialization issue
  • Loading branch information
Lanayx authored Nov 26, 2024
1 parent 34da715 commit 0c20a51
Show file tree
Hide file tree
Showing 12 changed files with 277 additions and 60 deletions.
2 changes: 0 additions & 2 deletions examples/CRUD/Backend/Handlers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions examples/ContactApp/ContactApp.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<Compile Include="Models.fs" />
<Compile Include="Tools.fs" />
<Compile Include="templates\shared\layout.fs" />
<Compile Include="templates\shared\errors.fs" />
<Compile Include="templates\shared\contactFields.fs" />
<Compile Include="templates\index.fs" />
<Compile Include="templates\show.fs" />
Expand Down
47 changes: 26 additions & 21 deletions examples/ContactApp/Handlers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ open ContactApp.Tools
open Microsoft.AspNetCore.Http
open Oxpecker
open Oxpecker.Htmx
open Oxpecker.ModelValidation

let mutable archiver = Archiver(ResizeArray())

Expand Down Expand Up @@ -34,52 +35,56 @@ 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<ContactDTO>()
let validatedContact = contact.Validate()
if validatedContact.errors.Count > 0 then
return! ctx |> writeHtml (new'.html validatedContact)
else
match! ctx.BindAndValidateForm<ContactDTO>() 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<ContactDTO>()
let validatedContact = contact.Validate()
if validatedContact.errors.Count > 0 then
return! ctx |> writeHtml (edit.html { validatedContact with id = id })
else
match! ctx.BindAndValidateForm<ContactDTO>() 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 =
let contact = ContactService.find id
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 ->
Expand Down
22 changes: 7 additions & 15 deletions examples/ContactApp/Models.fs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
module ContactApp.Models
open System
open System.Collections.Generic
open System.ComponentModel.DataAnnotations


type Contact = {
Expand All @@ -14,24 +13,17 @@ type Contact = {
[<CLIMutable>]
type ContactDTO = {
id: int
[<Required>]
first: string
[<Required>]
last: string
[<Required>]
phone: string
[<Required>]
[<EmailAddress>]
email: string
errors: IDictionary<string, string>
} with
member this.GetError key =
match this.errors.TryGetValue key with
| true, value -> value
| _ -> ""
member this.Validate() =
let errors = Dictionary<string, string>()
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 }
2 changes: 2 additions & 0 deletions examples/ContactApp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 5 additions & 3 deletions examples/ContactApp/templates/edit.fs
Original file line number Diff line number Diff line change
@@ -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<ContactDTO>) =
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
Expand All @@ -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" }
Expand Down
3 changes: 2 additions & 1 deletion examples/ContactApp/templates/new.fs
Original file line number Diff line number Diff line change
@@ -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<ContactDTO>) =
Fragment() {
form(action="/contacts/new", method="post") {
fieldset() {
Expand Down
24 changes: 14 additions & 10 deletions examples/ContactApp/templates/shared/contactFields.fs
Original file line number Diff line number Diff line change
@@ -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<ContactDTO>) =
let x = Unchecked.defaultof<ContactDTO>
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
}
}
13 changes: 13 additions & 0 deletions examples/ContactApp/templates/shared/errors.fs
Original file line number Diff line number Diff line change
@@ -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()
Loading

0 comments on commit 0c20a51

Please sign in to comment.