packages feed

medium-sdk-haskell-0.1.0.0: src/MediumAPI.hs

{-# LANGUAGE DataKinds                                                     #-}
{-# LANGUAGE StandaloneDeriving                                            #-}
{-# LANGUAGE DeriveGeneric                                                 #-}
{-# LANGUAGE OverloadedStrings                                             #-}
{-# LANGUAGE RecordWildCards                                               #-}
{-# LANGUAGE TypeFamilies                                                  #-}
{-# LANGUAGE TypeOperators                                                 #-}

module MediumAPI where

import           Control.Applicative
import           Control.Monad.Trans.Either
import           Data.Aeson
import           Data.ByteString                           (intercalate)
import           Data.Default.Class
import           Data.Monoid
import           Data.Proxy
import           Data.String
import           Data.Text                                 (Text)
import           Data.Text.Encoding                        (encodeUtf8)
import           Data.Time.Clock
import           Data.Time.Clock.POSIX
import           GHC.Generics

import           Network.HTTP.Client                       hiding (Proxy)

import           Servant
import           Servant.Client


type API = "v1" :> "me" :> Header "Authorization" Token :> Get '[JSON] User
         :<|> "v1" :> "users" :> Capture "authorId" Text :> "posts" :> Header "Authorization" Token :> ReqBody '[JSON] NewPost :> Post '[JSON] CreatedPost
         :<|> "v1" :> "tokens" :> ReqBody '[FormUrlEncoded] TokenRequest :> Post '[JSON] TokenResp
         :<|> "v1" :> "tokens" :> Header "Authorization" Token :> ReqBody '[FormUrlEncoded] RefreshRequest :> Post '[JSON] TokenResp

data TokenRequest = TokenRequest { authCode :: Text
                                 , clientId      :: Text
                                 , clientSecret  :: Text
                                 , redirectUri   :: Text
                                 }

instance ToFormUrlEncoded TokenRequest where
    toFormUrlEncoded TokenRequest{..} = [ ("code", authCode)
                                        , ("client_id", clientId)
                                        , ("client_secret", clientSecret)
                                        , ("grant_type", "authorization_code")
                                        , ("redirect_uri", redirectUri)
                                        ]

newtype Token = Token { token :: Text } deriving (Show, Read, Eq)

instance ToText Token where
    toText Token{..} = "Bearer " <> token

data ContentFormat = Html | Markdown
                   deriving (Show, Read, Eq, Generic)

instance ToJSON ContentFormat where
    toJSON Html     = "html"
    toJSON Markdown = "markdown"

instance FromJSON ContentFormat where
    parseJSON (String "html")     = return Html
    parseJSON (String "markdown") = return Markdown
    parseJSON _                   =
      error "Expected valid string value for content format"

data PublishStatus = Public | Draft | Unlisted
                   deriving (Show, Read, Eq, Generic)

instance ToJSON PublishStatus where
    toJSON Public   = "public"
    toJSON Draft    = "draft"
    toJSON Unlisted = "unlisted"

instance FromJSON PublishStatus where
    parseJSON (String "public")   = return Public
    parseJSON (String "draft")    = return Draft
    parseJSON (String "unlisted") = return Unlisted
    parseJSON _                   =
      error "Expected valid string value for publish status"

data License = AllRightsReserved | Cc40By | Cc40BySa | Cc40ByNd | Cc40ByNc | Cc40ByNcNd | Cc40ByNcSa | Cc40Zero | PublicDomain
             deriving (Show, Read, Eq, Generic)

instance ToJSON License where
    toJSON AllRightsReserved = "all-rights-reserved"
    toJSON Cc40By            = "cc-40-by"
    toJSON Cc40BySa          = "cc-40-by-sa"
    toJSON Cc40ByNd          = "cc-40-by-nd"
    toJSON Cc40ByNc          = "cc-40-by-nc"
    toJSON Cc40ByNcNd        = "cc-40-by-nc-nd"
    toJSON Cc40ByNcSa        = "cc-40-by-nc-sa"
    toJSON Cc40Zero          = "cc-40-zero"
    toJSON PublicDomain      = "public-domain"

instance FromJSON License where
    parseJSON (String "all-rights-reserved") = return AllRightsReserved
    parseJSON (String "cc-40-by")            = return Cc40By
    parseJSON (String "cc-40-by-sa")         = return Cc40BySa
    parseJSON (String "cc-40-by-nd")         = return Cc40ByNd
    parseJSON (String "cc-40-by-nc")         = return Cc40ByNc
    parseJSON (String "cc-40-by-nc-nd")      = return Cc40ByNcNd
    parseJSON (String "cc-40-by-nc-sa")      = return Cc40ByNcSa
    parseJSON (String "cc-40-zero")          = return Cc40Zero
    parseJSON (String "public-domain")       = return PublicDomain
    parseJSON _                              =
      error "Expected valid string value for license"

data User = User { id       :: Text
                 , username :: Text
                 , name     :: Text
                 , url      :: Text
                 , imageUrl :: Text
                 }
                 deriving (Show, Read, Eq, Generic)

instance FromJSON User where
    parseJSON (Object o) = do
      Object o' <- o .: "data"
      User <$> o' .: "id"
           <*> o' .: "username"
           <*> o' .: "name"
           <*> o' .: "url"
           <*> o' .: "imageUrl"
    parseJSON _          = error "Expected an object"

data NewPost = NewPost { title         :: Text
                       , contentFormat :: ContentFormat
                       , content       :: Text
                       , tags          :: [Text]
                       , canonicalUrl  :: Maybe Text
                       , publishStatus :: PublishStatus
                       , license       :: License
                       }
                       deriving (Show, Read, Eq, Generic)

instance ToJSON NewPost


data CreatedPost = CreatedPost { postId            :: Text
                               , postTitle         :: Text
                               , authorId          :: Text
                               , postTags          :: [Text]
                               , mediumUrl         :: Text
                               , postCanonicalUrl  :: Maybe Text
                               , postPublishStatus :: PublishStatus
                               , publishedAt       :: Maybe Integer
                               , postLicense       :: License
                               , licenseUrl        :: Text
                               }
                               deriving (Show, Read, Eq, Generic)

instance FromJSON CreatedPost where
    parseJSON (Object o) = do
      Object o' <- o .: "data"
      CreatedPost <$> o' .:  "id"
               <*> o' .:  "title"
               <*> o' .:  "authorId"
               <*> o' .:  "tags"
               <*> o' .:  "url"
               <*> o' .:? "canonicalUrl"
               <*> o' .:  "publishStatus"
               <*> o' .:? "publishedAt"
               <*> o' .:  "license"
               <*> o' .:  "licenseUrl"
    parseJSON _          = error "Expected an object"

data Scope = BasicProfile | PublishPost | UploadImage deriving (Show, Read, Eq)

scopeString :: IsString a => Scope -> a
scopeString BasicProfile = "basicProfile"
scopeString PublishPost  = "publishPost"
scopeString UploadImage  = "uploadImage"

instance ToJSON Scope where
    toJSON = scopeString

instance FromJSON Scope where
    parseJSON "basicProfile" = return BasicProfile
    parseJSON "publishPost"  = return PublishPost
    parseJSON "uploadImage"  = return UploadImage
    parseJSON _              = error "Invalid scope value"

data TokenResp = TokenResp { tokenType    :: Text
                           , accessToken  :: Text
                           , refreshToken :: Text
                           , scope        :: [Scope]
                           , expiresAt    :: UTCTime
                           }
                           deriving (Show, Eq)

instance FromJSON TokenResp where
    parseJSON (Object o) = TokenResp "Bearer"
                                     <$> o .: "access_token"
                                     <*> o .: "refresh_token"
                                     <*> o .: "scope"
                                     <*> (milliToUtc <$> o .: "expires_at")
    parseJSON _          = error "Expected an object"

data RefreshRequest = RefreshRequest { refreshToken' :: Text
                                     , clientId'     :: Text
                                     , clientSecret' :: Text
                                     }
                                     deriving (Show, Read, Eq)

instance ToFormUrlEncoded RefreshRequest where
    toFormUrlEncoded RefreshRequest{..} = [ ("refresh_token", refreshToken')
                                          , ("client_id",     clientId')
                                          , ("client_secret", clientSecret')
                                          , ("grant_type",    "refresh_token")
                                          ]


milliToUtc :: POSIXTime -> UTCTime
milliToUtc = posixSecondsToUTCTime . (/ 1000)

defaultPost :: NewPost
defaultPost = NewPost "" Html "" [] Nothing Public AllRightsReserved

baseUrl :: BaseUrl
baseUrl = BaseUrl Https "api.medium.com" 443

-- TODO: scope list should be non-empty
authCodeUrl :: Text -> [Scope] -> Text -> Text -> String
authCodeUrl clientId requestedScope stateText redirectUrl =
    show . getUri $
      setQueryString [ ("client_id",    Just $ encodeUtf8 clientId)
                     , ("state",        Just $ encodeUtf8 stateText)
                     , ("redirect_uri", Just $ encodeUtf8 redirectUrl)
                     , ("scope",        Just scopeList)
                     , ("responseType", Just "code")
                     ]
                     def { host = "medium.com"
                         , port = 443
                         , secure = True
                         , path = "/m/oauth/authorize"
                         }
  where scopeList = intercalate "," $ map scopeString requestedScope

type EitherIO x = EitherT x IO

me ::  Maybe Token -> EitherIO ServantError User
posts :: Text -> Maybe Token -> NewPost -> EitherIO ServantError CreatedPost
tokenFromAuthCode :: TokenRequest -> EitherIO ServantError TokenResp
refreshAuthToken :: Maybe Token -> RefreshRequest -> EitherIO ServantError TokenResp

api :: Proxy API
api = Proxy

me :<|> posts :<|> tokenFromAuthCode :<|> refreshAuthToken = client api baseUrl