packages feed

registry-aeson-0.2.3.2: test/Test/Data/Registry/Aeson/DecoderSpec.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE PartialTypeSignatures #-}
{-# OPTIONS_GHC -fno-warn-partial-type-signatures #-}

module Test.Data.Registry.Aeson.DecoderSpec where

import Data.Aeson hiding (decode)
import Data.Aeson qualified as A
import Data.ByteString.Lazy qualified as BL (ByteString, fromStrict)
import Data.Registry
import Data.Registry.Aeson.Decoder
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import Data.Time
import Protolude
import Test.Data.Registry.Aeson.DataTypes
import Test.Data.Registry.Aeson.SimilarDataTypes qualified as SimilarDataTypes
import Test.Tasty.Hedgehogx hiding (either, maybe, text)

test_decode = test "decode" $ do
  checkDecoding "123" (Identifier 123)
  checkDecoding "{'_email':'me@here.com'}" email1
  checkDecoding "{'email':{'_email':'me@here.com'},'identifier':123}" person1
  checkDecoding "{'_datetime':'2022-04-18T00:00:12Z'}" datetime1
  checkDecoding "{'tag':'NoDelivery'}" delivery0
  checkDecoding "{'tag':'ByEmail','contents':{'_email':'me@here.com'}}" delivery1
  checkDecoding "{'tag':'InPerson','contents':[{'email':{'_email':'me@here.com'},'identifier':123},{'_datetime':'2022-04-18T00:00:12Z'}]}" delivery2

test_all_nullary_to_string_tag = test "allNullaryToStringTag" $ do
  checkDecodingWith allNullaryOptions "'AllNullary1'" AllNullary1
  checkDecodingWith allNullaryOptions "'AllNullary2'" AllNullary2

test_field_modifier = test "fieldLabelModifier" $ do
  checkDecodingWith fieldLabelModifierOptions "{'tag':'FieldLabelModifier1','__field1':123}" (FieldLabelModifier1 123)

test_constructor_modifier = test "constructorTagModifier" $ do
  checkDecodingWith constructorTagModifierOptions "{'tag':'__ConstructorTagModifier1','ctField1':123}" (ConstructorTagModifier1 123)

test_omit_nothing_fields = test "omitNothingFields" $ do
  checkDecodingWith omitNothingFieldsOptions "{'tag':'OmitNothingFields1','onField2':123}" (OmitNothingFields1 Nothing 123)

test_unwrap_unary_records = test "unwrapUnaryRecords" $ do
  checkDecodingWith unwrapUnaryRecordsOptions "123" (UnwrapUnaryRecords1 123)

test_tag_single_constructors = test "TagSingleConstructors" $ do
  checkDecodingWith tagSingleConstructorsOptions "{'tag':'TagSingleConstructors1', 'tsField1':123}" (TagSingleConstructors1 123)

test_untagged_values_sum_encoding = test "UntaggedValueSumEncoding" $ do
  checkDecodingWith untaggedValueOptions "{'uvField1':123}" (UntaggedValueSumEncoding1 123)

test_object_with_single_field_sum_encoding = test "ObjectWithSingleFieldSumEncoding" $ do
  checkDecodingWith objectWithSingleFieldSumEncodingOptions "{'ObjectWithSingleFieldSumEncoding1':{'owsfField1':123}}" (ObjectWithSingleFieldSumEncoding1 123)

test_two_elem_array_sum_encoding = test "TwoElemArray" $ do
  checkDecodingWith twoElemArraySumEncodingOptions "['TwoElemArraySumEncoding1',{'teaField1':123}]" (TwoElemArraySumEncoding1 123)

test_errors = test "report errors" $ do
  checkErrors @Identifier "'123'" "Cannot decode the type 'Identifier' >> parsing Int failed, expected Number, but encountered String"
  checkErrors @Email "{'_email':123}" "Cannot decode the type 'Email' >> '_email :: Text' >> parsing Text failed, expected String, but encountered Number"
  checkErrors @Email "{'email':'me@here.com'}" "Cannot decode the type 'Email' >> field '_email' not found"
  checkErrors @Person "{'email':{'email':'me@here.com'},'identifier':123}" "Cannot decode the type 'Person' >> 'email :: Email' >> field '_email' not found"
  checkErrors @Person "{'email':{'email':'me@here.com'}}" "Cannot decode the type 'Person' >> field 'identifier' not found"
  checkErrors @Person "{'email':{'_email':123},'identifier':123}" "Cannot decode the type 'Person' >> 'email :: Email' >> '_email :: Text' >> parsing Text failed, expected String, but encountered Number"
  checkErrors @Team
    "{'name':'team1', 'members': [{'email':{'_email':'1'},'identifier':1}, {'email':{'_email':2},'identifier':2}], 'leaderName':'me'}"
    "Cannot decode the type 'Team' >> 'members :: [] Person' >> 'email :: Email' >> '_email :: Text' >> parsing Text failed, expected String, but encountered Number"
  checkErrors @Team
    "{'name':'team1', 'members': [{'email':{'_email':'1'},'identifier':1}, {'email':{'_email':'2'},'identifier':2}], 'leaderName':123}"
    "Cannot decode the type 'Team' >> 'leaderName :: Maybe Text' >> parsing Text failed, expected String, but encountered Number"

  checkErrors @Delivery "{'tag':'NoDeliveryx'}" "Cannot decode the type 'Delivery' >> expected the tag field to be one of: NoDelivery, ByEmail, InPerson, found: NoDeliveryx"
  checkErrors @Delivery "{'tag1':'NoDelivery'}" "Cannot decode the type 'Delivery' >> tag field 'tag' not found"
  checkErrorsWith @Delivery
    constructorTagModifierOptions
    "{'tag':'_NoDelivery'}"
    "Cannot decode the type 'Delivery' >> expected the tag field to be one of: __NoDelivery, __ByEmail, __InPerson, found: _NoDelivery"

  checkErrors @Delivery
    "{'tag':'ByEmail','contents':{'_email':123}}"
    "Cannot decode the type 'Delivery' >> (ByEmail) '_email :: Text' >> parsing Text failed, expected String, but encountered Number"

  checkErrors @Delivery
    "{'tag':'InPerson','contents':[{'email':{'_email':'me@here.com'},'identifier':123},{'datetime':'2022-04-18T00:00:12Z'}]}"
    "Cannot decode the type 'Delivery' >> (InPerson) field '_datetime' not found"

  checkErrors @Delivery
    "{'tag':'InPerson','contents':[{'email':{'_email':'me@here.com'},'identifier':123},'2022-04-18T00:00:12Z']}"
    "Cannot decode the type 'Delivery' >> (InPerson) expected an object with field '_datetime"

test_reject_unknown_fields = test "rejectUnknownFields" $ do
  let reject = defaultOptions {rejectUnknownFields = True}
  checkErrorsWith @Email reject "{'_email':'me@here.com', 'f':1}" "Cannot decode the type 'Email' >> unknown field: f"
  checkErrorsWith @Delivery
    reject
    "{'tag':'ByEmail','contents':{'_email':'me@here.com','f':1}}"
    "Cannot decode the type 'Delivery' >> (ByEmail) unknown field: f"
  checkErrorsWith @Delivery
    reject {sumEncoding = UntaggedValue}
    "{'_email':'me@here.com','f':1}"
    "Cannot decode the type 'Delivery' >> (ByEmail) unknown field: f"
  checkErrorsWith @Delivery
    reject {sumEncoding = TwoElemArray}
    "['ByEmail', {'_email':'me@here.com','f':1}]"
    "Cannot decode the type 'Delivery' >> (ByEmail) unknown field: f"
  checkErrorsWith @Delivery
    reject {sumEncoding = ObjectWithSingleField}
    "{'ByEmail':{'_email':'me@here.com','f':1}}"
    "Cannot decode the type 'Delivery' >> (ByEmail) unknown field: f"
  checkErrorsWith @Delivery
    reject
    "{'tag':'InPerson','contents':[{'email':{'_email':'me@here.com'},'identifier':123,'f1':1,'f2':1},{'_datetime':'2022-04-18T00:00:12Z'}]}"
    "Cannot decode the type 'Delivery' >> (InPerson) unknown fields: f1, f2"
  checkErrorsWith @Delivery
    reject {sumEncoding = UntaggedValue}
    "[{'email':{'_email':'me@here.com'},'identifier':123,'f1':1,'f2':1},{'_datetime':'2022-04-18T00:00:12Z'}]"
    "Cannot decode the type 'Delivery' >> (ByEmail) expected an object with field '_email ->> (InPerson) unknown fields: f1, f2"
  checkErrorsWith @Delivery
    reject {sumEncoding = TwoElemArray}
    "['InPerson', [{'email':{'_email':'me@here.com'},'identifier':123,'f1':1,'f2':1},{'_datetime':'2022-04-18T00:00:12Z'}]]"
    "Cannot decode the type 'Delivery' >> (InPerson) unknown fields: f1, f2"
  checkErrorsWith @Delivery
    reject {sumEncoding = ObjectWithSingleField}
    "{'InPerson':[{'email':{'_email':'me@here.com'},'identifier':123,'f1':1,'f2':1},{'_datetime':'2022-04-18T00:00:12Z'}]}"
    "Cannot decode the type 'Delivery' >> (InPerson) unknown fields: f1, f2"

test_types_th_index_error = test "error with TH when 2 fields have the same type" $ do
  -- this code did not compile before
  let _ = $(makeDecoder ''Stats) <: jsonDecoder @Int <: defaultDecoderOptions
  success

test_decode_map = test "decode map" $ do
  let ds =
        decodeMapOf @Name @Int
          <: decodeKey (Right . Name)
          <: jsonDecoder @Int
          <: jsonDecoder @Text
          <: defaultDecoderOptions

  case decodeByteString (make @(Decoder (Map Name Int)) ds) "{\"name1\":1,\"name2\":2}" of
    Left e -> annotateShow e >> failure
    Right a -> a === [("name1", 1), ("name2", 2)]

-- * HELPERS

checkDecoding :: forall a. (FromJSON a, ToJSON a, Typeable a, Eq a, Show a) => Text -> a -> PropertyT IO ()
checkDecoding t a = withFrozenCallStack $ checkDecodingWith defaultOptions t a

checkDecodingWith :: forall a. (FromJSON a, ToJSON a, Typeable a, Eq a, Show a) => Options -> Text -> a -> PropertyT IO ()
checkDecodingWith options text a = withFrozenCallStack $ do
  let input = setDoubleQuotes text
  let decoder = make @(Decoder a) (val options <: decoders)
  let asValue = decodeByteString decoder input
  let asGeneric = A.decode input

  annotate "the decoded Value must be the same as the generic one"
  annotateShow (encode a)
  asValue === maybe (Left "wrong") Right asGeneric

  annotate "the decoded Value must be the expected value"
  asValue === Right a

checkErrors :: forall a. (FromJSON a, ToJSON a, Typeable a, Eq a, Show a) => Text -> Text -> PropertyT IO ()
checkErrors t1 t2 = withFrozenCallStack $ checkErrorsWith @a defaultOptions t1 t2

checkErrorsWith :: forall a. (FromJSON a, ToJSON a, Typeable a, Eq a, Show a) => Options -> Text -> Text -> PropertyT IO ()
checkErrorsWith options text errorMessage = withFrozenCallStack $ do
  let input = setDoubleQuotes text
  let decoder = make @(Decoder a) (val options <: decoders)
  let asValue = decodeByteString decoder input
  -- let asGeneric = A.eitherDecode @a input
  -- annotateShow asGeneric
  mapLeft setSimpleQuotes asValue === Left (setSimpleQuotes errorMessage)

decoders :: Registry _ _
decoders =
  $(makeDecoder ''Delivery)
    <: $(makeDecoder ''Team)
    <: decodeListOf @Person
    -- test that it is possible to generate a Decoder when there are name clashes
    <: $(makeDecoderQualifiedLast ''SimilarDataTypes.Person)
    <: $(makeDecoderQualifiedLast ''SimilarDataTypes.Email)
    <: $(makeDecoderQualifiedLast ''SimilarDataTypes.Identifier)
    <: $(makeDecoderQualifiedLast ''SimilarDataTypes.DateTime)
    <: $(makeDecoder ''Person)
    <: $(makeDecoder ''Email)
    <: $(makeDecoder ''Identifier)
    <: $(makeDecoder ''DateTime)
    <: $(makeDecoder ''AllNullary)
    <: $(makeDecoder ''FieldLabelModifier)
    <: $(makeDecoder ''ConstructorTagModifier)
    <: $(makeDecoder ''OmitNothingFields)
    <: $(makeDecoder ''UnwrapUnaryRecords)
    <: $(makeDecoder ''TagSingleConstructors)
    <: $(makeDecoder ''UntaggedValueSumEncoding)
    <: $(makeDecoder ''ObjectWithSingleFieldSumEncoding)
    <: $(makeDecoder ''TwoElemArraySumEncoding)
    <: fun utcTimeDecoder
    <: decodeMaybeOf @Text
    <: jsonDecoder @Text
    <: decodeMaybeOf @Int
    <: jsonDecoder @Int
    <: defaultDecoderOptions

utcTimeDecoder :: Decoder UTCTime
utcTimeDecoder = Decoder $ \case
  String s ->
    case parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%S%QZ" $ toS s of
      Just t -> pure t
      Nothing -> Left ("cannot read a UTCTime: " <> s)
  other -> Left $ "not a valid UTCTime: " <> show other

setSimpleQuotes :: Text -> Text
setSimpleQuotes = T.replace "\"" "'"

setDoubleQuotesText :: Text -> Text
setDoubleQuotesText = T.replace "'" "\""

setDoubleQuotes :: Text -> BL.ByteString
setDoubleQuotes = BL.fromStrict . T.encodeUtf8 . setDoubleQuotesText

mapLeft :: (a -> c) -> Either a b -> Either c b
mapLeft f (Left e) = Left (f e)
mapLeft _ (Right a) = Right a