packages feed

curl-aeson-0.1.0.2: src/Network/Curl/Aeson.hs

{-# LANGUAGE CPP, RecordWildCards #-}
-- |
-- Module    : Network.Curl.Aeson
-- Copyright : (c) 2013-2022, Joel Lehtonen
-- License   : BSD3
--
-- Maintainer: Joel Lehtonen <joel.lehtonen+curlaeson@iki.fi>
-- Stability : experimental
-- Portability: portable
--
-- Functions for communicating with JSON over HTTP, HTTPS, or any
-- protocol supported by [cURL](https://curl.se/).

module Network.Curl.Aeson
       ( -- * How to use this library
         -- $use
         
         -- * cURL requests with JSON payload and response
         curlAesonGet
       , curlAesonGetWith
       , curlAesonCustom
       , curlAesonCustomWith
         -- * Generic cURL request
       , curlAesonRaw
         -- * Helpers for working with raw requests
       , jsonPayload
       , binaryPayload
       , binaryResponse
       , valueResponse
       , jsonResponse
         -- * Other helper functions
       , cookie
       , rawJson
       , (...)
       , noData
         -- * Types
       , Payload(..)
       , ResponseParser
         -- * Exception handling
       , CurlAesonException(..)
         -- * Deprecated functions
       , curlAeson
       ) where

import Control.Exception
import Control.Monad
import Data.Aeson
import Data.Aeson.Types
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as B
import Data.Maybe
import Data.Text (Text)
import Data.Typeable
import Network.Curl
import Network.Curl.Aeson.Internal

#if !MIN_VERSION_aeson(2,0,0)
-- Backwards compatibility with older versions of aeson library
type Key = Text
#endif

-- | Shorthand for doing just a HTTP GET request and parsing the output to
-- any 'FromJSON' instance.
curlAesonGet :: (FromJSON a)
  => URLString -- ^ Request URL
  -> IO a      -- ^ Received and parsed data
curlAesonGet url = curlAesonCustom [] "GET" url noData

-- | Shorthand for doing just a HTTP GET request and parsing the
-- output with given parser /p/.
curlAesonGetWith
  :: (Value -> Parser a) -- ^Aeson parser for response. Use 'pure' if
                         -- you want it in AST format.
  -> URLString           -- ^Request URL
  -> IO a                -- ^Received and parsed data
curlAesonGetWith p url = curlAesonCustomWith p [] "GET" url noData

-- | Send a single HTTP request and a custom parser.
-- 
-- The request automatically has @Content-type: application/json@
-- header if you pass any data. This function is lenient on response
-- content type; everything is accepted as long as it is valid JSON
-- and parseable with your supplied parser.
-- 
-- If you need authentication, you need to pass session cookie or
-- other means of authentication tokens via 'CurlOption' list.
curlAesonCustomWith
  :: (ToJSON a)
  => (Value -> Parser b) -- ^ Aeson parser for response. Use
                         -- 'pure' if you want it in AST format.
  -> [CurlOption]        -- ^ Session cookies, or other cURL
                         -- options. Use 'mempty' if you don't need
                         -- any.
  -> String              -- ^ Request method
  -> URLString           -- ^ Request URL
  -> Maybe a             -- ^ JSON data to send, or 'Nothing' when
                         -- sending request without any content.
  -> IO b                -- ^ Received and parsed data
curlAesonCustomWith parser opts method url maybeValue =
  curlAesonRaw
  (\x -> eitherDecode x >>= parseEither parser)
  opts method url
  (maybeValue >>= jsonPayload)

{-# DEPRECATED curlAeson "Use customAesonCustomWith instead" #-}
-- |See type of 'curlAesonCustomWith'.
curlAeson :: ToJSON a => (Value -> Parser b) -> String -> URLString -> [CurlOption] -> Maybe a -> IO b
curlAeson parser method url opts = curlAesonCustomWith parser opts method url

-- | Send a single cURL request.
--
-- The request automatically has @Content-type: application/json@
-- header if you pass any data. This function is lenient on response
-- content type; everything is accepted as long as 'parseJSON'
-- succeeds.
--
-- If you need authentication, you need to pass session cookie or
-- other means of authentication tokens via 'CurlOption' list.
curlAesonCustom ::
  (ToJSON a, FromJSON b)
  => [CurlOption]        -- ^ Session cookies, or other cURL
                         -- options. Use 'mempty' if you don't need
                         -- any.
  -> String              -- ^ Request method
  -> URLString           -- ^ Request URL
  -> Maybe a             -- ^ JSON data to send, or 'Nothing' when
                         -- sending request without any content.
  -> IO b                -- ^ Received and parsed data
curlAesonCustom opts method url maybeValue =
  curlAesonRaw eitherDecode opts method url
  (maybeValue >>= jsonPayload)

-- |The most flexible function we have here.
--
-- Sends raw cURL request with a possible payload and collects the
-- output. This function is binary safe so it works with other media
-- types than JSON and supports binary upload and JSON response
-- (common case with media uploads).
--
-- When /payload/ is given, cURL options 'CurlReadFunction',
-- 'CurlUpload' and 'CurlInFileSizeLarge' are set and HTTP header
-- Content-Type is appended implicitly.
curlAesonRaw
  :: ResponseParser a    -- ^ Parser function for the response such as 'eitherDecode'
  -> [CurlOption]        -- ^ Extra curl options.
  -> String              -- ^ Request method
  -> URLString           -- ^ Request URL
  -> Maybe Payload       -- ^ Request body payload, if any.
  -> IO a                -- ^ Received and parsed data
curlAesonRaw parser userOpts method url maybePayload = do
  -- Prepare the upload
  putOpts <- case maybePayload of
    Nothing -> pure userOpts
    Just Payload{..} -> do
      readFunc <- mkReadFunctionLazy payload
      pure $ CurlReadFunction readFunc :
             CurlUpload True :
             CurlInFileSizeLarge (fromIntegral $ B.length payload) :
             mergeHeaders [ "Content-Type: " <> contentType ] userOpts
  -- Add request method
  let curlOpts = CurlCustomRequest method : putOpts
  -- Perform the request
  (curlCode, received) <- curlGetString_ url curlOpts
  when (curlCode /= CurlOK) $
    throwIO CurlAesonException{parseError = Nothing, ..}
  -- Trying to parse
  case parser received of
    Left e  -> throwIO CurlAesonException{parseError = Just e, ..}
    Right x -> pure x

-- |Internal tool to merge headers in a cURL option list
mergeHeaders :: [String] -> [CurlOption] -> [CurlOption]
mergeHeaders acc [] = [CurlHttpHeaders acc]
mergeHeaders acc ((CurlHttpHeaders x):xs) = mergeHeaders (acc <> x) xs
mergeHeaders acc (x:xs) = x:mergeHeaders acc xs 

-- |Convert a value to JSON payload. This never returns Nothing.
jsonPayload :: ToJSON a => a -> Maybe Payload
jsonPayload a = Just Payload{..}
  where payload = encode a
        contentType = "application/json"

-- |Just a shortcut for defining binary payloads of given media
-- type. This never returns Nothing.
binaryPayload :: String        -- ^Media type (MIME)
              -> ByteString    -- ^Data
              -> Maybe Payload -- ^Payload
binaryPayload a b = Just $ Payload a b

-- | Helper function for writing parsers for JSON objects which are
-- not needed to be parsed completely.
--
-- In this example we are parsing JSON from
-- <http://json.org/example.html>.  Note the use of the
-- @OverloadedStrings@ language extension which enables 'Key' values
-- to be written as string literals.
--
-- @p ('Data.Aeson.Types.Internal.Object' o) = 'pure' o'...'\"glossary\"'...'\"title\"
--p _ = 'mzero'
-- @
(...) :: FromJSON b
         => Parser Object -- ^ Parser to JSON object to look into
         -> Key           -- ^ Key to look for
         -> Parser b      -- ^ Parser to the resulting field
(...) p s = do
  o <- p
  o .: s

-- Precedence should be higher than >> and >>= but lower than ++
infixl 4 ...

-- | Single cookie of given key and value.
cookie :: String -> String -> CurlOption
cookie key value = CurlCookie $ key <> "=" <> value

-- | Useful for just giving the JSON as string when it is static
-- anyway and doesn't need to be programmatically crafted.
rawJson :: ByteString -> Maybe Value
rawJson = decode

-- |Useful with 'curlAesonRaw' when you just need to take the binary output.
binaryResponse :: ResponseParser ByteString
binaryResponse = Right

-- |Useful with 'curlAesonRaw' when you just want to get the JSON Value
valueResponse :: ResponseParser Value
valueResponse = eitherDecode

-- |Just a friendly name for 'eitherDecode'. Can be used with
-- 'curlAesonRaw' for deserializing the response using 'FromJSON'
-- instance.
jsonResponse :: FromJSON a => ResponseParser a
jsonResponse = eitherDecode

-- |To avoid type ambiguity you may pass this value instead
-- of Nothing to 'curlAesonCustom'.
noData :: Maybe Value
noData = Nothing

-- |Holds the payload for raw sender.
data Payload = Payload
  { contentType :: String      -- ^Content media type (MIME)
  , payload     :: ByteString  -- ^Data
  } deriving (Show)

-- | This exception is is thrown when Curl doesn't finish cleanly or
-- the parsing of JSON response fails.
data CurlAesonException = CurlAesonException
  { url        :: URLString    -- ^The request URI
  , curlCode   :: CurlCode     -- ^Curl return code
  , curlOpts   :: [CurlOption] -- ^Curl options set
  , received   :: ByteString   -- ^Received raw data from the
                               -- server. Before version 0.1 the type
                               -- was 'Prelude.String'.
  , parseError :: Maybe String -- ^Parse error, if it failed during parse.
  } deriving (Show)

instance Exception CurlAesonException

-- |Parser type from response to your data. Normally: 'eitherDecode'
type ResponseParser a = ByteString -> Either String a

-- $use
--
-- Let\'s simulate a service by creating a file @\/tmp\/ticker.json@
-- with the following content:
--
-- > {"bid":3,"ask":3.14}
--
-- This example shows how to hand-craft the parser for the bid and ask
-- values:
--
-- > {-# LANGUAGE OverloadedStrings #-}
-- > import Control.Monad
-- > import Data.Aeson
-- > import Network.Curl.Aeson
-- >
-- > ticker :: IO (Double,Double)
-- > ticker = curlAesonGetWith p "file:///tmp/ticker.json"
-- >   where
-- >     p (Object o) = do
-- >       bid <- o .: "bid"
-- >       ask <- o .: "ask"
-- >       return (bid,ask)
-- >     p _ = mzero
--
-- The same as above, but we define our own data type which is an
-- instance of 'FromJSON':
--
-- > {-# LANGUAGE DeriveGeneric #-}
-- > import GHC.Generics
-- > import Data.Aeson
-- > import Network.Curl.Aeson
-- >
-- > data Ticker = Ticker { bid :: Double
-- >                      , ask :: Double
-- >                      } deriving (Generic, Show)
-- >
-- > instance FromJSON Ticker
-- >
-- > ticker :: IO Ticker
-- > ticker = curlAesonGet "file:///tmp/ticker.json"