1 {-# LANGUAGE DeriveFunctor #-}
2 {-# LANGUAGE GeneralizedNewtypeDeriving #-}
3 {-# LANGUAGE OverloadedStrings #-}
4 {-# LANGUAGE Rank2Types #-}
5 {-# LANGUAGE StrictData #-}
6 {-# OPTIONS_GHC -fno-warn-orphans #-}
7 -- | See <https://hackage.haskell.org/package/symantic-http-demo symantic-http-demo>
8 -- for an example of how to use this module.
9 module Symantic.HTTP.Client where
11 import Control.Applicative (Applicative(..){-, Alternative(..)-})
12 import Control.Monad (Monad(..), unless)
13 import Control.Monad.Trans.Class (MonadTrans(..))
15 import Data.Either (Either(..))
16 import Data.Eq (Eq(..))
17 import Data.Foldable (null, for_, toList)
18 import Data.Function (($), (.), id, on)
19 import Data.Functor (Functor(..), (<$>))
20 import Data.Maybe (Maybe(..), maybe, fromMaybe)
21 import Data.Ord (Ord(..))
22 import Data.Proxy (Proxy(..))
23 import Data.Semigroup (Semigroup(..))
24 import Data.String (IsString(..))
25 import Data.Text (Text)
26 import Data.Traversable (sequence)
27 import Data.Tuple (fst)
28 import GHC.Exts (fromList)
30 import Text.Read (readMaybe)
31 import Text.Show (Show(..))
32 import qualified Control.Concurrent.STM as STM
33 import qualified Control.Exception as Exn
34 import qualified Control.Monad.Classes as MC
35 import qualified Control.Monad.Trans.Except as E
36 import qualified Control.Monad.Trans.Reader as R
37 import qualified Data.ByteString as BS
38 import qualified Data.ByteString.Base64 as BS64
39 import qualified Data.ByteString.Builder as BSB
40 import qualified Data.ByteString.Lazy as BSL
41 import qualified Data.IORef as IO
42 import qualified Data.List as List
43 import qualified Data.List.NonEmpty as NonEmpty
44 import qualified Data.Sequence as Seq
45 import qualified Data.Text as Text
46 import qualified Data.Text.Encoding as Text
47 import qualified Data.Time.Clock as Time
48 import qualified Network.HTTP.Client as Client
49 import qualified Network.HTTP.Media as Media
50 import qualified Network.HTTP.Types as HTTP
51 import qualified Network.URI as URI
52 import qualified Web.HttpApiData as Web
54 import Symantic.HTTP.API
55 import Symantic.HTTP.URI
56 import Symantic.HTTP.MIME
59 -- | @'Client' a k@ is a recipe to produce a 'ClientRequest'
60 -- from arguments 'requests' (one per number of alternative routes)
61 -- separated by (':!:').
63 -- 'Client' is analogous to a printf using a format customized for HTTP routing.
64 newtype Client requests k
66 { unClient :: (ClientModifier -> k) -> requests
69 -- | @'client' requests@ returns the 'ClientRequest'
70 -- builders from the given API.
71 client :: Client requests ClientRequest -> requests
72 client (Client requests) = requests ($ ini)
75 { clientReq_httpVersion = HTTP.http11
76 , clientReq_method = HTTP.methodGet
78 , clientReq_queryString = Seq.empty
79 , clientReq_accept = Seq.empty
80 , clientReq_headers = Seq.empty
81 , clientReq_body = Nothing
84 -- ** Type 'ClientModifier'
85 type ClientModifier = ClientRequest -> ClientRequest
87 instance Cat Client where
88 Client x <.> Client y = Client $ \k ->
89 x $ \fx -> y $ \fy -> k $ fy . fx
90 instance Alt Client where
91 Client x <!> Client y = Client $ \k ->
94 type AltMerge Client = (:!:)
95 Client x <!> Client y = Client $ \k ->
96 x (\cm -> let n:!:_ = k cm in n) :!:
97 y (\cm -> let _:!:n = k cm in n)
99 -- try = id -- FIXME: see what to do
100 instance Pro Client where
101 dimap _a2b b2a r = Client $ \k -> unClient r k . b2a
103 instance HTTP_Path Client where
104 type PathConstraint Client a = Web.ToHttpApiData a
105 segment s = Client $ \k -> k $ \req ->
106 req{ clientReq_path = clientReq_path req <> "/" <> Web.toEncodedUrlPiece s }
107 capture' _n = Client $ \k a -> k $ \req ->
108 req{ clientReq_path = clientReq_path req <> "/" <> Web.toEncodedUrlPiece a }
109 captureAll = Client $ \k ss -> k $ \req ->
110 req{ clientReq_path =
111 List.foldl' (\acc s -> acc <> "/" <> Web.toEncodedUrlPiece s) "" $
112 Web.toUrlPiece <$> ss
114 instance HTTP_Header Client where
115 header n = Client $ \k v -> k $ \req ->
116 req{ clientReq_headers = clientReq_headers req Seq.|> (n, Web.toHeader v) }
117 instance HTTP_BasicAuth Client where
118 type BasicAuthArgs Client a k = BasicAuthUser -> BasicAuthPass -> k
119 basicAuth' realm = Client $ \k user pass -> k $ \req ->
120 req{ clientReq_headers =
121 let user_pass = Text.encodeUtf8 $ user<>":"<>pass in
122 clientReq_headers req Seq.|>
123 ( HTTP.hAuthorization
124 , Web.toHeader $ "Basic " <> BS64.encode user_pass
127 instance HTTP_Query Client where
128 type QueryConstraint Client a = Web.ToHttpApiData a
129 queryParams' n = Client $ \k vs -> k $ \req ->
130 req{ clientReq_queryString =
131 clientReq_queryString req <>
132 fromList ((\v -> (n, Just $ Text.encodeUtf8 $ Web.toQueryParam v)) <$> vs) }
133 instance HTTP_Version Client where
134 version v = Client $ \k -> k $ \req ->
135 req{clientReq_httpVersion = v}
137 -- ** Type 'ClientBodyArg'
138 newtype ClientBodyArg (ts::[*]) a = ClientBodyArg a
139 instance HTTP_Body Client where
140 type BodyConstraint Client a ts = MimeTypes ts (MimeEncodable a)
141 type BodyArg Client a ts = ClientBodyArg ts a
144 BodyConstraint repr a ts =>
146 repr (BodyArg repr a ts -> k) k
147 body'= Client $ \k (ClientBodyArg a) -> k $ \req ->
149 case NonEmpty.head (mimeTypes @ts) :: MimeType (MimeEncodable a) of
150 MimeType (mt::Proxy t) ->
152 ( Client.RequestBodyLBS $ mimeEncode mt a
156 -- ** Type 'ClientBodyStreamArg'
157 newtype ClientBodyStreamArg framing (ts::[*]) as = ClientBodyStreamArg as
158 instance HTTP_BodyStream Client where
159 type BodyStreamConstraint Client as ts framing =
160 ( FramingEncode framing as
161 , MimeTypes ts (MimeEncodable (FramingYield as))
163 type BodyStreamArg Client as ts framing = ClientBodyStreamArg framing ts as
165 forall as ts framing k repr.
166 BodyStreamConstraint repr as ts framing =>
168 repr (BodyStreamArg repr as ts framing -> k) k
169 bodyStream'= Client $ \k (ClientBodyStreamArg as) -> k $ \req ->
171 case NonEmpty.head (mimeTypes @ts) :: MimeType (MimeEncodable (FramingYield as)) of
172 MimeType (mt::Proxy t) ->
173 Just $ (, mediaType @t) $
174 Client.RequestBodyStreamChunked $ \write -> do
175 let enc = framingEncode (Proxy @framing) (mimeEncode mt)
177 ioref <- IO.newIORef ini
180 Left _end -> return ""
182 | BSL.null bsl -> enc next >>= go
183 -- NOTE: skip all null 'ByteString' because it would end the stream.
184 | otherwise -> enc next >>= \n -> do
185 IO.writeIORef ioref n
186 return $ BSL.toStrict bsl
187 -- NOTE: strictify the 'bsl' 'ByteString'
188 -- instead of iterating on its chunks,
189 -- in order to diminish the number of 'Client.connectionWrite'.
190 write $ IO.readIORef ioref >>= go
193 instance HTTP_Response Client where
194 type ResponseConstraint Client a ts = MimeTypes ts (MimeDecodable a)
195 type ResponseArgs Client a ts = Proxy ts -> Proxy a -> ClientRequest
196 type Response Client = ClientRequest
199 ResponseConstraint repr a ts =>
202 repr (ResponseArgs repr a ts)
204 response m = Client $ \k Proxy Proxy -> k $ \req ->
206 { clientReq_method = m
208 clientReq_accept req <>
209 fromList (toList $ mediaTypes @ts @(MimeDecodable a))
212 instance HTTP_ResponseStream Client where
213 type ResponseStreamConstraint Client as ts framing =
214 MimeTypes ts (MimeDecodable (FramingYield as))
215 type ResponseStreamArgs Client as ts framing =
220 type ResponseStream Client = ClientRequest
223 forall as ts framing repr.
224 ResponseStreamConstraint repr as ts framing =>
227 repr (ResponseStreamArgs repr as ts framing)
228 (ResponseStream repr)
229 responseStream m = Client $ \k Proxy Proxy Proxy -> k $ \req ->
231 { clientReq_method = m
233 clientReq_accept req <>
234 fromList (toList $ mediaTypes @ts @(MimeDecodable (FramingYield as)))
237 instance Web.ToHttpApiData BS.ByteString where
238 toUrlPiece = Web.toUrlPiece . Text.decodeUtf8
241 -- * Type 'ClientConn'
242 -- | A monadic connection from a client to a server.
243 -- It is specialized in 'ClientConnection' and 'ClientConnectionStream'.
245 -- NOTE: no 'Monad' transformer is put within this newtype
246 -- to let @monad-classes@ handle all the |lift|ing.
247 newtype ClientConn m a
249 { unClientConn :: m a
250 } deriving (Functor, Applicative, Monad)
251 -- | All supported effects are handled by nested 'Monad's.
252 type instance MC.CanDo (ClientConn m) eff = 'False
253 instance MonadTrans ClientConn where
256 -- ** Type 'ClientEnv'
259 { clientEnv_manager :: Client.Manager
260 , clientEnv_baseURI :: URI
261 , clientEnv_cookieJar :: Maybe (STM.TVar Client.CookieJar)
263 clientEnv :: Client.Manager -> URI -> ClientEnv
264 clientEnv clientEnv_manager clientEnv_baseURI =
266 { clientEnv_cookieJar = Nothing
270 -- ** Type 'ClientError'
272 -- | The server returned an error response
273 = ClientError_FailureResponse ClientResponse
274 -- | The body could not be decoded at the expected type
275 | ClientError_DecodeFailure Text ClientResponse
276 -- | The content-type of the response is not supported
277 | ClientError_UnsupportedContentType BS.ByteString ClientResponse
278 -- | There was a connection error, and no response was received
279 | ClientError_ConnectionError Client.HttpException
280 -- | 'ClientConn' is 'empty'
281 | ClientError_EmptyClient
282 deriving (Eq, Show{-, Generic, Typeable-})
283 instance Exn.Exception ClientError
284 instance Eq Client.HttpException where
285 (==) = (==) `on` show
287 -- ** Type 'ClientRequest'
290 { clientReq_httpVersion :: HTTP.HttpVersion
291 , clientReq_method :: HTTP.Method
292 , clientReq_path :: BSB.Builder
293 , clientReq_queryString :: Seq.Seq HTTP.QueryItem
294 , clientReq_accept :: Seq.Seq Media.MediaType
295 , clientReq_headers :: Seq.Seq HTTP.Header
296 , clientReq_body :: Maybe (Client.RequestBody, Media.MediaType)
298 instance Show ClientRequest where
299 show _ = "ClientRequest"
301 clientRequest :: URI -> ClientRequest -> Client.Request
302 clientRequest baseURI req =
303 Client.defaultRequest
304 { Client.method = clientReq_method req
305 , Client.host = maybe "" (fromString . URI.uriRegName) $ URI.uriAuthority baseURI
306 , Client.port = case URI.uriPort <$> URI.uriAuthority baseURI of
307 Just (':':p) | Just port <- readMaybe p -> port
309 , Client.path = BSL.toStrict $ fromString (URI.uriPath baseURI) <> BSB.toLazyByteString (clientReq_path req)
310 , Client.queryString = HTTP.renderQuery True . toList $ clientReq_queryString req
311 , Client.requestHeaders = acceptHeader <> contentTypeHeader <> headers
313 , Client.secure = URI.uriScheme baseURI == "https"
316 headers = List.filter (\(h, _) -> h/="Accept" && h/="Content-Type") $
317 toList $ clientReq_headers req
319 acceptHeader | null hs = []
320 | otherwise = [("Accept", Media.renderHeader hs)]
322 hs = toList $ clientReq_accept req
324 (requestBody, contentTypeHeader) =
325 case clientReq_body req of
326 Nothing -> (Client.RequestBodyBS "", [])
327 Just (b, mt) -> (b, [(HTTP.hContentType, Media.renderHeader mt)])
329 -- ** Type 'ClientConnection'
330 type ClientConnection
331 = ClientConn (R.ReaderT ClientEnv (E.ExceptT ClientError IO))
334 -- | Try clients in order, last error is preserved.
335 instance Alternative ClientConnection where
336 empty = MC.throw $ ClientError_EmptyClient
337 x <|> y = ClientConn $ do
339 MC.exec @IO (E.runExceptT $ R.runReaderT (unClientConn x) env) >>= \case
340 Right xa -> return xa
341 Left _err -> unClientConn y
345 MimeTypes ts (MimeDecodable a) =>
347 (Proxy ts -> Proxy a -> ClientRequest) ->
348 IO (Either ClientError a)
351 (`R.runReaderT` env) .
357 MimeTypes ts (MimeDecodable a) =>
358 (Proxy ts -> Proxy a -> ClientRequest) ->
360 clientConnection req = do
361 clientRes <- doClientRequest $ req (Proxy::Proxy ts) (Proxy::Proxy a)
363 fromMaybe "application/octet-stream" $
364 List.lookup "Content-Type" $
365 Client.responseHeaders clientRes
366 case matchContent @ts @(MimeDecodable a) mtRes of
367 Nothing -> MC.throw $ ClientError_UnsupportedContentType mtRes clientRes
368 Just (MimeType mt) ->
369 case mimeDecode mt $ Client.responseBody clientRes of
370 Left err -> MC.throw $ ClientError_DecodeFailure (Text.pack err) clientRes
371 Right val -> return val
373 doClientRequest :: ClientRequest -> ClientConnection ClientResponse
374 doClientRequest clientReq = do
375 ClientEnv{..} <- MC.ask
377 let req = clientRequest clientEnv_baseURI clientReq in
378 case clientEnv_cookieJar of
382 now <- Time.getCurrentTime
384 oldCookieJar <- STM.readTVar cj
385 let (newRequest, newCookieJar) =
386 Client.insertCookiesIntoRequest req oldCookieJar now
387 STM.writeTVar cj newCookieJar
390 MC.exec @IO $ catchClientConnectionError $
391 Client.httpLbs req clientEnv_manager
393 Left err -> MC.throw err
395 for_ clientEnv_cookieJar $ \cj ->
397 now <- Time.getCurrentTime
398 STM.atomically $ STM.modifyTVar' cj (fst . Client.updateCookieJar res req now)
399 let code = HTTP.statusCode $ Client.responseStatus res
400 unless (code >= 200 && code < 300) $
401 MC.throw $ ClientError_FailureResponse res
404 catchClientConnectionError :: IO a -> IO (Either ClientError a)
405 catchClientConnectionError ma =
406 Exn.catch (Right <$> ma) $ \err ->
407 return $ Left $ ClientError_ConnectionError err
409 -- *** Type 'ClientResponse'
411 = Client.Response BSL.ByteString
413 -- ** Type 'ClientConnectionStream'
414 type ClientConnectionStream
415 = ClientConn (R.ReaderT ClientEnv (Codensity (E.ExceptT ClientError IO)))
418 FramingDecode framing as =>
419 MC.MonadExec IO (FramingMonad as) =>
420 MimeTypes ts (MimeDecodable (FramingYield as)) =>
422 (Proxy framing -> Proxy ts -> Proxy as -> ClientRequest) ->
423 (as -> IO b) -> IO (Either ClientError b)
424 runClientStream env req k =
426 (`runCodensity` lift . k) $
427 (`R.runReaderT` env) $
429 clientConnectionStream req
431 clientConnectionStream ::
432 forall as ts framing.
433 FramingDecode framing as =>
434 MC.MonadExec IO (FramingMonad as) =>
435 MimeTypes ts (MimeDecodable (FramingYield as)) =>
436 (Proxy framing -> Proxy ts -> Proxy as -> ClientRequest) ->
437 ClientConnectionStream as
438 clientConnectionStream req = do
439 doClientRequestStream (Proxy::Proxy ts) (req Proxy Proxy Proxy) $ \(MimeType mt) clientRes ->
441 framingDecode (Proxy @framing) (mimeDecode mt) $
442 MC.exec @IO $ Client.responseBody clientRes
444 doClientRequestStream ::
446 MimeTypes ts (MimeDecodable (FramingYield as)) =>
449 ( MimeType (MimeDecodable (FramingYield as)) ->
450 Client.Response Client.BodyReader ->
451 E.ExceptT ClientError IO as ) ->
452 ClientConnectionStream as
453 doClientRequestStream Proxy clientReq k = do
454 ClientEnv{..} <- MC.ask
455 let req = clientRequest clientEnv_baseURI $ clientReq
456 ClientConn $ lift $ Codensity $ \k' ->
457 E.ExceptT $ Client.withResponse req clientEnv_manager $ \res ->
458 E.runExceptT $ do{-E.ExceptT ClientError IO-}
460 let code = HTTP.statusCode $ Client.responseStatus res
461 unless (code >= 200 && code < 300) $ do
462 err <- MC.exec @IO $ sequence $ (BSL.fromChunks <$>) . Client.brConsume <$> res
463 E.throwE $ ClientError_FailureResponse err
464 -- Check Content-Type header
466 fromMaybe "application/octet-stream" $
467 List.lookup "Content-Type" $
468 Client.responseHeaders res
469 case matchContent @ts @(MimeDecodable (FramingYield as)) contentTypeH of
471 err <- MC.exec @IO $ sequence $ (BSL.fromChunks <$>) . Client.brConsume <$> res
472 E.throwE $ ClientError_UnsupportedContentType contentTypeH err
473 Just ct -> k ct res >>= k'
475 -- *** Type 'Codensity'
476 -- | Copy from the @kan-extensions@ package to avoid the dependencies.
477 newtype Codensity m a
479 { runCodensity :: forall b. (a -> m b) -> m b }
480 type instance MC.CanDo (Codensity m) (MC.EffExec eff) = 'False
481 instance Functor (Codensity k) where
482 fmap f (Codensity m) = Codensity (\k -> m (k .f))
484 instance Applicative (Codensity f) where
485 pure x = Codensity (\k -> k x)
487 Codensity f <*> Codensity g = Codensity (\bfr -> f (\ab -> g (bfr . ab)))
489 instance Monad (Codensity f) where
491 {-# INLINE return #-}
492 m >>= k = Codensity (\c -> runCodensity m (\a -> runCodensity (k a) c))
494 instance MonadTrans Codensity where
495 lift m = Codensity (m >>=)