diff --git a/Packages.props b/Packages.props index 673f3b7a7..f37d360ad 100644 --- a/Packages.props +++ b/Packages.props @@ -73,5 +73,6 @@ + diff --git a/tests/FSharp.Data.GraphQL.Tests/ErrorHelpers.fs b/tests/FSharp.Data.GraphQL.Tests/ErrorHelpers.fs index 138b32bdf..2feb909ff 100644 --- a/tests/FSharp.Data.GraphQL.Tests/ErrorHelpers.fs +++ b/tests/FSharp.Data.GraphQL.Tests/ErrorHelpers.fs @@ -26,7 +26,7 @@ let ensureRequestError (result : GQLExecutionResult) (onRequestError : GQLProble | RequestError errors -> onRequestError errors | response -> fail $"Expected RequestError GQLResponse but got {Environment.NewLine}{response}" -let ensureValidationError (message : string) (path : FieldPath) (error) = +let ensureValidationError (message : string) (path : FieldPath) (error : GQLProblemDetails) = equals message error.Message equals (Include path) error.Path match error.Extensions with @@ -34,7 +34,7 @@ let ensureValidationError (message : string) (path : FieldPath) (error) = | Include extensions -> equals Validation (unbox extensions[CustomErrorFields.Kind]) -let ensureExecutionError (message : string) (path : FieldPath) (error) = +let ensureExecutionError (message : string) (path : FieldPath) (error : GQLProblemDetails) = equals message error.Message equals (Include path) error.Path match error.Extensions with @@ -42,7 +42,7 @@ let ensureExecutionError (message : string) (path : FieldPath) (error) = | Include extensions -> equals Execution (unbox extensions[CustomErrorFields.Kind]) -let ensureInputCoercionError (errorSource : ErrorSource) (message : string) (``type`` : string) (error) = +let ensureInputCoercionError (errorSource : ErrorSource) (message : string) (``type`` : string) (error : GQLProblemDetails) = equals message error.Message match error.Extensions with | Skip -> fail "Expected extensions to be present" @@ -56,7 +56,7 @@ let ensureInputCoercionError (errorSource : ErrorSource) (message : string) (``t equals name (unbox extensions[CustomErrorFields.ArgumentName]) equals ``type`` (unbox extensions[CustomErrorFields.ArgumentType]) -let ensureInputObjectFieldCoercionError (errorSource : ErrorSource) (message : string) (inputObjectPath : FieldPath) (objectType : string) (fieldType : string) (error) = +let ensureInputObjectFieldCoercionError (errorSource : ErrorSource) (message : string) (inputObjectPath : FieldPath) (objectType : string) (fieldType : string) (error : GQLProblemDetails) = equals message error.Message match error.Extensions with | Skip -> fail "Expected extensions to be present" @@ -70,7 +70,7 @@ let ensureInputObjectFieldCoercionError (errorSource : ErrorSource) (message : s equals objectType (unbox extensions[CustomErrorFields.ObjectType]) equals fieldType (unbox extensions[CustomErrorFields.FieldType]) -let ensureInputObjectValidationError (errorSource : ErrorSource) (message : string) (inputObjectPath : FieldPath) (objectType : string) (error) = +let ensureInputObjectValidationError (errorSource : ErrorSource) (message : string) (inputObjectPath : FieldPath) (objectType : string) (error : GQLProblemDetails) = equals message error.Message match error.Extensions with | Skip -> fail "Expected extensions to be present" diff --git a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj index 3c38c2014..8abaab60a 100644 --- a/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj +++ b/tests/FSharp.Data.GraphQL.Tests/FSharp.Data.GraphQL.Tests.fsproj @@ -17,6 +17,7 @@ + @@ -51,6 +52,8 @@ + + diff --git a/tests/FSharp.Data.GraphQL.Tests/Helpers.fs b/tests/FSharp.Data.GraphQL.Tests/Helpers.fs index 6cc0efa22..a50199789 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Helpers.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Helpers.fs @@ -89,13 +89,13 @@ let asts query = ["defer"; "stream"] |> Seq.map (query >> parse) -let set (mre : ManualResetEvent) = +let setEvent (mre : ManualResetEvent) = mre.Set() |> ignore -let reset (mre : ManualResetEvent) = +let resetEvent (mre : ManualResetEvent) = mre.Reset() |> ignore -let wait (mre : ManualResetEvent) errorMsg = +let waitEvent (mre : ManualResetEvent) errorMsg = if TimeSpan.FromSeconds(float 30) |> mre.WaitOne |> not then fail errorMsg diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/CoercionTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/CoercionTests.fs index c1282e101..af901f1bc 100644 --- a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/CoercionTests.fs +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/CoercionTests.fs @@ -1,5 +1,5 @@ // The MIT License (MIT) -/// Copyright (c) 2015-Mar 2016 Kevin Thompson @kthompson +// Copyright (c) 2015-Mar 2016 Kevin Thompson @kthompson // Copyright (c) 2016 Bazinga Technologies Inc [] diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.ValidString.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.ValidString.fs new file mode 100644 index 000000000..1bede223a --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.ValidString.fs @@ -0,0 +1,165 @@ +// The MIT License (MIT) + +namespace FSharp.Data.GraphQL.Tests.OptionalsNormalizationTests + +open System +open FSharp.Data.GraphQL + +[] +type ValidString<'t> = internal ValidString of string +with + static member internal CreateVOption<'t> (value: string option) : ValidString<'t> voption = + value |> ValueOption.ofOption |> ValueOption.map ValidString + +module ValidString = + + let value (ValidString text) = text + + let vOption strOption : string voption = strOption |> ValueOption.map value + + let vOptionValue strOption : string = strOption |> ValueOption.map value |> ValueOption.toObj + +open Validus +open Validus.Operators + +module String = + + let notStartsWithWhiteSpace fieldName (s: string) = + if s.StartsWith ' ' then + Error + <| ValidationErrors.create fieldName [ $"'%s{fieldName}' field cannot start with whitespace" ] + else + Ok <| s + + let notEndsWithWhiteSpace fieldName (s: string) = + if s.EndsWith ' ' then + Error + <| ValidationErrors.create fieldName [ $"'%s{fieldName}' field cannot end with whitespace" ] + else + Ok <| s + + let notContainsWhiteSpace fieldName (s: string) = + if s.Contains ' ' then + Error + <| ValidationErrors.create fieldName [ $"'%s{fieldName}' field cannot contain whitespace" ] + else + Ok <| s + + let notContainsBacktick fieldName (s: string) = + if s.Contains '`' then + Error + <| ValidationErrors.create fieldName [ $"'%s{fieldName}' field cannot contain backtick" ] + else + Ok <| s + + let notContainsTilde fieldName (s: string) = + if s.Contains '~' then + Error + <| ValidationErrors.create fieldName [ $"'%s{fieldName}' field cannot contain tilde" ] + else + Ok <| s + + let notContainsDash fieldName (s: string) = + if s.Contains '-' then + Error + <| ValidationErrors.create fieldName [ $"'%s{fieldName}' field cannot contain dash: '-'" ] + else + Ok <| s + + let notContainsUmlauts fieldName (s: string) = + let umlauts = [ 'ä'; 'ö'; 'ü'; 'ß'; 'Ä'; 'Ö'; 'Ü' ] |> set + + let contains = s |> Seq.exists (fun c -> umlauts |> Set.contains c) + + if contains then + Error + <| ValidationErrors.create fieldName [ + $"'%s{fieldName}' field cannot contain umlauts: ä, ö, ü, ß, Ä, Ö, Ü" + ] + else + Ok <| s + + open Validus.Operators + + let allowEmpty = ValueOption.ofObj >> ValueOption.filter (not << String.IsNullOrWhiteSpace) + + let validateStringCharacters = + notStartsWithWhiteSpace + <+> notEndsWithWhiteSpace + <+> notContainsTilde + <+> notContainsUmlauts + <+> notContainsBacktick + + module Uri = + + let isValid fieldName uriString = + if Uri.IsWellFormedUriString(uriString, UriKind.Absolute) then + Ok uriString + else + Error + <| ValidationErrors.create fieldName [ $"'%s{fieldName}' field is not a valid URI" ] + + +//module VOptionString = + +// let allow (validator : Validator) : Validator = +// fun fieldName (value : string voption) -> +// match value with +// | ValueNone -> Ok ValueNone +// | ValueSome str -> (validator *|* ValueSome) fieldName str + +// let toValidationResult _ value : ValidationResult = +// let valueOption = +// value +// |> ValueOption.ofObj +// |> ValueOption.filter (not << String.IsNullOrWhiteSpace) +// match valueOption with +// | ValueSome str -> ValueSome str |> Ok +// | ValueNone -> ValueNone |> Ok + +module ValidationErrors = + + let toIGQLErrors (errors: ValidationErrors) : IGQLError list = + errors + |> ValidationErrors.toList + |> List.map (fun e -> { new IGQLError with member _.Message = e }) + +module Operators = + + let vOption (v1: 'a -> 'a voption) (v2: Validator<'a, 'b>) : Validator<'a, 'b voption> = + fun x y -> + let value = v1 y + match value with + | ValueSome value -> (v2 *|* ValueSome) x y + | ValueNone -> Ok ValueNone + + let (?=>) v1 v2 = vOption v1 v2 + let (?=<) v2 v1 = vOption v1 v2 + +module Scalars = + + open System.Text.Json + open FSharp.Data.GraphQL.Ast + open FSharp.Data.GraphQL.Types + open FSharp.Data.GraphQL.Types.SchemaDefinitions.Errors + + type Define with + + static member ValidStringScalar<'t>(typeName, createValid : Validator, ?description: string) = + let createValid = createValid typeName + Define.WrappedScalar + (name = typeName, + coerceInput = + (function + | Variable e when e.ValueKind = JsonValueKind.String -> e.GetString() |> createValid |> Result.mapError ValidationErrors.toIGQLErrors + | InlineConstant (StringValue s) -> s |> createValid |> Result.mapError ValidationErrors.toIGQLErrors + | Variable e -> e.GetDeserializeError typeName + | InlineConstant value -> value.GetCoerceError typeName), + coerceOutput = + (function + | :? ('t voption) as x -> x |> string |> Some + | :? 't as x -> Some (string x) + | :? string as s -> s |> Some + | null -> None + | _ -> raise <| System.NotSupportedException ()), + ?description = description) diff --git a/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.fs b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.fs new file mode 100644 index 000000000..34717d2ad --- /dev/null +++ b/tests/FSharp.Data.GraphQL.Tests/Variables and Inputs/OptionalsNormalizationTests.fs @@ -0,0 +1,244 @@ +// The MIT License (MIT) + +[] +module FSharp.Data.GraphQL.Tests.OptionalsNormalizationTests + +#nowarn "25" + +open Xunit +open System +open System.Collections.Immutable +open System.Text.Json + +open FSharp.Data.GraphQL +open FSharp.Data.GraphQL.Types +open FSharp.Data.GraphQL.Parser +open FSharp.Data.GraphQL.Samples.StarWarsApi + +module Phantom = + + type ZipCode = interface end + type City = interface end + type State = interface end + + module Address = + + type Line1 = interface end + type Line2 = interface end + +open FSharp.Data.GraphQL.Tests.OptionalsNormalizationTests + +type AddressLine1 = ValidString +type AddressLine2 = ValidString +type City = ValidString +type State = ValidString +type ZipCode = ValidString + +type AddressRecord = { + Line1: AddressLine1 voption + Line2: AddressLine2 voption + City: City voption + State: State voption + ZipCode: ZipCode voption +} + +type AddressClass(zipCode, city, state, line1, line2) = + member _.Line1 : AddressLine1 voption = line1 + member _.Line2 : AddressLine2 voption = line2 + member _.City : City voption = city + member _.State : State voption = state + member _.ZipCode : ZipCode voption = zipCode + +[] +type AddressStruct ( + zipCode : ZipCode voption, + city : City voption, + state : State voption, + line1 : AddressLine1 voption, + line2 : AddressLine2 voption +) = + member _.Line1 = line1 + member _.Line2 = line2 + member _.City = city + member _.State = state + member _.ZipCode = zipCode + + +open Validus +open FSharp.Data.GraphQL.Tests.OptionalsNormalizationTests.String +open FSharp.Data.GraphQL.Tests.OptionalsNormalizationTests.Operators + +[] +module State = + + open ValidString + open Validus.Operators + + let create : Validator = + (Check.String.lessThanLen 100 <+> validateStringCharacters) *|* ValidString + + let createOrWhitespace : Validator = + (allowEmpty ?=> (Check.String.lessThanLen 100 <+> validateStringCharacters)) *|* ValueOption.map ValidString + +module Address = + + open ValidString + open Validus.Operators + + let createLine1 : Validator = + (allowEmpty ?=> (Check.String.lessThanLen 1000 <+> validateStringCharacters)) *|* ValueOption.map ValidString + + let createLine2 : Validator = + (allowEmpty ?=> (Check.String.lessThanLen 1000 <+> validateStringCharacters)) *|* ValueOption.map ValidString + + let createZipCode : Validator = + (allowEmpty ?=> (Check.String.lessThanLen 100 <+> validateStringCharacters)) *|* ValueOption.map ValidString + + let createCity : Validator = + (allowEmpty ?=> (Check.String.lessThanLen 100 <+> validateStringCharacters)) *|* ValueOption.map ValidString + + + open Scalars + + let Line1Type = Define.ValidStringScalar("AddressLine1", createLine1, "Address line 1") + let Line2Type = Define.ValidStringScalar("AddressLine2", createLine2, "Address line 2") + let ZipCodeType = Define.ValidStringScalar("AddressZipCode", createZipCode, "Address zip code") + let CityType = Define.ValidStringScalar("City", createCity) + let StateType = Define.ValidStringScalar("State", State.createOrWhitespace) + +let InputAddressRecordType = + Define.InputObject( + name = "InputAddressRecord", + fields = [ + Define.Input("line1", Nullable Address.Line1Type) + Define.Input("line2", Nullable Address.Line2Type) + Define.Input("zipCode", Nullable Address.ZipCodeType) + Define.Input("city", Nullable Address.CityType) + Define.Input("state", Nullable Address.StateType) + ] + ) + +let InputAddressClassType = + Define.InputObject( + name = "InputAddressObject", + fields = [ + Define.Input("line1", Nullable Address.Line1Type) + Define.Input("line2", Nullable Address.Line2Type) + Define.Input("zipCode", Nullable Address.ZipCodeType) + Define.Input("city", Nullable Address.CityType) + Define.Input("state", Nullable Address.StateType) + ] + ) + +let InputAddressStructType = + Define.InputObject( + name = "InputAddressStruct", + fields = [ + Define.Input("line1", Nullable Address.Line1Type) + Define.Input("line2", Nullable Address.Line2Type) + Define.Input("zipCode", Nullable Address.ZipCodeType) + Define.Input("city", Nullable Address.CityType) + Define.Input("state", Nullable Address.StateType) + ] + ) + +open FSharp.Data.GraphQL.Execution +open FSharp.Data.GraphQL.Validation +open FSharp.Data.GraphQL.Validation.ValidationResult +open ErrorHelpers + +let createSingleError message = + [{ new IGQLError with member _.Message = message }] + +type InputRecordNested = { HomeAddress : AddressRecord; WorkAddress : AddressRecord option; MailingAddress : AddressRecord voption } + +let InputRecordNestedType = + Define.InputObject ( + "InputRecordNested", + [ Define.Input ("homeAddress", InputAddressRecordType) + Define.Input ("workAddress", Nullable InputAddressRecordType) + Define.Input ("mailingAddress", Nullable InputAddressRecordType) ], + fun inputRecord -> + match inputRecord.MailingAddress, inputRecord.WorkAddress with + | ValueNone, None -> ValidationError <| createSingleError "MailingAddress or WorkAddress must be provided" + | _ -> Success + @@ + if inputRecord.MailingAddress.IsSome && inputRecord.HomeAddress = inputRecord.MailingAddress.Value then + ValidationError <| createSingleError "HomeAddress and MailingAddress must be different" + else + Success + ) + +let schema = + let schema = + Schema ( + query = + Define.Object ( + "Query", + fun () -> + [ Define.Field ( + "recordInputs", + StringType, + [ Define.Input ("record", InputAddressRecordType) + Define.Input ("recordOptional", Nullable InputAddressRecordType) + Define.Input ("recordNested", Nullable InputRecordNestedType) ], + stringifyInput + ) // TODO: add all args stringificaiton + Define.Field ( + "objectInputs", + StringType, + [ Define.Input ("object", InputAddressClassType) + Define.Input ("objectOptional", Nullable InputAddressClassType) ], + stringifyInput + ) // TODO: add all args stringificaiton + Define.Field ( + "structInputs", + StringType, + [ Define.Input ("struct", InputAddressStructType) + Define.Input ("structOptional", Nullable InputAddressStructType) ], + stringifyInput + ) ] // TODO: add all args stringificaiton + ) + ) + + Executor schema + + +[] +let ``Execute handles validation of valid inline input records with all fields`` () = + let query = + """{ + recordInputs( + record: { zipCode: "12345", city: "Miami" }, + recordOptional: { zipCode: "12345", city: "Miami" }, + recordNested: { homeAddress: { zipCode: "12345", city: "Miami" }, workAddress: { zipCode: "67890", city: "Miami" } } + ) + objectInputs( + object: { zipCode: "12345", city: "Miami" }, + objectOptional: { zipCode: "12345", city: "Miami" } + ) + structInputs( + struct: { zipCode: "12345", city: "Miami" }, + structOptional: { zipCode: "12345", city: "Miami" } + ) + }""" + let result = sync <| schema.AsyncExecute(parse query) + ensureDirect result <| fun data errors -> empty errors + +[] +let ``Execute handles validation of valid inline input records with mandatory-only fields`` () = + let query = + """{ + recordInputs( + record: { zipCode: "12345", city: "Miami" }, + recordNested: { homeAddress: { zipCode: "12345", city: "Miami" }, workAddress: { zipCode: "67890", city: "Miami" } } + ) + objectInputs( + object: { zipCode: "12345", city: "Miami" }, + ) + structInputs( + struct: { zipCode: "12345", city: "Miami" }, + ) + }""" + let result = sync <| schema.AsyncExecute(parse query) + ensureDirect result <| fun data errors -> empty errors