packages feed

twill-0.1.0.3: src/Service/Twilio/Types.hs

{-# LANGUAGE DeriveDataTypeable, OverloadedStrings, RecordWildCards #-}

-- | General API types for communicating with Twilio. The names of the
-- types are fairly generic and thus are expected to be imported
-- qualified.
module Service.Twilio.Types where

import Prelude hiding (id)

import Text.Printf
import Data.Data
import Data.Char
import Data.String
import Data.Monoid
import Data.Default
import Data.Aeson
import Data.Aeson.Types
import qualified Data.Attoparsec.Text as A
import Data.Time.Clock
import Data.Time.Format
import System.Locale
import Data.DateTime () -- imports 'Arbitrary UTCTime'
import           Data.ByteString (ByteString)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BC
import qualified Data.ByteString.Base16 as B16
import           Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE

import Control.Applicative
import Control.Error
import Test.QuickCheck

-- | A price in cents USD.
type Price = Int

-- | A Twilio identity, either for an application or a user,
-- corresponds to a Twilio SID and Secret Token, in order.
data Passport = Passport ByteString ByteString
              deriving (Eq, Data, Typeable)

-- | Specialized to elide secret token information
instance Show Passport where
  showsPrec _ (Passport sid _) rest =
    "Passport " ++ show sid ++ "[elided]" ++ rest

-- | Phone numbers are just strings containing only numbers with some
-- specialized creation logic. In particular, a phone number can be
-- any string of numbers and are somewhat assumed to be US numbers
-- (country code "+1"). The input data is preserved---a number entered
-- without a country code is stored, printed, and returned without a
-- country code---but 'Eq' is overrided with some assumptions.
newtype PhoneNumber = PhoneNumber ByteString
                    deriving (Show, Data, Typeable)

-- | TODO Make this do more than lift the equality judgement
instance Eq PhoneNumber where
  (PhoneNumber a) == (PhoneNumber b) = a == b

-- | Converts a string to a more canonical PhoneNumber type
--
-- > fromString = PhoneNumber . BC.pack . filter isDigit
--
instance IsString PhoneNumber where
  fromString = PhoneNumber . BC.pack . filter isDigit

-- | The status of an outbound SMS in Twilio's system. The state
-- machine for messages is @(Queued -> Sending -> {Sent, Failed})@.
data SendStatus =
  -- | 'Queued' messages have been received by Twilio but not yet sent.
  Queued |
  -- | 'Sending' messages are in the process of sending.
  Sending |
  -- | 'Sent' messages have been confirmed sent and thus have both the
  -- send date and the price charged.
  Sent UTCTime Price |
  -- | 'Failed' messages have been confirmed failed to send by the
  -- carrier and usually suggest an unregistered phone number.
  Failed 
  deriving (Show, Eq, Ord, Data, Typeable)

-- | Assumes the default is a just sent message, thus it's queued
instance Default SendStatus where
  def = Queued

-- | The type of API used to send an outgoing message
data APIKind = API | Reply deriving (Show, Eq, Data, Typeable)

-- | Assumes the default is API usage
instance Default APIKind where
  def = API

-- | The type of SMS, be it incoming or outgoing
data SMSKind = Outbound APIKind SendStatus | Inbound
             deriving (Show, Eq, Data, Typeable)

-- | Assumes we're sending a default outbound message
instance Default SMSKind where
  def = Outbound def def

-- | A fixed enum representation of the API version since
-- 'ByteString's are far too permissive.
data APIVersion = Api20100401 deriving (Eq, Ord, Data, Typeable)

instance Show APIVersion where
  show Api20100401 = "2010-04-01"

-- | Defaults to the most recent API, "2010-04-01"
instance Default APIVersion where
  def = Api20100401

-- | A unique identifier for a message based on the account SID, API
-- version, and message SID. This can be used to construct the
-- canonical Twilio API URI.
data Id = Id { account :: ByteString, version :: APIVersion, sid :: ByteString }
        deriving (Show, Eq, Data, Typeable)

-- | Create a URI derived from an 'Id'
uri :: ByteString -> Id -> ByteString
uri typ (Id { .. }) = "/" <> fromString (show version)
                      <> "/Accounts/" <> account
                      <> "/" <> typ <> "/"
                      <> sid

data SMSCore =
  SMSCore { to   :: PhoneNumber,
            from :: PhoneNumber,
            body :: Text }
  deriving (Show, Eq, Data, Typeable)

-- | A 'TwilioMsg' is a record of an SMS sent or received by
-- Twilio. These are returned from calls to the Twilio log API or as
-- responses to new POSTs to the Twilio SMS API.
data SMS =
  SMS { id           :: Id,
        kind         :: SMSKind,
        core         :: SMSCore,
        dateCreated  :: UTCTime,
        dateUpdated  :: UTCTime }
  deriving (Show, Eq, Data, Typeable)

-- Parameter parsing

-- | Indicates that an object can be parsed a Form-Urlencoded data
-- source
class FromFormUrlencoded a where
  fromForm :: [(ByteString, ByteString)] -> Maybe a

-- Aeson JSON Serialization helper types

-- | The UTC format used by Twilio: "%a, %d %b %Y %T %z"
twilioUTCFormat :: String
twilioUTCFormat = "%a, %d %b %Y %T %z"

-- | This has a VERY interesting general type signature, I wonder if
-- it exists somewhere more clearly?
--
-- > (Monad m, Monad w) => (a -> m b) -> w a -> m (w a)
--
maybeParse :: (Value -> Parser a) -> Maybe Value -> Parser (Maybe a)
maybeParse p = maybe (return Nothing) (fmap Just . p)

-- | A super Quick Object creator-helper
qo :: Text -> Text -> Value
qo a b = object [ a .= b ]

-- | Assuming all of the 'Value's are 'Object's, concat the fields
-- using the Monoid instance of the underlying map, thus the concat is
-- left-annihilating (earlier objects have priority)
concatObjects :: [Value] -> Value
concatObjects = Object . mconcat . map unObject
  where unObject (Object m) = m
        unObject _ = error "error: Service.Twilio.Types.concatObjects"

-- | A newtype around UTCTime for serialization of Twilio timestamps
newtype TwilioTime = TwilioTime { unTwilioTime :: UTCTime }

instance FromJSON TwilioTime where
  parseJSON (String s) =
    justZ $ fmap TwilioTime $ parseTime defaultTimeLocale twilioUTCFormat (T.unpack s)
  parseJSON _ = fail "parse Service.Twilio.Types.TwilioTime"

instance ToJSON TwilioTime where
  toJSON (TwilioTime utc) =
    String $ T.pack $ formatTime defaultTimeLocale twilioUTCFormat utc

-- | A newtype around Int for serialization of Twilio prices. It's
-- really disappointing that Twilio didn't just use a JSON double here
newtype USD = Cents { unCents :: Int }

instance FromJSON USD where
  parseJSON (String s) =
    fmap (Cents . negate . round . (*100))
    $ rightZ $ A.parseOnly (A.signed A.double) s
  parseJSON _ = fail "parse Service.Twilio.Types.USD"

instance ToJSON USD where
  toJSON (Cents p) =
    String $ T.pack $ printf "%.2f\n" (negate $ (/100) $ fromIntegral p :: Float)

instance FromJSON PhoneNumber where
  parseJSON (String s) = pure . fromString . T.unpack $ s
  parseJSON _ = fail "parse Service.Twilio.Types.PhoneNumber"

instance ToJSON PhoneNumber where
  toJSON (PhoneNumber p) =
    case B.length p of
         10 -> String (TE.decodeUtf8 $ "+1" <> p)
         11 -> String (TE.decodeUtf8 $ "+" <> p)
         _  -> String (TE.decodeUtf8 p)

versionFromString :: (Eq a, IsString a) => a -> Maybe APIVersion
versionFromString "2010-04-01" = Just Api20100401
versionFromString _            = Nothing

instance FromJSON APIVersion where
  parseJSON (String s) = justZ (versionFromString s)
  parseJSON _ = fail "parse Service.Twilio.Types.APIVersion"

instance ToJSON APIVersion where
  toJSON = String . fromString . show

instance FromJSON Id where
  parseJSON (Object o) =
    Id <$> o .: "account_sid"
       <*> o .: "api_version"
       <*> o .: "sid"
  parseJSON _ = fail "parse Service.Twilio.Types.Id"

-- | Creates a partial Twilio JSON object, reconstituting
-- "account_sid", "api_version", "sid", and "uri".
instance ToJSON Id where
  toJSON id@(Id { .. }) =
    object [ "account_sid" .= account,
             "api_version" .= version,
             "sid"         .= sid,
             "uri"         .= url ]
    where url = uri "SMS/Messages" id <> ".json"

instance FromFormUrlencoded Id where
  fromForm ps = do accountSid <- lookup "AccountSid" ps
                   sid        <- lookup "SmsSid" ps <|> lookup "CallSid" ps
                   -- This always fails through to the default
                   -- instance, but it's nice to have theoretically
                   version    <- (lookup "ApiVersion" ps >>= versionFromString)
                                 <|> Just def
                   return Id { version = version,
                               account = accountSid,
                               sid     = sid }

instance FromJSON APIKind where
  parseJSON (Object o) = do
    d <- o .: "direction" :: Parser String
    case d of
      "outbound-api"   -> pure API
      "outbound-reply" -> pure Reply
      _                -> fail "parse Service.Twilio.Types.APIKind"
  parseJSON _ = fail "parse Service.Twilio.Types.APIKind not an object"

instance ToJSON APIKind where
  toJSON API   = qo "direction" "outbound-api"
  toJSON Reply = qo "direction" "outbound-reply"
  
instance FromJSON SendStatus where
  parseJSON (Object o) = do
    d <- o .: "direction" :: Parser String
    case d of
      "outbound-api"   -> doStatus
      "outbound-reply" -> doStatus
      _                -> fail "parse Service.Twilio.Types.SendStatus"
      where doStatus =
              do status <- o .: "status" :: Parser String
                 case status of
                   "queued"  -> pure Queued
                   "sending" -> pure Sending
                   "failed"  -> pure Failed
                   "sent"    ->
                     Sent <$> fmap unTwilioTime (o .: "date_sent")
                          <*> fmap unCents (o .:  "price")
                   _         ->
                     fail "parse Service.Twilio.Types.SendStatus invalid status"
  parseJSON _ = fail "parse Service.Twilio.Types.SendStatus not an object"

instance ToJSON SendStatus where
  toJSON Queued  = qo "status" "queued"
  toJSON Sending = qo "status" "sending"
  toJSON Failed  = qo "status" "failed"
  toJSON (Sent ts price) = object [ "status"    .= T.pack "sent",
                                    "date_sent" .= TwilioTime ts,
                                    "price"     .= Cents price ]

instance FromJSON SMSKind where
  parseJSON obj@(Object o) = do
    direction   <- o .: "direction" :: Parser String
    case direction of
      "inbound" -> pure Inbound
      "outbound-reply" -> rest
      "outbound-api"   -> rest
      _                -> fail "parse Service.Twilio.Types.SMSKind invalid direction"
      where rest = Outbound <$> parseJSON obj <*> parseJSON obj
  parseJSON _ = fail "parse Service.Twilio.Types.SMSKind not an object"

instance ToJSON SMSKind where
  toJSON Inbound = qo "direction" "inbound"
  toJSON (Outbound api status) = concatObjects [ toJSON api, toJSON status ]

instance FromJSON SMSCore where
  parseJSON (Object o) = do
    to          <- o .: "to"
    from        <- o .: "from"
    body        <- o .: "body"
    return SMSCore { to = to, from = from, body = body }
  parseJSON _ = fail "parse Service.Twilio.Types.SMSCore"

instance ToJSON SMSCore where
  toJSON (SMSCore { ..}) =
    object [ "to" .= to, "from" .= from, "body" .= body ]

instance FromFormUrlencoded SMSCore where
  fromForm ps = do to   <- lookup "To" ps
                   from <- lookup "From" ps
                   body <- lookup "Body" ps
                   return SMSCore { to   = fromString $ BC.unpack to,
                                    from = fromString $ BC.unpack from,
                                    body = TE.decodeUtf8 body }

instance FromJSON SMS where
  parseJSON obj@(Object o) = do
    id          <- parseJSON obj
    kind        <- parseJSON obj
    core        <- parseJSON obj
    dateCreated <- o .: "date_created"
    dateUpdated <- o .: "date_updated"

    return SMS { id   = id,
                 kind = kind,
                 core = core,
                 dateCreated = unTwilioTime dateCreated,
                 dateUpdated = unTwilioTime dateUpdated }
  parseJSON _ = fail "parse Service.Twilio.Types.SMS not an object"

instance ToJSON SMS where
  toJSON (SMS { .. }) =
    concatObjects [ toJSON id,
                    toJSON kind,
                    toJSON core,
                    object ["date_created" .= TwilioTime dateCreated],
                    object ["date_updated" .= TwilioTime dateUpdated]
                  ]

-- Arbitrary Instances

-- | A helper for generating arbitrary base-16 strings
genB16 :: Int -> Gen ByteString
genB16 = fmap (B16.encode . B.pack) . vector

instance Arbitrary Passport where
  arbitrary = do
    sid <- genB16 16
    token <- genB16 16
    return $ Passport ("AC" <> sid) token

-- | Creates arbitrary US-based phone number with country codes
instance Arbitrary PhoneNumber where
  arbitrary = fmap (fromString . ('1':) . map intToDigit)
                   (vectorOf 9 $ choose (0,9))

-- | Here we assume that SMS always cost 10 cents
instance Arbitrary SendStatus where
  arbitrary = oneof [pure Queued,
                     pure Sending,
                     Sent <$> arbitrary <*> pure 10,
                     pure Failed]

instance Arbitrary APIKind where
  arbitrary = elements [ API, Reply ]

instance Arbitrary SMSKind where
  arbitrary = oneof [ Outbound <$> arbitrary <*> arbitrary,
                      pure Inbound ]

-- | Assumes the API version is fixed as "2010-04-01"
instance Arbitrary Id where
  arbitrary = do
    acct <- fmap ("AC" <>) (genB16 16)
    sid  <- fmap ("SM" <>) (genB16 16)
    return Id { account = acct,
                sid     = sid,
                version = Api20100401 }

-- | The random body generation should create semi-meaningful body
-- text obeying the size limit.
instance Arbitrary SMSCore where
  arbitrary =
    do body <- fmap T.pack arbitraryBody
       to   <- arbitrary
       from <- arbitrary
       return SMSCore { to = to, from = from, body = body }
    where
      arbitraryBody = do n <- choose (20, 160)
                         vectorOf n $ frequency [(1, pure ' '),
                                                 (3, choose ('a', 'z'))]


-- | Creates an arbitrary SMS guaranteeing that the 'dateCreated' and
-- 'dateUpdated' and 'dateSent' fields are sensible.
instance Arbitrary SMS where
  arbitrary = do
    dateCreated  <- arbitrary
    dateUpdated  <- arbitrary `suchThat` (> dateCreated)
    kind         <- fmap (mkValidDate dateUpdated) arbitrary
    core         <- arbitrary
    id           <- arbitrary
    return SMS { kind = kind, core = core, id = id,
                 dateCreated = dateCreated,
                 dateUpdated = dateUpdated }
    where mkValidDate d (Outbound a (Sent _ p)) =
            Outbound a (Sent d p)
          mkValidDate _ k = k