From ecdc136f9e24fd6db89f7403c6bdf7aaaf6901e8 Mon Sep 17 00:00:00 2001 From: Bram Date: Tue, 16 Jan 2024 16:06:38 +0100 Subject: [PATCH] Write JSON test module --- src/Internal/Tools/Json.elm | 290 +++++++++++++++++++++++++++--------- tests/Test/Tools/Json.elm | 249 +++++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+), 67 deletions(-) create mode 100644 tests/Test/Tools/Json.elm diff --git a/src/Internal/Tools/Json.elm b/src/Internal/Tools/Json.elm index 48c64e0..bbc22f5 100644 --- a/src/Internal/Tools/Json.elm +++ b/src/Internal/Tools/Json.elm @@ -1,4 +1,11 @@ -module Internal.Tools.Json exposing (..) +module Internal.Tools.Json exposing + ( Coder, string, bool, int, float + , encode, decode + , Docs(..), RequiredField(..), toDocs + , list, slowDict, fastDict, maybe + , Field, field + , object2, object3, object4, object5, object6, object7, object8, object9, object10, object11 + ) {-| @@ -21,6 +28,36 @@ data types. Because this module uses dynamic builder types, this also means it is relatively easy to write documentation for any data type that uses this module to build its encoders and decoders. +@docs Coder, string, bool, int, float + + +## JSON Coding + +@docs encode, decode + + +## Documentation + +@docs Docs, RequiredField, toDocs + + +## Data types + +@docs list, slowDict, fastDict, maybe + + +## Objects + +This section creates objects that can be (re)used in the library's JSON +specification. For this, the user needs to construct fields for the object +first. + +@docs Field, field + +Once all fields are constructed, the user can create JSON objects. + +@docs object2, object3, object4, object5, object6, object7, object8, object9, object10, object11 + -} import Dict as SlowDict @@ -32,32 +69,55 @@ import Json.Decode as D import Json.Encode as E +{-| A field of type `a` as a subtype of an object `object`. + +In concrete terms, to construct a data type + + type alias User = + { name : String + , age : Int + , hobbies : List String + } + +The user needs to construct the field types: + + - `Field String User`, + - `Field Int User`, + - and `Field (List String) User`. + +-} type Field a object = Field { fieldName : String , description : List String , encoder : a -> Maybe E.Value , decoder : D.Decoder ( a, List Log ) - , docs : JSONDocs + , docs : Docs , toField : object -> a , requiredness : RequiredField } -type JSONCoder a - = JSONCoder +{-| Builder type that helps create JSON encoders, JSON decoders, data type +documentation and various other data types. +-} +type Coder a + = Coder { encoder : a -> E.Value , decoder : D.Decoder ( a, List Log ) - , docs : JSONDocs + , docs : Docs } -type JSONDocs +{-| Structure of JSON documentation. It is up to an external module to turn the +documentation structure into a readable format. +-} +type Docs = DocsBool - | DocsDict JSONDocs + | DocsDict Docs | DocsFloat | DocsInt - | DocsList JSONDocs + | DocsList Docs | DocsObject { name : String , description : List String @@ -66,22 +126,28 @@ type JSONDocs { field : String , description : List String , required : RequiredField - , content : JSONDocs + , content : Docs } } - | DocsOptional JSONDocs + | DocsOptional Docs | DocsString +{-| Value that tells whether an object field is required to be included. If it +is not required, it can either be omitted - or a given default will be assumed. +The given default is a string representation, not the actual value. +-} type RequiredField = RequiredField | OptionalField | OptionalFieldWithDefault String -bool : JSONCoder Bool +{-| Define a boolean value. +-} +bool : Coder Bool bool = - JSONCoder + Coder { encoder = E.bool , decoder = D.map empty D.bool , docs = DocsBool @@ -90,11 +156,18 @@ bool = {-| Get a JSON coder's decode value -} -decode : JSONCoder a -> D.Decoder ( a, List Log ) -decode (JSONCoder data) = +decode : Coder a -> D.Decoder ( a, List Log ) +decode (Coder data) = data.decoder +{-| Generate documentation from a Coder definition. +-} +toDocs : Coder a -> Docs +toDocs (Coder data) = + data.docs + + {-| Create a tuple with no logs -} empty : a -> ( a, List Log ) @@ -103,17 +176,25 @@ empty x = {-| Get a JSON coder's encode value + + + text : Json.Encode.Value + text = + encode string "test" + + -- == Json.Encode.string "test" + -} -encode : JSONCoder a -> (a -> E.Value) -encode (JSONCoder data) = +encode : Coder a -> (a -> E.Value) +encode (Coder data) = data.encoder {-| Define a fast dict. The dict can only have strings as keys. -} -fastDict : JSONCoder value -> JSONCoder (FastDict.Dict String value) -fastDict (JSONCoder value) = - JSONCoder +fastDict : Coder value -> Coder (FastDict.Dict String value) +fastDict (Coder value) = + Coder { encoder = FastDict.toCoreDict >> E.dict identity value.encoder , decoder = value.decoder @@ -132,26 +213,63 @@ fastDict (JSONCoder value) = } -{-| Create a new field +{-| Create a new field using any of the three provided options. + +For example, suppose we are creating a `Field String User` to represent the +`name` field in + + type alias User = + { name : String + , age : Int + , hobbies : List String + } + +then the following field type would be used: + + field.required + { fieldName = "name" -- Field name when encoded into JSON + , toField = .name + , description = + [ "This description describes this field's information content." + , "Here's another paragraph!" + ] + , coder = string + } + +Suppose the JSO isn't obligated to provide a list of hobbies, and the list would +by default be overriden with an empty list, then we would use the following +field type: + + field.optional.withDefault + { fieldName = "hobbies" + , toField = .hobbies + , description = + [ "The hobbies of the person. Can be omitted." + ] + , coder = list string + , default = ( [], [] ) -- The `List Log` can be inserted in case you wish to insert a message when relying on a default + , defaultToString = always "[]" -- Default converted to a string + } + -} field : - { required : { fieldName : String, toField : object -> a, description : List String, coder : JSONCoder a } -> Field a object + { required : { fieldName : String, toField : object -> a, description : List String, coder : Coder a } -> Field a object , optional : - { value : { fieldName : String, toField : object -> Maybe a, description : List String, coder : JSONCoder a } -> Field (Maybe a) object - , withDefault : { fieldName : String, toField : object -> a, description : List String, coder : JSONCoder a, default : ( a, List Log ), defaultToString : a -> String } -> Field a object + { value : { fieldName : String, toField : object -> Maybe a, description : List String, coder : Coder a } -> Field (Maybe a) object + , withDefault : { fieldName : String, toField : object -> a, description : List String, coder : Coder a, default : ( a, List Log ), defaultToString : a -> String } -> Field a object } } field = { required = \{ fieldName, toField, description, coder } -> case coder of - JSONCoder { encoder, decoder, docs } -> + Coder { encoder, decoder, docs } -> Field { fieldName = fieldName , toField = toField , description = description , encoder = encoder >> Maybe.Just - , decoder = decoder + , decoder = D.field fieldName decoder , docs = docs , requiredness = RequiredField } @@ -159,7 +277,7 @@ field = { value = \{ fieldName, toField, description, coder } -> case coder of - JSONCoder { encoder, decoder, docs } -> + Coder { encoder, decoder, docs } -> Field { fieldName = fieldName , toField = toField @@ -183,7 +301,7 @@ field = , withDefault = \{ fieldName, toField, description, coder, default, defaultToString } -> case coder of - JSONCoder { encoder, decoder, docs } -> + Coder { encoder, decoder, docs } -> Field { fieldName = fieldName , toField = toField @@ -201,22 +319,22 @@ field = } -{-| Define a float. +{-| Define a float value. -} -float : JSONCoder Float +float : Coder Float float = - JSONCoder + Coder { encoder = E.float , decoder = D.map empty D.float , docs = DocsFloat } -{-| Define an int. +{-| Define an int value. -} -int : JSONCoder Int +int : Coder Int int = - JSONCoder + Coder { encoder = E.int , decoder = D.map empty D.int , docs = DocsInt @@ -225,9 +343,9 @@ int = {-| Define a list. -} -list : JSONCoder a -> JSONCoder (List a) -list (JSONCoder old) = - JSONCoder +list : Coder a -> Coder (List a) +list (Coder old) = + Coder { encoder = E.list old.encoder , decoder = old.decoder @@ -242,9 +360,15 @@ list (JSONCoder old) = } -maybe : JSONCoder a -> JSONCoder (Maybe a) -maybe (JSONCoder old) = - JSONCoder +{-| Define a maybe value. + +NOTE: most of the time, you wish to avoid this function! Make sure to look at +objects instead. + +-} +maybe : Coder a -> Coder (Maybe a) +maybe (Coder old) = + Coder { encoder = Maybe.map old.encoder >> Maybe.withDefault E.null , decoder = old.decoder @@ -272,14 +396,46 @@ objectEncoder items object = {-| Define an object with 2 keys + + type alias Human = + { name : String, age : Maybe Int } + + humanCoder : Coder Human + humanCoder = + object2 + { name = "Human" + , description = + [ "Documentation description of the human type." + ] + , init = Human + } + (field.required + { fieldName = "name" + , toField = .name + , description = + [ "Human's name." + ] + , coder = string + } + ) + (field.optional.value + { fieldName = "age" + , toField = .age + , description = + [ "(Optional) human's age" + ] + , coder = int + } + ) + -} object2 : { name : String, description : List String, init : a -> b -> object } -> Field a object -> Field b object - -> JSONCoder object + -> Coder object object2 { name, description, init } fa fb = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -313,9 +469,9 @@ object3 : -> Field a object -> Field b object -> Field c object - -> JSONCoder object + -> Coder object object3 { name, description, init } fa fb fc = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -353,9 +509,9 @@ object4 : -> Field b object -> Field c object -> Field d object - -> JSONCoder object + -> Coder object object4 { name, description, init } fa fb fc fd = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -397,9 +553,9 @@ object5 : -> Field c object -> Field d object -> Field e object - -> JSONCoder object + -> Coder object object5 { name, description, init } fa fb fc fd fe = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -445,9 +601,9 @@ object6 : -> Field d object -> Field e object -> Field f object - -> JSONCoder object + -> Coder object object6 { name, description, init } fa fb fc fd fe ff = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -497,9 +653,9 @@ object7 : -> Field e object -> Field f object -> Field g object - -> JSONCoder object + -> Coder object object7 { name, description, init } fa fb fc fd fe ff fg = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -553,9 +709,9 @@ object8 : -> Field f object -> Field g object -> Field h object - -> JSONCoder object + -> Coder object object8 { name, description, init } fa fb fc fd fe ff fg fh = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -613,9 +769,9 @@ object9 : -> Field g object -> Field h object -> Field i object - -> JSONCoder object + -> Coder object object9 { name, description, init } fa fb fc fd fe ff fg fh fi = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -677,9 +833,9 @@ object10 : -> Field h object -> Field i object -> Field j object - -> JSONCoder object + -> Coder object object10 { name, description, init } fa fb fc fd fe ff fg fh fi fj = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -745,9 +901,9 @@ object11 : -> Field i object -> Field j object -> Field k object - -> JSONCoder object + -> Coder object object11 { name, description, init } fa fb fc fd fe ff fg fh fi fj fk = - JSONCoder + Coder { encoder = objectEncoder [ toEncodeField fa @@ -801,11 +957,11 @@ object11 { name, description, init } fa fb fc fd fe ff fg fh fi fj fk = } -{-| Define a slow dict from the elm/core library. +{-| Define a slow dict from the `elm/core` library. -} -slowDict : JSONCoder value -> JSONCoder (SlowDict.Dict String value) -slowDict (JSONCoder data) = - JSONCoder +slowDict : Coder value -> Coder (SlowDict.Dict String value) +slowDict (Coder data) = + Coder { encoder = E.dict identity data.encoder , decoder = data.decoder @@ -824,11 +980,11 @@ slowDict (JSONCoder data) = } -{-| Define a string. +{-| Define a string value. -} -string : JSONCoder String +string : Coder String string = - JSONCoder + Coder { encoder = E.string , decoder = D.map empty D.string , docs = DocsString @@ -844,7 +1000,7 @@ toDecoderField (Field data) = {-| Turn a Field type into a descriptive field documentation -} -toDocsField : Field a object -> { field : String, description : List String, required : RequiredField, content : JSONDocs } +toDocsField : Field a object -> { field : String, description : List String, required : RequiredField, content : Docs } toDocsField x = case x of Field { fieldName, description, docs, requiredness } -> diff --git a/tests/Test/Tools/Json.elm b/tests/Test/Tools/Json.elm new file mode 100644 index 0000000..c4fce46 --- /dev/null +++ b/tests/Test/Tools/Json.elm @@ -0,0 +1,249 @@ +module Test.Tools.Json exposing (..) + +import Expect +import Fuzz exposing (Fuzzer) +import Internal.Tools.Json as Json +import Json.Decode as D +import Json.Encode as E +import Test exposing (..) + + +type alias Human2 = + { name : String, age : Maybe Int } + + +type alias Human3 = + { name : String, age : Maybe Int, hobbies : List String } + + +type alias Human4 = + { name : String + , age : Maybe Int + , hobbies : List String + , weight : Maybe Float + } + + +type alias Human5 = + { name : String + , age : Maybe Int + , hobbies : List String + , weight : Maybe Float + , height : Float + } + + +ageField : Json.Field (Maybe Int) { a | age : Maybe Int } +ageField = + Json.field.optional.value + { fieldName = "age" + , toField = .age + , description = [] + , coder = Json.int + } + + +ageFuzzer : Fuzzer (Maybe Int) +ageFuzzer = + Fuzz.maybe Fuzz.int + + +heightField : Json.Field Float { a | height : Float } +heightField = + Json.field.required + { fieldName = "height" + , toField = .height + , description = [] + , coder = Json.float + } + + +heightFuzzer : Fuzzer Float +heightFuzzer = + Fuzz.niceFloat + + +hobbiesField : Json.Field (List String) { a | hobbies : List String } +hobbiesField = + Json.field.optional.withDefault + { fieldName = "hobbies" + , toField = .hobbies + , description = [] + , coder = Json.list Json.string + , default = ( [], [] ) + , defaultToString = always "[]" + } + + +hobbiesFuzzer : Fuzzer (List String) +hobbiesFuzzer = + Fuzz.list Fuzz.string + + +nameField : Json.Field String { a | name : String } +nameField = + Json.field.required + { fieldName = "name" + , toField = .name + , description = [] + , coder = Json.string + } + + +nameFuzzer : Fuzzer String +nameFuzzer = + Fuzz.string + + +weightField : Json.Field (Maybe Float) { a | weight : Maybe Float } +weightField = + Json.field.optional.value + { fieldName = "weight" + , toField = .weight + , description = [] + , coder = Json.float + } + + +weightFuzzer : Fuzzer (Maybe Float) +weightFuzzer = + -- TODO: Maybe make Float not so nice? + Fuzz.maybe Fuzz.niceFloat + + +human2Coder : Json.Coder Human2 +human2Coder = + Json.object2 + { name = "Human2" + , description = [] + , init = Human2 + } + nameField + ageField + + +human2Fuzzer : Fuzzer Human2 +human2Fuzzer = + Fuzz.map2 Human2 + nameFuzzer + ageFuzzer + + +human3Coder : Json.Coder Human3 +human3Coder = + Json.object3 + { name = "Human3" + , description = [] + , init = Human3 + } + nameField + ageField + hobbiesField + + +human3Fuzzer : Fuzzer Human3 +human3Fuzzer = + Fuzz.map3 Human3 + nameFuzzer + ageFuzzer + hobbiesFuzzer + + +human4Coder : Json.Coder Human4 +human4Coder = + Json.object4 + { name = "Human4" + , description = [] + , init = Human4 + } + nameField + ageField + hobbiesField + weightField + + +human4Fuzzer : Fuzzer Human4 +human4Fuzzer = + Fuzz.map4 Human4 + nameFuzzer + ageFuzzer + hobbiesFuzzer + weightFuzzer + + +human5Coder : Json.Coder Human5 +human5Coder = + Json.object5 + { name = "Human5" + , description = [] + , init = Human5 + } + nameField + ageField + hobbiesField + weightField + heightField + + +human5Fuzzer : Fuzzer Human5 +human5Fuzzer = + Fuzz.map5 Human5 + nameFuzzer + ageFuzzer + hobbiesFuzzer + weightFuzzer + heightFuzzer + + +suite : Test +suite = + describe "JSON module" + [ describe "Human2" + [ fuzz human2Fuzzer + "Recoding succeeds" + (\human -> + human + |> Json.encode human2Coder + |> E.encode 0 + |> D.decodeString (Json.decode human2Coder) + |> Result.map Tuple.first + |> Expect.equal (Ok human) + ) + ] + , describe "Human3" + [ fuzz human3Fuzzer + "Recoding succeeds" + (\human -> + human + |> Json.encode human3Coder + |> E.encode 0 + |> D.decodeString (Json.decode human3Coder) + |> Result.map Tuple.first + |> Expect.equal (Ok human) + ) + ] + , describe "Human4" + [ fuzz human4Fuzzer + "Recoding succeeds" + (\human -> + human + |> Json.encode human4Coder + |> E.encode 0 + |> D.decodeString (Json.decode human4Coder) + |> Result.map Tuple.first + |> Expect.equal (Ok human) + ) + ] + , describe "Human5" + [ fuzz human5Fuzzer + "Recoding succeeds" + (\human -> + human + |> Json.encode human5Coder + |> E.encode 0 + |> D.decodeString (Json.decode human5Coder) + |> Result.map Tuple.first + |> Expect.equal (Ok human) + ) + ] + ]