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.Kind (Constraint)
21 import Data.Maybe (Maybe(..), maybe, fromMaybe)
22 import Data.Ord (Ord(..))
23 import Data.Proxy (Proxy(..))
24 import Data.Semigroup (Semigroup(..))
25 import Data.String (IsString(..), String)
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.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 returned ('callers') (one per number of alternative routes)
60 -- separated by (':!:').
62 -- 'Client' is analogous to a printf using the API as a format customized for HTTP routing.
63 newtype Client callers k
65 { unClient :: (ClientModifier -> k) -> callers
68 -- | @'client' callers@ returns the 'ClientRequest'
69 -- builders from the given API.
70 client :: Client callers ClientRequest -> callers
71 client (Client callers) = callers ($ 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_Raw Client where
117 type RawConstraint Client = ()
118 type RawArgs Client = HTTP.Method -> Proxy ('[]::[*]) -> Proxy ClientResponse -> ClientRequest
119 type Raw Client = ClientRequest
120 raw = Client $ \k meth Proxy Proxy -> k $ \req ->
121 req{ clientReq_method = meth }
122 instance HTTP_BasicAuth Client where
123 type BasicAuthConstraint Client a = ()
124 type BasicAuthArgs Client a k = BasicAuthUser -> BasicAuthPass -> k
125 basicAuth' realm = Client $ \k user pass -> k $ \req ->
126 req{ clientReq_headers =
127 let user_pass = Text.encodeUtf8 $ user<>":"<>pass in
128 clientReq_headers req Seq.|>
129 ( HTTP.hAuthorization
130 , Web.toHeader $ "Basic " <> BS64.encode user_pass
133 instance HTTP_Query Client where
134 type QueryConstraint Client a = Web.ToHttpApiData a
135 queryParams' n = Client $ \k vs -> k $ \req ->
136 req{ clientReq_queryString =
137 clientReq_queryString req <>
138 fromList ((\v -> (n, Just $ Text.encodeUtf8 $ Web.toQueryParam v)) <$> vs) }
139 instance HTTP_Version Client where
140 version v = Client $ \k -> k $ \req ->
141 req{clientReq_httpVersion = v}
143 -- ** Type 'ClientBodyArg'
144 newtype ClientBodyArg (ts::[*]) a = ClientBodyArg a
145 instance HTTP_Body Client where
146 type BodyConstraint Client a ts = MimeTypes ts (MimeEncodable a)
147 type BodyArg Client a ts = ClientBodyArg ts a
150 BodyConstraint repr a ts =>
152 repr (BodyArg repr a ts -> k) k
153 body'= Client $ \k (ClientBodyArg a) -> k $ \req ->
155 case NonEmpty.head (mimeTypes @ts) :: MimeType (MimeEncodable a) of
156 MimeType (mt::Proxy t) ->
158 ( Client.RequestBodyLBS $ mimeEncode mt a
162 -- ** Type 'ClientBodyStreamArg'
163 newtype ClientBodyStreamArg framing (ts::[*]) as = ClientBodyStreamArg as
164 instance HTTP_BodyStream Client where
165 type BodyStreamConstraint Client as ts framing =
166 ( FramingEncode framing as
167 , MimeTypes ts (MimeEncodable (FramingYield as))
169 type BodyStreamArg Client as ts framing = ClientBodyStreamArg framing ts as
171 forall as ts framing k repr.
172 BodyStreamConstraint repr as ts framing =>
174 repr (BodyStreamArg repr as ts framing -> k) k
175 bodyStream'= Client $ \k (ClientBodyStreamArg as) -> k $ \req ->
177 case NonEmpty.head (mimeTypes @ts) :: MimeType (MimeEncodable (FramingYield as)) of
178 MimeType (mt::Proxy t) ->
179 Just $ (, mediaType @t) $
180 Client.RequestBodyStreamChunked $ \write -> do
181 let enc = framingEncode (Proxy @framing) (mimeEncode mt)
183 ioref <- IO.newIORef ini
186 Left _end -> return ""
188 | BSL.null bsl -> enc next >>= go
189 -- NOTE: skip all null 'ByteString' because it would end the stream.
190 | otherwise -> enc next >>= \n -> do
191 IO.writeIORef ioref n
192 return $ BSL.toStrict bsl
193 -- NOTE: strictify the 'bsl' 'ByteString'
194 -- instead of iterating on its chunks,
195 -- in order to diminish the number of 'Client.connectionWrite'.
196 write $ IO.readIORef ioref >>= go
199 instance HTTP_Response Client where
200 type ResponseConstraint Client a ts = MimeTypes ts (MimeDecodable a)
201 type ResponseArgs Client a ts = Proxy ts -> Proxy a -> ClientRequest
202 type Response Client = ClientRequest
205 ResponseConstraint repr a ts =>
208 repr (ResponseArgs repr a ts)
210 response meth = Client $ \k Proxy Proxy -> k $ \req ->
212 { clientReq_method = meth
214 clientReq_accept req <>
215 fromList (toList $ mediaTypes @ts @(MimeDecodable a))
218 instance HTTP_ResponseStream Client where
219 type ResponseStreamConstraint Client as ts framing =
220 MimeTypes ts (MimeDecodable (FramingYield as))
221 type ResponseStreamArgs Client as ts framing =
226 type ResponseStream Client = ClientRequest
229 forall as ts framing repr.
230 ResponseStreamConstraint repr as ts framing =>
233 repr (ResponseStreamArgs repr as ts framing)
234 (ResponseStream repr)
235 responseStream meth = Client $ \k Proxy Proxy Proxy -> k $ \req ->
237 { clientReq_method = meth
239 clientReq_accept req <>
240 fromList (toList $ mediaTypes @ts @(MimeDecodable (FramingYield as)))
243 instance Web.ToHttpApiData BS.ByteString where
244 toUrlPiece = Web.toUrlPiece . Text.decodeUtf8
247 -- * Type 'ClientConn'
248 -- | A monadic connection from a client to a server.
249 -- It is specialized in 'ClientConnection' and 'ClientConnectionStream'.
251 -- NOTE: no 'Monad' transformer is put within this newtype
252 -- to let @monad-classes@ handle all the |lift|ing.
253 newtype ClientConn m a
255 { unClientConn :: m a
256 } deriving (Functor, Applicative, Monad)
257 -- | All supported effects are handled by nested 'Monad's.
258 type instance MC.CanDo (ClientConn m) eff = 'False
259 instance MonadTrans ClientConn where
262 -- ** Type 'ClientEnv'
265 { clientEnv_manager :: Client.Manager
266 , clientEnv_baseURI :: URI
267 , clientEnv_cookieJar :: Maybe (STM.TVar Client.CookieJar)
269 clientEnv :: Client.Manager -> URI -> ClientEnv
270 clientEnv clientEnv_manager clientEnv_baseURI =
272 { clientEnv_cookieJar = Nothing
276 -- ** Type 'ClientError'
278 -- | The server returned an error response
279 = ClientError_FailureResponse ClientResponse
280 -- | The body could not be decoded at the expected type
281 | ClientError_DecodeFailure String ClientResponse
282 -- | The content-type of the response is not supported
283 | ClientError_UnsupportedContentType BS.ByteString ClientResponse
284 -- | There was a connection error, and no response was received
285 | ClientError_ConnectionError Client.HttpException
286 -- | 'ClientConn' is 'empty'
287 | ClientError_EmptyClient
288 deriving (Eq, Show{-, Generic, Typeable-})
289 instance Exn.Exception ClientError
290 instance Eq Client.HttpException where
291 (==) = (==) `on` show
293 -- ** Type 'ClientRequest'
296 { clientReq_httpVersion :: HTTP.HttpVersion
297 , clientReq_method :: HTTP.Method
298 , clientReq_path :: BSB.Builder
299 , clientReq_queryString :: Seq.Seq HTTP.QueryItem
300 , clientReq_accept :: Seq.Seq Media.MediaType
301 , clientReq_headers :: Seq.Seq HTTP.Header
302 , clientReq_body :: Maybe (Client.RequestBody, Media.MediaType)
304 instance Show ClientRequest where
305 show _ = "ClientRequest"
307 clientRequest :: URI -> ClientRequest -> Client.Request
308 clientRequest baseURI req =
309 Client.defaultRequest
310 { Client.method = clientReq_method req
311 , Client.host = maybe "" (fromString . URI.uriRegName) $ URI.uriAuthority baseURI
312 , Client.port = case URI.uriPort <$> URI.uriAuthority baseURI of
313 Just (':':p) | Just port <- readMaybe p -> port
315 , Client.path = BSL.toStrict $ fromString (URI.uriPath baseURI) <> BSB.toLazyByteString (clientReq_path req)
316 , Client.queryString = HTTP.renderQuery True . toList $ clientReq_queryString req
317 , Client.requestHeaders = acceptHeader <> contentTypeHeader <> headers
319 , Client.secure = URI.uriScheme baseURI == "https"
322 headers = List.filter (\(h, _) -> h/="Accept" && h/="Content-Type") $
323 toList $ clientReq_headers req
325 acceptHeader | null hs = []
326 | otherwise = [("Accept", Media.renderHeader hs)]
328 hs = toList $ clientReq_accept req
330 (requestBody, contentTypeHeader) =
331 case clientReq_body req of
332 Nothing -> (Client.RequestBodyBS "", [])
333 Just (b, mt) -> (b, [(HTTP.hContentType, Media.renderHeader mt)])
335 -- ** Type 'ClientConnection'
336 type ClientConnection
337 = ClientConn (R.ReaderT ClientEnv (E.ExceptT ClientError IO))
340 -- | Try clients in order, last error is preserved.
341 instance Alternative ClientConnection where
342 empty = MC.throw $ ClientError_EmptyClient
343 x <|> y = ClientConn $ do
345 MC.exec @IO (E.runExceptT $ R.runReaderT (unClientConn x) env) >>= \case
346 Right xa -> return xa
347 Left _err -> unClientConn y
351 ClientConnectionConstraint a ts =>
352 ClientConnectionClass a ts =>
354 (Proxy ts -> Proxy a -> ClientRequest) ->
355 IO (Either ClientError a)
358 (`R.runReaderT` env) .
362 -- ** Class 'ClientConnectionClass'
363 -- | 'clientConnection' is different when 'ts' is empty:
364 -- no 'mimeDecode' is performed.
365 -- This is used by the 'raw' combinator.
366 class ClientConnectionClass a (ts::[*]) where
367 type ClientConnectionConstraint a ts :: Constraint
369 ClientConnectionConstraint a ts =>
370 (Proxy ts -> Proxy a -> ClientRequest) ->
372 instance ClientConnectionClass ClientResponse '[] where
373 type ClientConnectionConstraint ClientResponse '[] = ()
374 clientConnection req = do
375 clientRes <- doClientRequest $ req
377 (Proxy::Proxy ClientResponse)
379 instance ClientConnectionClass a (t ': ts) where
380 type ClientConnectionConstraint a (t ': ts) =
381 MimeTypes (t ': ts) (MimeDecodable a)
382 clientConnection req = do
383 clientRes <- doClientRequest $ req (Proxy::Proxy (t ': ts)) (Proxy::Proxy a)
385 fromMaybe "application/octet-stream" $
386 List.lookup "Content-Type" $
387 Client.responseHeaders clientRes
388 case matchContent @(t ': ts) @(MimeDecodable a) mtRes of
389 Nothing -> MC.throw $ ClientError_UnsupportedContentType mtRes clientRes
390 Just (MimeType mt) ->
391 case mimeDecode mt $ Client.responseBody clientRes of
392 Left err -> MC.throw $ ClientError_DecodeFailure err clientRes
395 doClientRequest :: ClientRequest -> ClientConnection ClientResponse
396 doClientRequest clientReq = do
397 ClientEnv{..} <- MC.ask
399 let req = clientRequest clientEnv_baseURI clientReq in
400 case clientEnv_cookieJar of
404 now <- Time.getCurrentTime
406 oldCookieJar <- STM.readTVar cj
407 let (newRequest, newCookieJar) =
408 Client.insertCookiesIntoRequest req oldCookieJar now
409 STM.writeTVar cj newCookieJar
412 MC.exec @IO $ catchClientConnectionError $
413 Client.httpLbs req clientEnv_manager
415 Left err -> MC.throw err
417 for_ clientEnv_cookieJar $ \cj ->
419 now <- Time.getCurrentTime
420 STM.atomically $ STM.modifyTVar' cj (fst . Client.updateCookieJar res req now)
421 let code = HTTP.statusCode $ Client.responseStatus res
422 unless (code >= 200 && code < 300) $
423 MC.throw $ ClientError_FailureResponse res
426 catchClientConnectionError :: IO a -> IO (Either ClientError a)
427 catchClientConnectionError ma =
428 Exn.catch (Right <$> ma) $ \err ->
429 return $ Left $ ClientError_ConnectionError err
431 -- *** Type 'ClientResponse'
433 = Client.Response BSL.ByteString
435 -- ** Type 'ClientConnectionStream'
436 type ClientConnectionStream
437 = ClientConn (R.ReaderT ClientEnv (Codensity (E.ExceptT ClientError IO)))
440 FramingDecode framing as =>
441 MC.MonadExec IO (FramingMonad as) =>
442 MimeTypes ts (MimeDecodable (FramingYield as)) =>
444 (Proxy framing -> Proxy ts -> Proxy as -> ClientRequest) ->
445 (as -> IO b) -> IO (Either ClientError b)
446 runClientStream env req k =
448 (`runCodensity` lift . k) $
449 (`R.runReaderT` env) $
451 clientConnectionStream req
453 clientConnectionStream ::
454 forall as ts framing.
455 FramingDecode framing as =>
456 MC.MonadExec IO (FramingMonad as) =>
457 MimeTypes ts (MimeDecodable (FramingYield as)) =>
458 (Proxy framing -> Proxy ts -> Proxy as -> ClientRequest) ->
459 ClientConnectionStream as
460 clientConnectionStream req = do
461 doClientRequestStream (Proxy::Proxy ts) (req Proxy Proxy Proxy) $ \(MimeType mt) clientRes ->
463 framingDecode (Proxy @framing) (mimeDecode mt) $
464 MC.exec @IO $ Client.responseBody clientRes
466 doClientRequestStream ::
468 MimeTypes ts (MimeDecodable (FramingYield as)) =>
471 ( MimeType (MimeDecodable (FramingYield as)) ->
472 Client.Response Client.BodyReader ->
473 E.ExceptT ClientError IO as ) ->
474 ClientConnectionStream as
475 doClientRequestStream Proxy clientReq k = do
476 ClientEnv{..} <- MC.ask
477 let req = clientRequest clientEnv_baseURI $ clientReq
478 ClientConn $ lift $ Codensity $ \k' ->
479 E.ExceptT $ Client.withResponse req clientEnv_manager $ \res ->
480 E.runExceptT $ do{-E.ExceptT ClientError IO-}
482 let code = HTTP.statusCode $ Client.responseStatus res
483 unless (code >= 200 && code < 300) $ do
484 err <- MC.exec @IO $ sequence $ (BSL.fromChunks <$>) . Client.brConsume <$> res
485 E.throwE $ ClientError_FailureResponse err
486 -- Check Content-Type header
488 fromMaybe "application/octet-stream" $
489 List.lookup "Content-Type" $
490 Client.responseHeaders res
491 case matchContent @ts @(MimeDecodable (FramingYield as)) contentTypeH of
493 err <- MC.exec @IO $ sequence $ (BSL.fromChunks <$>) . Client.brConsume <$> res
494 E.throwE $ ClientError_UnsupportedContentType contentTypeH err
495 Just ct -> k ct res >>= k'
497 -- *** Type 'Codensity'
498 -- | Copy from the @kan-extensions@ package to avoid the dependencies.
499 newtype Codensity m a
501 { runCodensity :: forall b. (a -> m b) -> m b }
502 type instance MC.CanDo (Codensity m) (MC.EffExec eff) = 'False
503 instance Functor (Codensity k) where
504 fmap f (Codensity m) = Codensity (\k -> m (k .f))
506 instance Applicative (Codensity f) where
507 pure x = Codensity (\k -> k x)
509 Codensity f <*> Codensity g = Codensity (\bfr -> f (\ab -> g (bfr . ab)))
511 instance Monad (Codensity f) where
513 {-# INLINE return #-}
514 m >>= k = Codensity (\c -> runCodensity m (\a -> runCodensity (k a) c))
516 instance MonadTrans Codensity where
517 lift m = Codensity (m >>=)