Write JSON test module

json-extra
Bram 2024-01-16 16:06:38 +01:00
parent 21dfa1e77f
commit ecdc136f9e
2 changed files with 472 additions and 67 deletions

View File

@ -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 } ->

249
tests/Test/Tools/Json.elm Normal file
View File

@ -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)
)
]
]