1 {-# LANGUAGE DeriveFunctor #-}
2 {-# LANGUAGE GeneralizedNewtypeDeriving #-}
3 {-# LANGUAGE OverloadedStrings #-}
4 {-# LANGUAGE Rank2Types #-}
5 {-# LANGUAGE StrictData #-}
6 {-# OPTIONS_GHC -fno-warn-orphans #-}
7 -- | See @demo/client/Main.hs@ for an example of how to use this module.
8 module Symantic.HTTP.Client where
10 import Control.Applicative (Applicative(..){-, Alternative(..)-})
11 import Control.Monad (Monad(..), unless)
12 import Control.Monad.Trans.Class (MonadTrans(..))
14 import Data.Either (Either(..))
15 import Data.Eq (Eq(..))
16 import Data.Foldable (null, for_, toList)
17 import Data.Function (($), (.), id, on)
18 import Data.Functor (Functor(..), (<$>))
19 import Data.Maybe (Maybe(..), maybe, fromMaybe)
20 import Data.Ord (Ord(..))
21 import Data.Proxy (Proxy(..))
22 import Data.Semigroup (Semigroup(..))
23 import Data.String (IsString(..))
24 import Data.Text (Text)
25 import Data.Traversable (sequence)
26 import Data.Tuple (fst)
27 import GHC.Exts (fromList)
29 import Text.Read (readMaybe)
30 import Text.Show (Show(..))
31 import qualified Control.Concurrent.STM as STM
32 import qualified Control.Exception as Exn
33 import qualified Control.Monad.Classes as MC
34 import qualified Control.Monad.Trans.Except as E
35 import qualified Control.Monad.Trans.Reader as R
36 import qualified Data.ByteString as BS
37 import qualified Data.ByteString.Base64 as BS64
38 import qualified Data.ByteString.Builder as BSB
39 import qualified Data.ByteString.Lazy as BSL
40 import qualified Data.IORef as IO
41 import qualified Data.List as List
42 import qualified Data.List.NonEmpty as NonEmpty
43 import qualified Data.Sequence as Seq
44 import qualified Data.Text as Text
45 import qualified Data.Text.Encoding as Text
46 import qualified Data.Time.Clock as Time
47 import qualified Network.HTTP.Client as Client
48 import qualified Network.HTTP.Media as Media
49 import qualified Network.HTTP.Types as HTTP
50 import qualified Network.URI as URI
51 import qualified Web.HttpApiData as Web
53 import Symantic.HTTP.API
54 import Symantic.HTTP.URI
55 import Symantic.HTTP.MIME
58 -- | @'Client' a k@ is a recipe to produce a 'ClientRequest'
59 -- from arguments 'requests' (one per number of alternative routes)
60 -- separated by (':!:').
62 -- 'Client' is analogous to a printf using a format customized for HTTP routing.
63 newtype Client requests k
65 { unClient :: (ClientModifier -> k) -> requests
68 -- | @'client' requests@ returns the 'ClientRequest'
69 -- builders from the given API.
70 client :: Client requests ClientRequest -> requests
71 client (Client requests) = requests ($ ini)
74 { clientReq_httpVersion = HTTP.http11
75 , clientReq_method = HTTP.methodGet
77 , clientReq_queryString = Seq.empty
78 , clientReq_accept = Seq.empty
79 , clientReq_headers = Seq.empty
80 , clientReq_body = Nothing
83 -- ** Type 'ClientModifier'
84 type ClientModifier = ClientRequest -> ClientRequest
86 instance Cat Client where
87 Client x <.> Client y = Client $ \k ->
88 x $ \fx -> y $ \fy -> k $ fy . fx
89 instance Alt Client where
90 Client x <!> Client y = Client $ \k ->
93 type AltMerge Client = (:!:)
94 Client x <!> Client y = Client $ \k ->
95 x (\cm -> let n:!:_ = k cm in n) :!:
96 y (\cm -> let _:!:n = k cm in n)
98 -- try = id -- FIXME: see what to do
99 instance Pro Client where
100 dimap _a2b b2a r = Client $ \k -> unClient r k . b2a
102 instance HTTP_Path Client where
103 type PathConstraint Client a = Web.ToHttpApiData a
104 segment s = Client $ \k -> k $ \req ->
105 req{ clientReq_path = clientReq_path req <> "/" <> Web.toEncodedUrlPiece s }
106 capture' _n = Client $ \k a -> k $ \req ->
107 req{ clientReq_path = clientReq_path req <> "/" <> Web.toEncodedUrlPiece a }
108 captureAll = Client $ \k ss -> k $ \req ->
109 req{ clientReq_path =
110 List.foldl' (\acc s -> acc <> "/" <> Web.toEncodedUrlPiece s) "" $
111 Web.toUrlPiece <$> ss
113 instance HTTP_Header Client where
114 header n = Client $ \k v -> k $ \req ->
115 req{ clientReq_headers = clientReq_headers req Seq.|> (n, Web.toHeader v) }
116 instance HTTP_BasicAuth Client where
117 type BasicAuthArgs Client a k = BasicAuthUser -> BasicAuthPass -> k
118 basicAuth' realm = Client $ \k user pass -> k $ \req ->
119 req{ clientReq_headers =
120 let user_pass = Text.encodeUtf8 $ user<>":"<>pass in
121 clientReq_headers req Seq.|>
122 ( HTTP.hAuthorization
123 , Web.toHeader $ "Basic " <> BS64.encode user_pass
126 instance HTTP_Query Client where
127 type QueryConstraint Client a = Web.ToHttpApiData a
128 queryParams' n = Client $ \k vs -> k $ \req ->
129 req{ clientReq_queryString =
130 clientReq_queryString req <>
131 fromList ((\v -> (n, Just $ Text.encodeUtf8 $ Web.toQueryParam v)) <$> vs) }
132 instance HTTP_Version Client where
133 version v = Client $ \k -> k $ \req ->
134 req{clientReq_httpVersion = v}
136 -- ** Type 'ClientBodyArg'
137 newtype ClientBodyArg (ts::[*]) a = ClientBodyArg a
138 instance HTTP_Body Client where
139 type BodyConstraint Client a ts = MimeTypes ts (MimeEncodable a)
140 type BodyArg Client a ts = ClientBodyArg ts a
143 BodyConstraint repr a ts =>
145 repr (BodyArg repr a ts -> k) k
146 body'= Client $ \k (ClientBodyArg a) -> k $ \req ->
148 case NonEmpty.head (mimeTypes @ts) :: MimeType (MimeEncodable a) of
149 MimeType (mt::Proxy t) ->
151 ( Client.RequestBodyLBS $ mimeEncode mt a
155 -- ** Type 'ClientBodyStreamArg'
156 newtype ClientBodyStreamArg framing (ts::[*]) as = ClientBodyStreamArg as
157 instance HTTP_BodyStream Client where
158 type BodyStreamConstraint Client as ts framing =
159 ( FramingEncode framing as
160 , MimeTypes ts (MimeEncodable (FramingYield as))
162 type BodyStreamArg Client as ts framing = ClientBodyStreamArg framing ts as
164 forall as ts framing k repr.
165 BodyStreamConstraint repr as ts framing =>
167 repr (BodyStreamArg repr as ts framing -> k) k
168 bodyStream'= Client $ \k (ClientBodyStreamArg as) -> k $ \req ->
170 case NonEmpty.head (mimeTypes @ts) :: MimeType (MimeEncodable (FramingYield as)) of
171 MimeType (mt::Proxy t) ->
172 Just $ (, mediaType @t) $
173 Client.RequestBodyStreamChunked $ \write -> do
174 let enc = framingEncode (Proxy @framing) (mimeEncode mt)
176 ioref <- IO.newIORef ini
179 Left _end -> return ""
181 | BSL.null bsl -> enc next >>= go
182 -- NOTE: skip all null 'ByteString' because it would end the stream.
183 | otherwise -> enc next >>= \n -> do
184 IO.writeIORef ioref n
185 return $ BSL.toStrict bsl
186 -- NOTE: strictify the 'bsl' 'ByteString'
187 -- instead of iterating on its chunks,
188 -- in order to diminish the number of 'Client.connectionWrite'.
189 write $ IO.readIORef ioref >>= go
192 instance HTTP_Response Client where
193 type ResponseConstraint Client a ts = MimeTypes ts (MimeDecodable a)
194 type ResponseArgs Client a ts = Proxy ts -> Proxy a -> ClientRequest
195 type Response Client = ClientRequest
198 ResponseConstraint repr a ts =>
201 repr (ResponseArgs repr a ts)
203 response m = Client $ \k Proxy Proxy -> k $ \req ->
205 { clientReq_method = m
207 clientReq_accept req <>
208 fromList (toList $ mediaTypes @ts @(MimeDecodable a))
211 instance HTTP_ResponseStream Client where
212 type ResponseStreamConstraint Client as ts framing =
213 MimeTypes ts (MimeDecodable (FramingYield as))
214 type ResponseStreamArgs Client as ts framing =
219 type ResponseStream Client = ClientRequest
222 forall as ts framing repr.
223 ResponseStreamConstraint repr as ts framing =>
226 repr (ResponseStreamArgs repr as ts framing)
227 (ResponseStream repr)
228 responseStream m = Client $ \k Proxy Proxy Proxy -> k $ \req ->
230 { clientReq_method = m
232 clientReq_accept req <>
233 fromList (toList $ mediaTypes @ts @(MimeDecodable (FramingYield as)))
236 instance Web.ToHttpApiData BS.ByteString where
237 toUrlPiece = Web.toUrlPiece . Text.decodeUtf8
240 -- * Type 'ClientConn'
241 -- | A monadic connection from a client to a server.
242 -- It is specialized in 'ClientConnection' and 'ClientConnectionStream'.
244 -- NOTE: no 'Monad' transformer is put within this newtype
245 -- to let |monad-classes| handle all the |lift|ing.
246 newtype ClientConn m a
248 { unClientConn :: m a
249 } deriving (Functor, Applicative, Monad)
250 -- | All supported effects are handled by nested 'Monad's.
251 type instance MC.CanDo (ClientConn m) eff = 'False
252 instance MonadTrans ClientConn where
255 -- ** Type 'ClientEnv'
258 { clientEnv_manager :: Client.Manager
259 , clientEnv_baseURI :: URI
260 , clientEnv_cookieJar :: Maybe (STM.TVar Client.CookieJar)
262 clientEnv :: Client.Manager -> URI -> ClientEnv
263 clientEnv clientEnv_manager clientEnv_baseURI =
265 { clientEnv_cookieJar = Nothing
269 -- ** Type 'ClientError'
271 -- | The server returned an error response
272 = ClientError_FailureResponse ClientResponse
273 -- | The body could not be decoded at the expected type
274 | ClientError_DecodeFailure Text ClientResponse
275 -- | The content-type of the response is not supported
276 | ClientError_UnsupportedContentType BS.ByteString ClientResponse
277 -- | There was a connection error, and no response was received
278 | ClientError_ClientConnectionError Client.HttpException
279 -- | 'ClientConn' is 'empty'
280 | ClientError_EmptyClient
281 deriving (Eq, Show{-, Generic, Typeable-})
282 instance Exn.Exception ClientError
283 instance Eq Client.HttpException where
284 (==) = (==) `on` show
286 -- ** Type 'ClientRequest'
289 { clientReq_httpVersion :: HTTP.HttpVersion
290 , clientReq_method :: HTTP.Method
291 , clientReq_path :: BSB.Builder
292 , clientReq_queryString :: Seq.Seq HTTP.QueryItem
293 , clientReq_accept :: Seq.Seq Media.MediaType
294 , clientReq_headers :: Seq.Seq HTTP.Header
295 , clientReq_body :: Maybe (Client.RequestBody, Media.MediaType)
297 instance Show ClientRequest where
298 show _ = "ClientRequest"
300 clientRequest :: URI -> ClientRequest -> Client.Request
301 clientRequest baseURI req =
302 Client.defaultRequest
303 { Client.method = clientReq_method req
304 , Client.host = maybe "" (fromString . URI.uriRegName) $ URI.uriAuthority baseURI
305 , Client.port = case URI.uriPort <$> URI.uriAuthority baseURI of
306 Just (':':p) | Just port <- readMaybe p -> port
308 , Client.path = BSL.toStrict $ fromString (URI.uriPath baseURI) <> BSB.toLazyByteString (clientReq_path req)
309 , Client.queryString = HTTP.renderQuery True . toList $ clientReq_queryString req
310 , Client.requestHeaders = acceptHeader <> contentTypeHeader <> headers
312 , Client.secure = URI.uriScheme baseURI == "https"
315 headers = List.filter (\(h, _) -> h/="Accept" && h/="Content-Type") $
316 toList $ clientReq_headers req
318 acceptHeader | null hs = []
319 | otherwise = [("Accept", Media.renderHeader hs)]
321 hs = toList $ clientReq_accept req
323 (requestBody, contentTypeHeader) =
324 case clientReq_body req of
325 Nothing -> (Client.RequestBodyBS "", [])
326 Just (b, mt) -> (b, [(HTTP.hContentType, Media.renderHeader mt)])
328 -- ** Type 'ClientConnection'
329 type ClientConnection
330 = ClientConn (R.ReaderT ClientEnv (E.ExceptT ClientError IO))
333 -- | Try clients in order, last error is preserved.
334 instance Alternative ClientConnection where
335 empty = MC.throw $ ClientError_EmptyClient
336 x <|> y = ClientConn $ do
338 MC.exec @IO (E.runExceptT $ R.runReaderT (unClientConn x) env) >>= \case
339 Right xa -> return xa
340 Left _err -> unClientConn y
344 MimeTypes ts (MimeDecodable a) =>
346 (Proxy ts -> Proxy a -> ClientRequest) ->
347 IO (Either ClientError a)
350 (`R.runReaderT` env) .
356 MimeTypes ts (MimeDecodable a) =>
357 (Proxy ts -> Proxy a -> ClientRequest) ->
359 clientConnection req = do
360 clientRes <- doClientRequest $ req (Proxy::Proxy ts) (Proxy::Proxy a)
362 fromMaybe "application/octet-stream" $
363 List.lookup "Content-Type" $
364 Client.responseHeaders clientRes
365 case matchContent @ts @(MimeDecodable a) mtRes of
366 Nothing -> MC.throw $ ClientError_UnsupportedContentType mtRes clientRes
367 Just (MimeType mt) ->
368 case mimeDecode mt $ Client.responseBody clientRes of
369 Left err -> MC.throw $ ClientError_DecodeFailure (Text.pack err) clientRes
370 Right val -> return val
372 doClientRequest :: ClientRequest -> ClientConnection ClientResponse
373 doClientRequest clientReq = do
374 ClientEnv{..} <- MC.ask
376 let req = clientRequest clientEnv_baseURI clientReq in
377 case clientEnv_cookieJar of
381 now <- Time.getCurrentTime
383 oldCookieJar <- STM.readTVar cj
384 let (newRequest, newCookieJar) =
385 Client.insertCookiesIntoRequest req oldCookieJar now
386 STM.writeTVar cj newCookieJar
389 MC.exec @IO $ catchClientConnectionError $
390 Client.httpLbs req clientEnv_manager
392 Left err -> MC.throw err
394 for_ clientEnv_cookieJar $ \cj ->
396 now <- Time.getCurrentTime
397 STM.atomically $ STM.modifyTVar' cj (fst . Client.updateCookieJar res req now)
398 let code = HTTP.statusCode $ Client.responseStatus res
399 unless (code >= 200 && code < 300) $
400 MC.throw $ ClientError_FailureResponse res
403 catchClientConnectionError :: IO a -> IO (Either ClientError a)
404 catchClientConnectionError ma =
405 Exn.catch (Right <$> ma) $ \err ->
406 return $ Left $ ClientError_ClientConnectionError err
408 -- *** Type 'ClientResponse'
410 = Client.Response BSL.ByteString
412 -- ** Type 'ClientConnectionStream'
413 type ClientConnectionStream
414 = ClientConn (R.ReaderT ClientEnv (Codensity (E.ExceptT ClientError IO)))
417 FramingDecode framing as =>
418 MC.MonadExec IO (FramingMonad as) =>
419 MimeTypes ts (MimeDecodable (FramingYield as)) =>
421 (Proxy framing -> Proxy ts -> Proxy as -> ClientRequest) ->
422 (as -> IO b) -> IO (Either ClientError b)
423 runClientStream env req k =
425 (`runCodensity` lift . k) $
426 (`R.runReaderT` env) $
428 clientConnectionStream req
430 clientConnectionStream ::
431 forall as ts framing.
432 FramingDecode framing as =>
433 MC.MonadExec IO (FramingMonad as) =>
434 MimeTypes ts (MimeDecodable (FramingYield as)) =>
435 (Proxy framing -> Proxy ts -> Proxy as -> ClientRequest) ->
436 ClientConnectionStream as
437 clientConnectionStream req = do
438 doClientRequestStream (Proxy::Proxy ts) (req Proxy Proxy Proxy) $ \(MimeType mt) clientRes ->
440 framingDecode (Proxy @framing) (mimeDecode mt) $
441 MC.exec @IO $ Client.responseBody clientRes
443 doClientRequestStream ::
445 MimeTypes ts (MimeDecodable (FramingYield as)) =>
448 ( MimeType (MimeDecodable (FramingYield as)) ->
449 Client.Response Client.BodyReader ->
450 E.ExceptT ClientError IO as ) ->
451 ClientConnectionStream as
452 doClientRequestStream Proxy clientReq k = do
453 ClientEnv{..} <- MC.ask
454 let req = clientRequest clientEnv_baseURI $ clientReq
455 ClientConn $ lift $ Codensity $ \k' ->
456 E.ExceptT $ Client.withResponse req clientEnv_manager $ \res ->
457 E.runExceptT $ do{-E.ExceptT ClientError IO-}
459 let code = HTTP.statusCode $ Client.responseStatus res
460 unless (code >= 200 && code < 300) $ do
461 err <- MC.exec @IO $ sequence $ (BSL.fromChunks <$>) . Client.brConsume <$> res
462 E.throwE $ ClientError_FailureResponse err
463 -- Check Content-Type header
465 fromMaybe "application/octet-stream" $
466 List.lookup "Content-Type" $
467 Client.responseHeaders res
468 case matchContent @ts @(MimeDecodable (FramingYield as)) contentTypeH of
470 err <- MC.exec @IO $ sequence $ (BSL.fromChunks <$>) . Client.brConsume <$> res
471 E.throwE $ ClientError_UnsupportedContentType contentTypeH err
472 Just ct -> k ct res >>= k'
474 -- *** Type 'Codensity'
475 -- | Copy from the *kan-extensions* package to avoid the dependencies.
476 newtype Codensity m a
478 { runCodensity :: forall b. (a -> m b) -> m b }
479 type instance MC.CanDo (Codensity m) (MC.EffExec eff) = 'False
480 instance Functor (Codensity k) where
481 fmap f (Codensity m) = Codensity (\k -> m (k .f))
483 instance Applicative (Codensity f) where
484 pure x = Codensity (\k -> k x)
486 Codensity f <*> Codensity g = Codensity (\bfr -> f (\ab -> g (bfr . ab)))
488 instance Monad (Codensity f) where
490 {-# INLINE return #-}
491 m >>= k = Codensity (\c -> runCodensity m (\a -> runCodensity (k a) c))
493 instance MonadTrans Codensity where
494 lift m = Codensity (m >>=)