packages feed

amazonka-core-2.0: src/Amazonka/Data/Time.hs

-- |
-- Module      : Amazonka.Data.Time
-- Copyright   : (c) 2013-2023 Brendan Hay
-- License     : Mozilla Public License, v. 2.0.
-- Maintainer  : Brendan Hay <brendan.g.hay+amazonka@gmail.com>
-- Stability   : provisional
-- Portability : non-portable (GHC extensions)
module Amazonka.Data.Time
  ( -- * Time
    Format (..),
    Time (..),
    _Time,

    -- ** Formats
    UTCTime,
    RFC822,
    ISO8601,
    BasicTime,
    AWSTime,
    POSIX,
  )
where

import Amazonka.Core.Lens.Internal (iso)
import Amazonka.Data.ByteString
import Amazonka.Data.JSON
import Amazonka.Data.Query
import Amazonka.Data.Text
import Amazonka.Data.XML
import Amazonka.Prelude
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Types as Aeson
import qualified Data.Attoparsec.Text as A
import qualified Data.Attoparsec.Text as AText
import qualified Data.ByteString.Char8 as BS
import qualified Data.Scientific as Scientific
import qualified Data.Text as Text
import qualified Data.Time as Time
import Data.Time.Clock.POSIX
import Data.Time.Format (defaultTimeLocale, formatTime)

data Format
  = RFC822Format
  | ISO8601Format
  | BasicFormat
  | AWSFormat
  | POSIXFormat
  deriving stock (Eq, Read, Show, Generic)

newtype Time (a :: Format) = Time {fromTime :: UTCTime}
  deriving stock (Show, Read, Eq, Ord, Generic)
  deriving newtype (NFData)

instance Hashable (Time a) where
  hashWithSalt salt (Time (Time.UTCTime (Time.ModifiedJulianDay d) t)) =
    salt
      `hashWithSalt` d
      `hashWithSalt` toRational t

_Time :: Iso' (Time a) UTCTime
_Time = iso fromTime Time

convert :: Time a -> Time b
convert = coerce

type RFC822 = Time 'RFC822Format

type ISO8601 = Time 'ISO8601Format

type BasicTime = Time 'BasicFormat

type AWSTime = Time 'AWSFormat

type POSIX = Time 'POSIXFormat

class TimeFormat a where
  format :: proxy a -> String

instance TimeFormat RFC822 where
  format _ = "%a, %d %b %Y %H:%M:%S %Z"

instance TimeFormat ISO8601 where
  format _ = "%FT%XZ"

instance TimeFormat BasicTime where
  format _ = "%Y%m%d"

instance TimeFormat AWSTime where
  format _ = "%Y%m%dT%H%M%SZ"

instance FromText (Time fmt) where
  fromText = A.parseOnly ((parseUnixTimestamp <|> parseFormattedTime) <* A.endOfInput)

parseFormattedTime :: A.Parser (Time a)
parseFormattedTime = do
  s <- Text.unpack <$> AText.takeText

  let parse :: String -> A.Parser (Time a)
      parse fmt =
        case Time.parseTimeM True defaultTimeLocale fmt s of
          Just x -> pure (Time x)
          Nothing ->
            fail
              ( "Unable to parse Time format "
                  ++ show fmt
                  ++ " from "
                  ++ show s
              )

  parse (format (Proxy @RFC822))
    <|> parse (format (Proxy @ISO8601))
    <|> parse (format (Proxy @BasicTime))
    <|> parse (format (Proxy @AWSTime))
    -- Deprecated ISO8601 format exhibited in the AWS-supplied examples.
    <|> parse "%FT%X%Q%Z"
    -- Exhaustive Failure
    <|> fail ("Failure parsing Time from value: " ++ show s)

parseUnixTimestamp :: A.Parser (Time a)
parseUnixTimestamp =
  Time . posixSecondsToUTCTime . realToFrac
    <$> AText.double
    <* AText.endOfInput
    <|> fail "Failure parsing Unix Timestamp"

