on master help sign in
Merge
commit5637fae61a25c9be24b3be9531bff24b7044846c authorBryan O'Sullivan <bos@serpentine.com> dateFri 17 Jul 2015, 20:18 (11 years ago) parent2980e4eb076043
2 files changed+167−9
Data/Aeson/Types/Instances.hs+90−7 tests/UnitTests.hs+77−2
Data/Aeson/Types/Instances.hs+90−7
@@ -1,6 +1,7 @@
1−{-# LANGUAGE CPP, DeriveDataTypeable, FlexibleContexts, FlexibleInstances,
2− GeneralizedNewtypeDeriving, IncoherentInstances, OverlappingInstances,
3− OverloadedStrings, UndecidableInstances, ViewPatterns #-}
1+{-# LANGUAGE CPP, BangPatterns, DeriveDataTypeable, FlexibleContexts,
2+ FlexibleInstances, GeneralizedNewtypeDeriving, IncoherentInstances,
3+ OverlappingInstances, OverloadedStrings, UndecidableInstances,
4+ ViewPatterns #-}
45{-# LANGUAGE DefaultSignatures #-}
56{-# OPTIONS_GHC -fno-warn-orphans #-}
67
@@ -55,9 +56,11 @@ module Data.Aeson.Types.Instances
5556 ) where
5657
5758import Control.Applicative ((<$>), (<*>), (<|>), pure, empty)
59+import Control.Monad (when)
5860import qualified Data.Aeson.Encode.Builder as E
5961import qualified Data.ByteString.Builder as B
6062import Data.Aeson.Functions
63+import Data.Bits ((.&.))
6164import Data.Monoid ((<>), mempty)
6265import Data.Aeson.Encode.Functions (brackets, builder, foldable, list)
6366import qualified Data.Aeson.Parser.Time as Time
@@ -66,6 +69,8 @@ import Data.Aeson.Types.Internal
6669import Data.Scientific (Scientific)
6770import qualified Data.Scientific as Scientific (coefficient, base10Exponent, fromFloatDigits, toRealFloat)
6871import Data.Attoparsec.Number (Number(..))
72+import qualified Data.Attoparsec.Text as A
73+import Data.Char (isDigit, ord)
6974import Data.Fixed
7075import Data.Foldable (Foldable, toList)
7176import Data.Functor.Identity (Identity(..))
@@ -75,8 +80,10 @@ import Data.Maybe (fromMaybe)
7580import Data.Monoid (Dual(..), First(..), Last(..))
7681import Data.Ratio (Ratio, (%), numerator, denominator)
7782import Data.Text (Text, pack, unpack)
78−import Data.Time (UTCTime, ZonedTime(..), TimeZone(..))
83+import Data.Time (UTCTime(..), ZonedTime(..), TimeZone(..), Day, utc)
84+import Data.Time.Calendar (fromGregorianValid, addDays)
7985import Data.Time.Format (FormatTime, formatTime, parseTime)
86+import Data.Time.LocalTime (TimeOfDay, makeTimeOfDayValid, minutesToTimeZone, timeOfDayToTime, localToUTCTimeOfDay)
8087import Data.Traversable as Tr (sequence, traverse)
8188import Data.Vector (Vector)
8289import Data.Word (Word, Word8, Word16, Word32, Word64)
@@ -740,11 +747,87 @@ utcTime :: UTCTime -> String
740747utcTime t = formatTime defaultTimeLocale format t
741748 where format = "%FT%T." ++ formatSubseconds t ++ "Z"
742749
750+getDay :: A.Parser Day
751+getDay = do
752+ yearStr <- A.takeWhile isDigit
753+ when (T.length yearStr < 4) (fail "year must consist of at least 4 digits")
754+
755+ let !year = toNum yearStr
756+ _ <- A.char '-'
757+ month <- digits "month"
758+ _ <- A.char '-'
759+ day <- digits "day"
760+
761+ case fromGregorianValid year month day of
762+ Nothing -> fail "invalid date"
763+ Just x -> return $! x
764+
765+toNum :: Num n => T.Text -> n
766+toNum = T.foldl' (\a c -> 10*a + digit c) 0
767+{-# INLINE toNum #-}
768+
769+digit :: Num n => Char -> n
770+digit c = fromIntegral (ord c .&. 0x0f)
771+{-# INLINE digit #-}
772+
773+digits :: Num n => String -> A.Parser n
774+digits msg = do
775+ x <- A.anyChar
776+ y <- A.anyChar
777+ if isDigit x && isDigit y
778+ then return $! (10 * digit x + digit y)
779+ else fail (msg ++ " is not 2 digits")
780+{-# INLINE digits #-}
781+
782+decimal :: Fractional a => T.Text -> a
783+decimal str = toNum str / 10^(T.length str)
784+{-# INLINE decimal #-}
785+
786+getTimeOfDay :: A.Parser TimeOfDay
787+getTimeOfDay = do
788+ hour <- digits "hours"
789+ _ <- A.char ':'
790+ minute <- digits "minutes"
791+ -- Allow omission of seconds. If seconds is omitted, don't try to
792+ -- parse the sub-second part.
793+ (sec,subsec)
794+ <- ((,) <$> (A.char ':' *> digits "seconds") <*> fract) <|> pure (0,0)
795+
796+ let !picos' = sec + subsec
797+
798+ case makeTimeOfDayValid hour minute picos' of
799+ Nothing -> fail "invalid time of day"
800+ Just x -> return $! x
801+
802+ where
803+ fract =
804+ (A.char '.' *> (decimal <$> A.takeWhile1 isDigit)) <|> pure 0
805+
806+getTimeZone :: A.Parser TimeZone
807+getTimeZone = do
808+ sign <- A.satisfy (\c -> c == '+' || c == '-')
809+ hours <- digits "timezone"
810+ mins <- (A.char ':' *> digits "timezone minutes") <|> pure 0
811+ let !absset = 60 * hours + mins
812+ !offset = if sign == '+' then absset else -absset
813+ return $! minutesToTimeZone offset
814+
815+getUTCTime :: A.Parser UTCTime
816+getUTCTime = do
817+ day <- getDay
818+ _ <- A.char ' ' <|> A.char 'T'
819+ time <- getTimeOfDay
820+ zone <- getTimeZone <|> (A.char 'Z' *> pure utc)
821+ let (!dayDelta,!time') = localToUTCTimeOfDay zone time
822+ let !day' = addDays dayDelta day
823+ let !time'' = timeOfDayToTime time'
824+ return (UTCTime day' time'')
825+
743826instance FromJSON UTCTime where
744827 parseJSON = withText "UTCTime" $ \t ->
745− case parseTime defaultTimeLocale "%FT%T%QZ" (unpack t) of
746− Just d -> pure d
747− _ -> fail "could not parse ISO-8601 date"
828+ case A.parseOnly (getUTCTime <* A.endOfInput) t of
829+ Right d -> pure d
830+ Left msg -> fail ("could not parse ISO-8601 date: " ++ msg)
748831
749832parseJSONElemAtIndex :: FromJSON a => Int -> Vector Value -> Parser a
750833parseJSONElemAtIndex idx ary = parseJSON (V.unsafeIndex ary idx) <?> Index idx
tests/UnitTests.hs+77−2
@@ -1,12 +1,14 @@
1−{-# LANGUAGE DeriveGeneric #-}
1+{-# LANGUAGE CPP, DeriveGeneric, OverloadedStrings, ScopedTypeVariables #-}
22
33module UnitTests (ioTests, tests) where
44
55import Control.Monad (forM)
6−import Data.Aeson (eitherDecode, encode, genericToJSON, genericToEncoding)
6+import Data.Aeson (decode, eitherDecode, encode, genericToJSON, genericToEncoding)
77import Data.Aeson.Encode (encodeToTextBuilder)
88import Data.Aeson.Types (ToJSON(..), Value, camelTo, camelTo2, defaultOptions)
99import Data.Char (toUpper)
10+import Data.Time (UTCTime)
11+import Data.Time.Format (parseTime)
1012import GHC.Generics (Generic)
1113import Test.Framework (Test, testGroup)
1214import Test.Framework.Providers.HUnit (testCase)
@@ -14,6 +16,14 @@ import Test.HUnit (Assertion, assertFailure, assertEqual)
1416import qualified Data.ByteString.Lazy.Char8 as L
1517import qualified Data.Text.Lazy.Builder as TLB
1618import qualified Data.Text.Lazy.Encoding as TLE
19+import qualified Data.Text.Lazy as LT
20+import qualified Data.Text.Lazy.Encoding as LT
21+
22+#if MIN_VERSION_time(1,5,0)
23+import Data.Time.Format (defaultTimeLocale)
24+#else
25+import System.Locale (defaultTimeLocale)
26+#endif
1727
1828tests :: Test
1929tests = testGroup "unit" [
@@ -32,6 +42,10 @@ tests = testGroup "unit" [
3242 , testGroup "encoding" [
3343 testCase "goodProducer" $ goodProducer
3444 ]
45+ , testGroup "utctime" [
46+ testCase "good" $ utcTimeGood
47+ , testCase "bad" $ utcTimeBad
48+ ]
3549 ]
3650
3751roundTripCamel :: String -> Assertion
@@ -67,6 +81,67 @@ goodProducer = assertEqual "partial encoding should not explode on undefined"
6781 , wibbleInt = undefined
6882 }
6983
84+-- Test decoding various UTC time formats
85+--
86+-- Note: the incomplete pattern matches for UTCTimes are completely
87+-- intentional. The test expects these parses to succeed. If the
88+-- pattern matches fails, there's a bug in either the test or in aeson
89+-- and needs to be investigated.
90+utcTimeGood :: Assertion
91+utcTimeGood = do
92+ let ts1 = "2015-01-01T12:13:00.00Z" :: LT.Text
93+ let ts2 = "2015-01-01T12:13:00Z" :: LT.Text
94+ -- 'T' between date and time is not required, can be space
95+ let ts3 = "2015-01-03 12:13:00.00Z" :: LT.Text
96+ let ts4 = "2015-01-03 12:13:00.125Z" :: LT.Text
97+ let (Just (t1 :: UTCTime)) = parseWithAeson ts1
98+ let (Just (t2 :: UTCTime)) = parseWithAeson ts2
99+ let (Just (t3 :: UTCTime)) = parseWithAeson ts3
100+ let (Just (t4 :: UTCTime)) = parseWithAeson ts4
101+ assertEqual "utctime" (parseWithRead "%FT%T%QZ" ts1) t1
102+ assertEqual "utctime" (parseWithRead "%FT%T%QZ" ts2) t2
103+ assertEqual "utctime" (parseWithRead "%F %T%QZ" ts3) t3
104+ assertEqual "utctime" (parseWithRead "%F %T%QZ" ts4) t4
105+ -- timezones. Both +HH and +HH:MM are allowed for timezone offset
106+ let ts5 = "2015-01-01T12:30:00.00+00" :: LT.Text
107+ let ts6 = "2015-01-01T12:30:00.00+01:15" :: LT.Text
108+ let ts7 = "2015-01-01T12:30:00.00-02" :: LT.Text
109+ let ts8 = "2015-01-01T22:00:00.00-03" :: LT.Text
110+ let (Just (t5 :: UTCTime)) = parseWithAeson ts5
111+ let (Just (t6 :: UTCTime)) = parseWithAeson ts6
112+ let (Just (t7 :: UTCTime)) = parseWithAeson ts7
113+ let (Just (t8 :: UTCTime)) = parseWithAeson ts8
114+ assertEqual "utctime" (parseWithRead "%FT%T%QZ" "2015-01-01T12:30:00.00Z") t5
115+ assertEqual "utctime" (parseWithRead "%FT%T%QZ" "2015-01-01T11:15:00.00Z") t6
116+ assertEqual "utctime" (parseWithRead "%FT%T%QZ" "2015-01-01T14:30:00Z") t7
117+ -- ts8 wraps around to the next day in UTC
118+ assertEqual "utctime" (parseWithRead "%FT%T%QZ" "2015-01-02T01:00:00Z") t8
119+ where
120+ parseWithRead :: String -> LT.Text -> UTCTime
121+ parseWithRead f s =
122+ case parseTime defaultTimeLocale f . LT.unpack $ s of
123+ Nothing -> error "parseTime input malformed"
124+ Just t -> t
125+ parseWithAeson :: LT.Text -> Maybe UTCTime
126+ parseWithAeson s = decode . LT.encodeUtf8 $ (LT.concat ["\"", s, "\""])
127+
128+-- Test that a few non-timezone qualified timestamp formats get
129+-- rejected if decoding to UTCTime.
130+utcTimeBad :: Assertion
131+utcTimeBad = do
132+ verifyFailParse "2000-01-01T12:13:00" -- missing Zulu time not allowed (some TZ required)
133+ verifyFailParse "2000-01-01 12:13:00" -- missing Zulu time not allowed (some TZ required)
134+ verifyFailParse "2000-01-01" -- date only not OK
135+ verifyFailParse "2000-01-01Z" -- date only not OK
136+ verifyFailParse "2015-01-01T12:30:00.00+00Z" -- no Zulu if offset given
137+ verifyFailParse "2015-01-01T12:30:00.00+00:00Z" -- no Zulu if offset given
138+ verifyFailParse "2015-01-03 12:13:00.Z" -- decimal at the end but no digits
139+ where
140+ verifyFailParse (s :: LT.Text) =
141+ let (dec :: Maybe UTCTime) = decode . LT.encodeUtf8 $ (LT.concat ["\"", s, "\""]) in
142+ assertEqual "verify failure" Nothing dec
143+
144+
70145------------------------------------------------------------------------------
71146-- Comparison between bytestring and text encoders
72147------------------------------------------------------------------------------