packages feed

pantry-0.9.3: src/Hackage/Security/Client/Repository/HttpLib/HttpClient.hs

-- Explicitly disabling due to external code {-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE FlexibleContexts    #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE RankNTypes          #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- Adapted from `hackage-security-http-client` to use our own
-- `Pantry.HTTP` implementation
module Hackage.Security.Client.Repository.HttpLib.HttpClient
  ( httpLib
  ) where

import           Control.Exception ( handle )
import           Control.Monad ( void )
import           Data.ByteString ( ByteString )
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS.C8
import           Hackage.Security.Client ( SomeRemoteError (..) )
import           Hackage.Security.Client.Repository.HttpLib
                   ( BodyReader, HttpLib (..), HttpRequestHeader (..)
                   , HttpResponseHeader (..), HttpStatus (..)
                   )
import           Hackage.Security.Util.Checked
                   ( Throws, handleChecked, throwChecked )
import           Network.URI ( URI )
import qualified Pantry.HTTP as HTTP

{-------------------------------------------------------------------------------
  Top-level API
-------------------------------------------------------------------------------}

-- | An 'HttpLib' value using the default global manager
httpLib :: HttpLib
httpLib = HttpLib
  { httpGet      = get
  , httpGetRange = getRange
  }

{-------------------------------------------------------------------------------
  Individual methods
-------------------------------------------------------------------------------}

get ::
     Throws SomeRemoteError
  => [HttpRequestHeader] -> URI
  -> ([HttpResponseHeader] -> BodyReader -> IO a)
  -> IO a
get reqHeaders uri callback = wrapCustomEx $ do
  -- TODO: setUri fails under certain circumstances; in particular, when
  -- the URI contains URL auth. Not sure if this is a concern.
  request' <- HTTP.setUri HTTP.defaultRequest uri
  let request = setRequestHeaders reqHeaders request'
  checkHttpException $ HTTP.withResponse request $ \response -> do
    let br = wrapCustomEx $ HTTP.getResponseBody response
    callback (getResponseHeaders response) br

getRange ::
     Throws SomeRemoteError
  => [HttpRequestHeader]
  -> URI
  -> (Int, Int)
  -> (HttpStatus -> [HttpResponseHeader] -> BodyReader -> IO a)
  -> IO a
getRange reqHeaders uri (from, to) callback = wrapCustomEx $ do
  request' <- HTTP.setUri HTTP.defaultRequest uri
  let request = setRange from to
              $ setRequestHeaders reqHeaders request'
  checkHttpException $ HTTP.withResponse request $ \response -> do
    let br = wrapCustomEx $ HTTP.getResponseBody response
    case () of
      () | HTTP.getResponseStatus response == HTTP.partialContent206 ->
        callback HttpStatus206PartialContent (getResponseHeaders response) br
      () | HTTP.getResponseStatus response == HTTP.ok200 ->
        callback HttpStatus200OK (getResponseHeaders response) br
      _otherwise ->
        throwChecked $ HTTP.HttpExceptionRequest request
                     $ HTTP.StatusCodeException (void response) ""

-- | Wrap custom exceptions
--
-- NOTE: The only other exception defined in @http-client@ is @TimeoutTriggered@
-- but it is currently disabled <https://github.com/snoyberg/http-client/issues/116>
wrapCustomEx ::
     (Throws HTTP.HttpException => IO a)
  -> (Throws SomeRemoteError => IO a)
wrapCustomEx = handleChecked (\(ex :: HTTP.HttpException) -> go ex)
 where
  go ex = throwChecked (SomeRemoteError ex)

checkHttpException :: Throws HTTP.HttpException => IO a -> IO a
checkHttpException = handle $ \(ex :: HTTP.HttpException) ->
                       throwChecked ex

{-------------------------------------------------------------------------------
  http-client auxiliary
-------------------------------------------------------------------------------}

hAcceptRanges :: HTTP.HeaderName
hAcceptRanges = "Accept-Ranges"

hAcceptEncoding :: HTTP.HeaderName
hAcceptEncoding = "Accept-Encoding"

setRange ::
     Int
  -> Int
  -> HTTP.Request
  -> HTTP.Request
setRange from to =
  HTTP.addRequestHeader HTTP.hRange rangeHeader
 where
  -- Content-Range header uses inclusive rather than exclusive bounds
  -- See <http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html>
  rangeHeader = BS.C8.pack $ "bytes=" ++ show from ++ "-" ++ show (to - 1)

-- | Set request headers
setRequestHeaders ::
     [HttpRequestHeader]
  -> HTTP.Request
  -> HTTP.Request
setRequestHeaders opts =
  setRequestHeaders' (trOpt disallowCompressionByDefault opts)
 where
  setRequestHeaders' :: [HTTP.Header] -> HTTP.Request -> HTTP.Request
  setRequestHeaders' =
    foldr (\(name, val) f -> f . HTTP.setRequestHeader name [val]) id

  trOpt ::
       [(HTTP.HeaderName, [ByteString])]
    -> [HttpRequestHeader]
    -> [HTTP.Header]
  trOpt acc [] =
    map finalizeHeader acc
  trOpt acc (HttpRequestMaxAge0:os) =
    trOpt (insert HTTP.hCacheControl ["max-age=0"] acc) os
  trOpt acc (HttpRequestNoTransform:os) =
    trOpt (insert HTTP.hCacheControl ["no-transform"] acc) os

  -- disable content compression (potential security issue)
  disallowCompressionByDefault :: [(HTTP.HeaderName, [ByteString])]
  disallowCompressionByDefault = [(hAcceptEncoding, [])]

  -- Some headers are comma-separated, others need multiple headers for
  -- multiple options.
  --
  -- TODO: Right we we just comma-separate all of them.
  finalizeHeader ::
       (HTTP.HeaderName, [ByteString])
    -> HTTP.Header
  finalizeHeader (name, strs) = (name, BS.intercalate ", " (reverse strs))

  insert :: Eq a => a -> [b] -> [(a, [b])] -> [(a, [b])]
  insert _ _ [] = []
  insert x y ((k, v):pairs)
    | x == k = (k, v ++ y) : insert x y pairs
    | otherwise = (k, v) : insert x y pairs

-- | Extract the response headers
getResponseHeaders :: HTTP.Response a -> [HttpResponseHeader]
getResponseHeaders response =
  [ HttpResponseAcceptRangesBytes | (hAcceptRanges, "bytes") `elem` headers ]
 where
  headers = HTTP.getResponseHeaders response