From 70a89aaacb20d79d5908b87d72fb728bff01f882 Mon Sep 17 00:00:00 2001 From: Vladimir Shchur Date: Sun, 24 Nov 2024 18:40:10 -0800 Subject: [PATCH 1/9] Introduce Oxpecker.ModelValidation --- examples/CRUD/Backend/Handlers.fs | 2 - examples/ContactApp/ContactApp.fsproj | 1 + examples/ContactApp/Handlers.fs | 47 +++--- examples/ContactApp/Models.fs | 22 +-- examples/ContactApp/README.md | 2 + examples/ContactApp/templates/edit.fs | 8 +- examples/ContactApp/templates/new.fs | 3 +- .../templates/shared/contactFields.fs | 24 +-- .../ContactApp/templates/shared/errors.fs | 13 ++ src/Oxpecker/ModelValidation.fs | 138 ++++++++++++++++++ src/Oxpecker/Oxpecker.fsproj | 1 + src/Oxpecker/README.md | 24 +++ 12 files changed, 233 insertions(+), 52 deletions(-) create mode 100644 examples/ContactApp/templates/shared/errors.fs create mode 100644 src/Oxpecker/ModelValidation.fs 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..0df2528 --- /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"){ + modelErrors.ErrorMessagesFor(fieldName) |> String.concat ", " + } :> HtmlElement + | _ -> + Fragment() diff --git a/src/Oxpecker/ModelValidation.fs b/src/Oxpecker/ModelValidation.fs new file mode 100644 index 0000000..e10e537 --- /dev/null +++ b/src/Oxpecker/ModelValidation.fs @@ -0,0 +1,138 @@ +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 + ) + member this.All: ValidationResult seq = errors + member this.ErrorMessagesFor(name): string seq = + match errorDict.Value.TryGetValue(name) with + | true, value -> value + | false, _ -> Seq.empty + + type InvalidModel<'T> = 'T * ValidationErrors + + [] + type ModelState<'T> = + | Empty + | Valid of 'T + | Invalid of InvalidModel<'T> + member this.Value(f: 'T -> string|null) = + match this with + | Empty -> null + | Valid model -> f model |> string + | Invalid (model, _) -> f model |> string + 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> + + 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..66f8555 100644 --- a/src/Oxpecker/Oxpecker.fsproj +++ b/src/Oxpecker/Oxpecker.fsproj @@ -44,6 +44,7 @@ + diff --git a/src/Oxpecker/README.md b/src/Oxpecker/README.md index 3d5152a..95cf1e3 100644 --- a/src/Oxpecker/README.md +++ b/src/Oxpecker/README.md @@ -999,6 +999,30 @@ 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` attributes. + +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*`) + +```fsharp +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 + } + +``` + ### File Upload ASP.NET Core makes it really easy to process uploaded files. From 7660140d97b5ff884a8c653530a78c1e1c5e40b0 Mon Sep 17 00:00:00 2001 From: Vladimir Shchur Date: Sun, 24 Nov 2024 18:42:22 -0800 Subject: [PATCH 2/9] Fantomas formatting --- src/Oxpecker/ModelValidation.fs | 37 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/Oxpecker/ModelValidation.fs b/src/Oxpecker/ModelValidation.fs index e10e537..de0c69c 100644 --- a/src/Oxpecker/ModelValidation.fs +++ b/src/Oxpecker/ModelValidation.fs @@ -8,22 +8,21 @@ module ModelValidation = 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 - ) + 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) member this.All: ValidationResult seq = errors - member this.ErrorMessagesFor(name): string seq = + member this.ErrorMessagesFor(name) : string seq = match errorDict.Value.TryGetValue(name) with | true, value -> value | false, _ -> Seq.empty @@ -35,16 +34,16 @@ module ModelValidation = | Empty | Valid of 'T | Invalid of InvalidModel<'T> - member this.Value(f: 'T -> string|null) = + member this.Value(f: 'T -> string | null) = match this with | Empty -> null | Valid model -> f model |> string - | Invalid (model, _) -> f model |> string + | Invalid(model, _) -> f model |> string member this.BoolValue(f: 'T -> bool) = match this with | Empty -> false | Valid model -> f model - | Invalid (model, _) -> f model + | Invalid(model, _) -> f model [] type ValidationResult<'T> = @@ -55,7 +54,7 @@ module ModelValidation = let validationResults = ResizeArray() match Validator.TryValidateObject(model, ValidationContext(model), validationResults, true) with | true -> ValidationResult.Valid model - | false -> ValidationResult.Invalid (model, ValidationErrors(validationResults)) + | false -> ValidationResult.Invalid(model, ValidationErrors(validationResults)) type HttpContextExtensions = From 609a4222f1ad4ff8923f7cb44bbb321a0e7b893e Mon Sep 17 00:00:00 2001 From: Vladimir Shchur Date: Sun, 24 Nov 2024 19:11:17 -0800 Subject: [PATCH 3/9] Fixed NRT warnings --- examples/ContactApp/templates/shared/errors.fs | 2 +- src/Oxpecker/ModelValidation.fs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/ContactApp/templates/shared/errors.fs b/examples/ContactApp/templates/shared/errors.fs index 0df2528..de71d0e 100644 --- a/examples/ContactApp/templates/shared/errors.fs +++ b/examples/ContactApp/templates/shared/errors.fs @@ -7,7 +7,7 @@ let showErrors (modelState: ModelState<_>) (fieldName: string) = match modelState with | ModelState.Invalid (_, modelErrors) -> span(class'="error"){ - modelErrors.ErrorMessagesFor(fieldName) |> String.concat ", " + System.String.Join(", ", modelErrors.ErrorMessagesFor(fieldName)) } :> HtmlElement | _ -> Fragment() diff --git a/src/Oxpecker/ModelValidation.fs b/src/Oxpecker/ModelValidation.fs index de0c69c..e8d3150 100644 --- a/src/Oxpecker/ModelValidation.fs +++ b/src/Oxpecker/ModelValidation.fs @@ -11,7 +11,7 @@ module ModelValidation = type ValidationErrors(errors: ResizeArray) = let errorDict = lazy - (let dict = Dictionary>() + (let dict = Dictionary>() for error in errors do for memberName in error.MemberNames do match dict.TryGetValue(memberName) with @@ -22,7 +22,7 @@ module ModelValidation = dict[memberName] <- arrayList dict) member this.All: ValidationResult seq = errors - member this.ErrorMessagesFor(name) : string seq = + member this.ErrorMessagesFor(name) : seq = match errorDict.Value.TryGetValue(name) with | true, value -> value | false, _ -> Seq.empty @@ -37,8 +37,8 @@ module ModelValidation = member this.Value(f: 'T -> string | null) = match this with | Empty -> null - | Valid model -> f model |> string - | Invalid(model, _) -> f model |> string + | Valid model -> f model + | Invalid(model, _) -> f model member this.BoolValue(f: 'T -> bool) = match this with | Empty -> false From a6d9b3f6813537359426297410cdc71a58f44e31 Mon Sep 17 00:00:00 2001 From: Vladimir Shchur Date: Sun, 24 Nov 2024 21:43:37 -0800 Subject: [PATCH 4/9] Updated validation doc --- src/Oxpecker/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Oxpecker/README.md b/src/Oxpecker/README.md index 95cf1e3..e0e8b86 100644 --- a/src/Oxpecker/README.md +++ b/src/Oxpecker/README.md @@ -1010,6 +1010,8 @@ You have 3 ways to validate your model: - 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 let addCar : EndpointHandler = fun (ctx: HttpContext) -> @@ -1020,8 +1022,18 @@ let addCar : EndpointHandler = | 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 From a5f0a778806ac0c58519339e19e7ee62bbff908e Mon Sep 17 00:00:00 2001 From: Vladimir Shchur Date: Sun, 24 Nov 2024 21:46:42 -0800 Subject: [PATCH 5/9] Updated validation doc --- src/Oxpecker/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Oxpecker/README.md b/src/Oxpecker/README.md index e0e8b86..6c6e418 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) From 61fef4c3df8ff2c4013dfacc9193325580e107fb Mon Sep 17 00:00:00 2001 From: Vladimir Shchur Date: Mon, 25 Nov 2024 09:56:16 -0800 Subject: [PATCH 6/9] Removed redundant section --- src/Oxpecker/Oxpecker.fsproj | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Oxpecker/Oxpecker.fsproj b/src/Oxpecker/Oxpecker.fsproj index 66f8555..60d98bb 100644 --- a/src/Oxpecker/Oxpecker.fsproj +++ b/src/Oxpecker/Oxpecker.fsproj @@ -24,11 +24,6 @@ 1.0.0 Major release - - 3 - 3239;0025 - 3186;40 - From 2e4f0a4fbc6c8489f63f68b7c13f1bda6e59411b Mon Sep 17 00:00:00 2001 From: Vladimir Shchur Date: Mon, 25 Nov 2024 11:22:40 -0800 Subject: [PATCH 7/9] Added several documentation comments --- src/Oxpecker/ModelValidation.fs | 16 ++++++++++++++++ src/Oxpecker/README.md | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Oxpecker/ModelValidation.fs b/src/Oxpecker/ModelValidation.fs index e8d3150..99e638a 100644 --- a/src/Oxpecker/ModelValidation.fs +++ b/src/Oxpecker/ModelValidation.fs @@ -21,7 +21,13 @@ module ModelValidation = 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 @@ -34,11 +40,18 @@ module ModelValidation = | 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 @@ -50,6 +63,9 @@ module ModelValidation = | 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 diff --git a/src/Oxpecker/README.md b/src/Oxpecker/README.md index 6c6e418..bece380 100644 --- a/src/Oxpecker/README.md +++ b/src/Oxpecker/README.md @@ -1002,7 +1002,7 @@ The underlying model binder is configured as a dependency during application sta ### 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` attributes. +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. @@ -1014,6 +1014,19 @@ You have 3 ways to validate your model: 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 { From 177ada7f0b76fde6a1e93e5eee646c23c54af55c Mon Sep 17 00:00:00 2001 From: Vladimir Shchur Date: Mon, 25 Nov 2024 15:23:01 -0800 Subject: [PATCH 8/9] Release notes updated --- src/Oxpecker/Oxpecker.fsproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Oxpecker/Oxpecker.fsproj b/src/Oxpecker/Oxpecker.fsproj index 60d98bb..ba134c9 100644 --- a/src/Oxpecker/Oxpecker.fsproj +++ b/src/Oxpecker/Oxpecker.fsproj @@ -20,9 +20,9 @@ README.md true snupkg - 1.0.0 - 1.0.0 - Major release + 1.1.0 + 1.1.0 + Oxpecker.ModelValidation module added From b05c0846fbd1336f217bd2a173a4c8d11f6c9644 Mon Sep 17 00:00:00 2001 From: Vladimir Shchur Date: Tue, 26 Nov 2024 08:49:41 -0800 Subject: [PATCH 9/9] Changed Seq.empty to Array.empty to avoid serialization issue --- src/Oxpecker/ModelValidation.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Oxpecker/ModelValidation.fs b/src/Oxpecker/ModelValidation.fs index 99e638a..a31df4a 100644 --- a/src/Oxpecker/ModelValidation.fs +++ b/src/Oxpecker/ModelValidation.fs @@ -31,7 +31,7 @@ module ModelValidation = member this.ErrorMessagesFor(name) : seq = match errorDict.Value.TryGetValue(name) with | true, value -> value - | false, _ -> Seq.empty + | false, _ -> Array.empty type InvalidModel<'T> = 'T * ValidationErrors