{-# LANGUAGE ConstrainedClassMethods #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE DuplicateRecordFields #-} module OpenTok.Client ( Client(Client, _apiKey, _secret) , ClientError(statusCode, message) , Path , post , postWithBody , get , del ) where import Prelude ( ) import Prelude.Compat import Control.Arrow ( left ) import Control.Lens.Combinators import Control.Lens.Operators import Control.Monad.Time import Control.Monad.Trans.Except import Crypto.JWT import Data.Either.Combinators ( mapLeft ) import Data.List ( isInfixOf ) import Data.Semigroup ( (<>) ) import Data.UUID ( toText ) import Data.UUID.V4 import Data.Aeson ( decode , encode , eitherDecode , FromJSON , ToJSON ) import Data.Aeson.Types import qualified Data.ByteString.Char8 as C8 ( pack ) import Data.ByteString.Lazy ( toStrict ) import Data.HashMap.Strict as HM import Data.Time.Clock import GHC.Generics ( Generic ) import Network.HTTP.Client hiding ( responseStatus ) import Network.HTTP.Client.TLS ( tlsManagerSettings ) import Network.HTTP.Simple ( getResponseStatusCode ) import Network.HTTP.Types.Header ( RequestHeaders ) import OpenTok.Util type Path = String data APIError = APIError { _code :: Int, _status :: Maybe String, _message :: String } deriving (Generic, Show) instance FromJSON APIError where parseJSON = genericParseJSON $ defaultOptions { omitNothingFields = True, fieldLabelModifier = drop 1 } data ClientError = ClientError { statusCode :: Int, message :: String } deriving (Generic, Show) jwtError :: ClientError jwtError = ClientError 0 "Failed to create JWT" decodeError :: Int -> ClientError decodeError sc = ClientError sc "Failed to decode API response" expireTime :: UTCTime -> UTCTime expireTime t = epochToUTC $ utcToEpoch t + 60 data Client = Client { _apiKey :: String, _secret :: String } -- | HTTP Verbs data Method = GET | POST | DELETE deriving (Show) -- | Create claims for a JWT mkClaims :: String -> IO ClaimsSet mkClaims projectKey = do now <- currentTime uuid <- nextRandom let now' = epochToUTC $ utcToEpoch now let later = expireTime now pure $ emptyClaimsSet & claimIss .~ preview stringOrUri (projectKey :: String) & claimIat ?~ NumericDate now' & claimExp ?~ NumericDate later & claimJti ?~ toText uuid & unregisteredClaims %~ HM.insert "ist" "project" -- | Sign JWT claims signJWT :: JWK -> ClaimsSet -> IO (Either JWTError SignedJWT) signJWT key claims = runExceptT $ signClaims key (newJWSHeader ((), HS256)) claims createJWT :: Client -> IO (Either ClientError SignedJWT) createJWT client = do claims <- mkClaims (_apiKey client) let key = fromOctets (C8.pack $ _secret client) eitherSigned <- signJWT key claims pure $ mapLeft (const jwtError) eitherSigned -- | Build headers for a Request buildHeaders :: SignedJWT -> RequestHeaders buildHeaders jwt = [ ("Content-Type" , "application/json") , ("Accept" , "application/json") , ("X-OPENTOK-AUTH", toStrict $ encodeCompact jwt) ] -- | Build headers for a Request to an API v1 endpoint (without Content-Type) buildV1Headers :: SignedJWT -> RequestHeaders buildV1Headers jwt = drop 1 $ buildHeaders jwt -- | Execute an API request execute :: (FromJSON a) => Request -> IO (Either ClientError a) execute req = do manager <- newManager tlsManagerSettings response <- httpLbs req manager let body = responseBody response let sc = getResponseStatusCode response case sc of 200 -> pure $ left (\m -> (decodeError sc) { message = m }) (eitherDecode body) _ -> pure $ Left $ maybe (decodeError sc) (ClientError sc . _message) (decode body :: Maybe APIError) -- | Execute an API request to delete a resource deleteResource :: Request -> IO (Either ClientError String) deleteResource req = do manager <- newManager tlsManagerSettings response <- httpLbs req manager let sc = getResponseStatusCode response case sc of 204 -> pure $ Right "Ok" _ -> pure $ Left $ ClientError sc "Failed to delete resource" buildRequest :: Method -> Path -> SignedJWT -> IO Request buildRequest m p jwt = do initialRequest <- parseRequest $ "https://api.opentok.com" <> p let buildFn = if "v2" `isInfixOf` p then buildHeaders else buildV1Headers pure $ initialRequest { method = C8.pack $ show m, requestHeaders = buildFn jwt } -- | Make a POST request post :: (FromJSON a) => Client -> Path -> IO (Either ClientError a) post client p = do eitherJWT <- createJWT client case eitherJWT of Left e -> pure $ Left e Right jwt -> do request <- buildRequest POST p jwt execute request -- | Make a POST request with a body postWithBody :: (ToJSON a, FromJSON b) => Client -> Path -> a -> IO (Either ClientError b) postWithBody client p b = do eitherJWT <- createJWT client case eitherJWT of Left e -> pure $ Left $ e Right jwt -> do request <- buildRequest POST p jwt execute $ request { requestBody = RequestBodyLBS $ encode b } -- | Make a GET request get :: (FromJSON a) => Client -> Path -> IO (Either ClientError a) get client p = do eitherJWT <- createJWT client case eitherJWT of Left e -> pure $ Left $ e Right jwt -> do request <- buildRequest GET p jwt execute $ request -- | Make a DELETE request del :: Client -> Path -> IO (Either ClientError String) del client p = do eitherJWT <- createJWT client case eitherJWT of Left e -> pure $ Left $ e Right jwt -> do request <- buildRequest DELETE p jwt deleteResource $ request