instance ToText RFC822 where
  toText = Text.pack . renderFormattedTime

instance ToText ISO8601 where
  toText = Text.pack . renderFormattedTime

instance ToText BasicTime where
  toText = Text.pack . renderFormattedTime

instance ToText AWSTime where
  toText = Text.pack . renderFormattedTime

instance ToText POSIX where
  toText (Time t) = toText (truncate (utcTimeToPOSIXSeconds t) :: Integer)

renderFormattedTime :: forall a. TimeFormat (Time a) => Time a -> String
renderFormattedTime (Time t) =
  formatTime
    defaultTimeLocale
    (format (Proxy @(Time a)))
    -- Convert `t` to a GMT `ZonedTime`, because otherwise the
    -- `FormatTime` instance for `UTCTime` converts to UTC `ZonedTime`
    -- for us. While they are the same offset, a UTC `ZonedTime` emits
    -- `UTC` instead of `GMT` when formatted by `RFC822`'s
    -- `TimeFormat`, which is not a valid `zone` in RFC 822's grammar.
    (Time.utcToZonedTime (read "GMT") t)

instance FromXML RFC822 where
  parseXML = parseXMLText "RFC822"

instance FromXML ISO8601 where
  parseXML = parseXMLText "ISO8601"

instance FromXML AWSTime where
  parseXML = parseXMLText "AWSTime"

instance FromXML BasicTime where
  parseXML = parseXMLText "BasicTime"

instance FromJSON RFC822 where
  parseJSON = parseJSONText "RFC822"

instance FromJSON ISO8601 where
  parseJSON = parseJSONText "ISO8601"

instance FromJSON AWSTime where
  parseJSON = parseJSONText "AWSTime"

instance FromJSON BasicTime where
  parseJSON = parseJSONText "BasicTime"

-- This is a somewhat unfortunate hack to support the bizzare apigateway
-- occurence of returning ISO8601 or POSIX timestamps in unknown scenarios.
--
-- See: https://github.com/brendanhay/amazonka/issues/291
instance FromJSON POSIX where
  parseJSON o = fmap convert (str o) <|> num o
    where
      str :: Value -> Aeson.Parser ISO8601
      str = parseJSON

      num :: Value -> Aeson.Parser POSIX
      num =
        Aeson.withScientific
          "POSIX"
          ( pure
              . Time
              . posixSecondsToUTCTime
              . realToFrac
          )

instance ToByteString RFC822 where
  toBS = BS.pack . renderFormattedTime

instance ToByteString ISO8601 where
  toBS = BS.pack . renderFormattedTime

instance ToByteString BasicTime where
  toBS = BS.pack . renderFormattedTime

instance ToByteString AWSTime where
  toBS = BS.pack . renderFormattedTime

instance ToQuery RFC822 where
  toQuery = toQuery . toBS

instance ToQuery ISO8601 where
  toQuery = toQuery . toBS

instance ToQuery BasicTime where
  toQuery = toQuery . toBS

instance ToQuery AWSTime where
  toQuery = toQuery . toBS

instance ToQuery POSIX where
  toQuery (Time t) = toQuery (truncate (utcTimeToPOSIXSeconds t) :: Integer)

instance ToXML RFC822 where
  toXML = toXMLText

instance ToXML ISO8601 where
  toXML = toXMLText

instance ToXML AWSTime where
  toXML = toXMLText

instance ToXML BasicTime where
  toXML = toXMLText

instance ToJSON RFC822 where
  toJSON = toJSONText

instance ToJSON ISO8601 where
  toJSON = toJSONText

instance ToJSON AWSTime where
  toJSON = toJSONText

instance ToJSON BasicTime where
  toJSON = toJSONText

instance ToJSON POSIX where
  toJSON (Time t) =
    Aeson.Number $
      Scientific.scientific (truncate (utcTimeToPOSIXSeconds t) :: Integer) 0