dhall-haskell/dhall/src/Dhall/Import/HTTP.hs
Frederik Ramcke fd8683216d Fix tests without with-http flag (#1159)
* Allow customization of remote import resolution

Makes the `Status` type more general; previously support for
`Network.HTTP.Client` was hardcoded. In short:

```
data Status = Status
    { _stack :: NonEmpty Chained
    [...]
--  , _manager :: Maybe Dynamic
--  --   importing the same expression twice with different values
++  , _remote :: URL -> StateT Status IO Data.Text.Text
++  -- ^ The remote resolver, fetches the content at the given URL.

    [...]
    }

```

* Simplify and expose `toHeaders`

`toHeaders` will be needed for mock http testing

* Fix compilation without `with-http` flag

* Fix compilation with `with-http` flag

* Fix tests without `with-http` flag

Implements a mock http client that handles requests to:
- `https://raw.githubusercontent.com/dhall-lang/dhall-lang/master/`
- `https://test.dhall-lang.org/Bool/package.dhall`
- `https://httpbin.org/user-agent`

This allows tests involving remote imports to succeed even when compiled
without the `with-http` flag.

* Build `dhall` with HTTP support compiled out in CI

... to prevent regressions from occurring in the future
2019-07-27 02:59:25 +00:00

283 lines
8.8 KiB
Haskell

{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Dhall.Import.HTTP where
import Control.Exception (Exception)
import Control.Monad.IO.Class (MonadIO(..))
import Control.Monad.Trans.State.Strict (StateT)
import Data.ByteString (ByteString)
import Data.CaseInsensitive (CI)
import Data.Dynamic (toDyn)
import Data.Semigroup ((<>))
import Data.Text (Text)
import Dhall.Core
( Directory(..)
, File(..)
, Import(..)
, ImportHashed(..)
, ImportType(..)
, Scheme(..)
, URL(..)
)
import qualified Control.Monad.Trans.State.Strict as State
import qualified Data.Text as Text
import qualified Data.Text.Encoding
import qualified Dhall.Util
import qualified Network.URI.Encode as URI.Encode
import Dhall.Import.Types
import qualified Control.Exception
#ifdef __GHCJS__
import qualified JavaScript.XHR
#else
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Text.Lazy
import qualified Data.Text.Lazy.Encoding
#endif
#if MIN_VERSION_http_client(0,5,0)
import Network.HTTP.Client
(HttpException(..), HttpExceptionContent(..), Manager)
#else
import Network.HTTP.Client (HttpException(..), Manager)
#endif
import qualified Network.HTTP.Client as HTTP
import qualified Network.HTTP.Client.TLS as HTTP
import qualified Network.HTTP.Types
mkPrettyHttpException :: HttpException -> PrettyHttpException
mkPrettyHttpException ex =
PrettyHttpException (renderPrettyHttpException ex) (toDyn ex)
renderPrettyHttpException :: HttpException -> String
#if MIN_VERSION_http_client(0,5,0)
renderPrettyHttpException (InvalidUrlException _ r) =
"\n"
<> "\ESC[1;31mError\ESC[0m: Invalid URL\n"
<> "\n"
<> "" <> show r
renderPrettyHttpException (HttpExceptionRequest _ e) =
case e of
ConnectionFailure _ ->
"\n"
<> "\ESC[1;31mError\ESC[0m: Remote host not found\n"
InvalidDestinationHost host ->
"\n"
<> "\ESC[1;31mError\ESC[0m: Invalid remote host name\n"
<> "\n"
<> "" <> show host
ResponseTimeout ->
"\n"
<> "\ESC[1;31mError\ESC[0m: The remote host took too long to respond"
StatusCodeException response _
| statusCode == 404 ->
"\n"
<> "\ESC[1;31mError\ESC[0m: Remote file not found"
| otherwise ->
"\n"
<> "\ESC[1;31mError\ESC[0m: Unexpected HTTP status code:\n"
<> "\n"
<> "" <> show statusCode
where
statusCode =
Network.HTTP.Types.statusCode
(HTTP.responseStatus response)
e' -> "\n" <> show e'
#else
renderPrettyHttpException e = case e of
FailedConnectionException2 _ _ _ e' ->
"\n"
<> "\ESC[1;31mError\ESC[0m: Wrong host\n"
<> "\n"
<> "" <> show e'
InvalidDestinationHost host ->
"\n"
<> "\ESC[1;31mError\ESC[0m: Invalid host name\n"
<> "\n"
<> "" <> show host
ResponseTimeout ->
"\ESC[1;31mError\ESC[0m: The host took too long to respond\n"
e' -> "\n"
<> show e'
#endif
newManager :: IO Manager
newManager = do
let settings = HTTP.tlsManagerSettings
#ifdef MIN_VERSION_http_client
#if MIN_VERSION_http_client(0,5,0)
{ HTTP.managerResponseTimeout = HTTP.responseTimeoutMicro (30 * 1000 * 1000) } -- 30 seconds
#else
{ HTTP.managerResponseTimeout = Just (30 * 1000 * 1000) } -- 30 seconds
#endif
#endif
HTTP.newManager settings
data NotCORSCompliant = NotCORSCompliant
{ expectedOrigins :: [ByteString]
, actualOrigin :: ByteString
}
instance Exception NotCORSCompliant
instance Show NotCORSCompliant where
show (NotCORSCompliant {..}) =
Dhall.Util._ERROR <> ": Not CORS compliant\n"
<> "\n"
<> "Dhall supports transitive imports, meaning that an imported expression can\n"
<> "import other expressions. However, a remote import (the \"parent\" import)\n"
<> "cannot import another remote import (the \"child\" import) unless the child\n"
<> "import grants permission to do using CORS. The child import must respond with\n"
<> "an `Access-Control-Allow-Origin` response header that matches the parent\n"
<> "import, otherwise Dhall rejects the import.\n"
<> "\n" <> prologue
where
prologue =
case expectedOrigins of
[ expectedOrigin ] ->
"The following parent import:\n"
<> "\n"
<> "" <> show actualOrigin <> "\n"
<> "\n"
<> "... did not match the expected origin:\n"
<> "\n"
<> "" <> show expectedOrigin <> "\n"
<> "\n"
<> "... so import resolution failed.\n"
[] ->
"The child response did not include any `Access-Control-Allow-Origin` header,\n"
<> "so import resolution failed.\n"
_:_:_ ->
"The child response included more than one `Access-Control-Allow-Origin` header,\n"
<> "when only one such header should have been present, so import resolution\n"
<> "failed.\n"
<> "\n"
<> "This may indicate that the server for the child import is misconfigured.\n"
corsCompliant
:: MonadIO io
=> ImportType -> URL -> [(CI ByteString, ByteString)] -> io ()
corsCompliant (Remote parentURL) childURL responseHeaders = liftIO $ do
let toOrigin (URL {..}) =
Data.Text.Encoding.encodeUtf8 (prefix <> "://" <> authority)
where
prefix =
case scheme of
HTTP -> "http"
HTTPS -> "https"
let actualOrigin = toOrigin parentURL
let childOrigin = toOrigin childURL
let predicate (header, _) = header == "Access-Control-Allow-Origin"
let originHeaders = filter predicate responseHeaders
let expectedOrigins = map snd originHeaders
case expectedOrigins of
[expectedOrigin]
| expectedOrigin == "*" ->
return ()
| expectedOrigin == actualOrigin ->
return ()
_ | actualOrigin == childOrigin ->
return ()
| otherwise ->
Control.Exception.throwIO (NotCORSCompliant {..})
corsCompliant _ _ _ = return ()
renderComponent :: Text -> Text
renderComponent component = "/" <> URI.Encode.encodeText component
renderQuery :: Text -> Text
renderQuery query = "?" <> query
renderURL :: URL -> Text
renderURL url =
schemeText
<> authority
<> pathText
<> queryText
where
URL {..} = url
File {..} = path
Directory {..} = directory
schemeText = case scheme of
HTTP -> "http://"
HTTPS -> "https://"
pathText =
foldMap renderComponent (reverse components)
<> renderComponent file
queryText = foldMap renderQuery query
type HTTPHeader = Network.HTTP.Types.Header
fetchFromHttpUrl
:: Manager
-> URL
-> Maybe [HTTPHeader]
-> StateT Status IO Text.Text
#ifdef __GHCJS__
fetchFromHttpUrl _ childURL Nothing = do
let childURLText = renderURL childURL
let childURLString = Text.unpack childURLText
-- No need to add a CORS compliance check when using GHCJS. The browser
-- will already check the CORS compliance of the following XHR
(statusCode, body) <- liftIO (JavaScript.XHR.get childURLText)
case statusCode of
200 -> return ()
_ -> fail (childURLString <> " returned a non-200 status code: " <> show statusCode)
return body
fetchFromHttpUrl _ _ _ = do
fail "Dhall does not yet support custom headers when built using GHCJS"
#else
fetchFromHttpUrl manager childURL mheaders = do
let childURLString = Text.unpack (renderURL childURL)
request <- liftIO (HTTP.parseUrlThrow childURLString)
let requestWithHeaders =
case mheaders of
Nothing -> request
Just headers -> request { HTTP.requestHeaders = headers }
let io = HTTP.httpLbs requestWithHeaders manager
let handler e = do
let _ = e :: HttpException
Control.Exception.throwIO (mkPrettyHttpException e)
response <- liftIO (Control.Exception.handle handler io)
Status {..} <- State.get
let Chained parentImport = NonEmpty.head _stack
let parentImportType = importType (importHashed parentImport)
corsCompliant parentImportType childURL (HTTP.responseHeaders response)
let bytes = HTTP.responseBody response
case Data.Text.Lazy.Encoding.decodeUtf8' bytes of
Left err -> liftIO (Control.Exception.throwIO err)
Right text -> return (Data.Text.Lazy.toStrict text)
#endif