Skip to content

Commit 360981f

Browse files
Scienceiphadej
authored andcommitted
Add client support (#26)
* preliminary client support * changed upload test to use the new client support rather than http-client * removed servant 0.15 constraint from travis build * changed <> and mempty local functions to not conflict with the Prelude typeclass ones
1 parent 2aebdb4 commit 360981f

File tree

5 files changed

+180
-28
lines changed

5 files changed

+180
-28
lines changed

.travis.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,6 @@ script:
155155
- if [ $HCNUMVER -lt 80800 ] ; then ${CABAL} v2-build $WITHCOMPILER --disable-tests --disable-benchmarks --constraint='network == 2.8.*' all | color_cabal_output ; fi
156156
# Constraint set network-3.0
157157
- if [ $HCNUMVER -lt 80800 ] ; then ${CABAL} v2-build $WITHCOMPILER --disable-tests --disable-benchmarks --constraint='network == 3.0.*' all | color_cabal_output ; fi
158-
# Constraint set servant-0.15
159-
- if [ $HCNUMVER -lt 80800 ] ; then ${CABAL} v2-build $WITHCOMPILER --disable-tests --disable-benchmarks --constraint='servant ==0.15.*' all | color_cabal_output ; fi
160158
# Constraint set servant-0.16
161159
- if [ $HCNUMVER -lt 80800 ] ; then ${CABAL} v2-build $WITHCOMPILER --disable-tests --disable-benchmarks --constraint='servant ==0.16.*' all | color_cabal_output ; fi
162160

cabal.haskell-ci

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ constraint-set network-3.0
88
ghc: >= 8.0 && <8.8
99
constraints: network == 3.0.*
1010

11-
constraint-set servant-0.15
12-
ghc: >= 8.0 && <8.8
13-
constraints: servant ==0.15.*
14-
1511
constraint-set servant-0.16
1612
ghc: >= 8.0 && <8.8
1713
constraints: servant ==0.16.*

exe/Upload.hs

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
{-# LANGUAGE DataKinds #-}
22
{-# LANGUAGE TypeOperators #-}
33
{-# LANGUAGE OverloadedStrings #-}
4+
{-# LANGUAGE TypeFamilies #-}
45

56
import Control.Concurrent
67
import Control.Monad
78
import Control.Monad.IO.Class
8-
import Data.Text.Encoding (encodeUtf8)
99
import Network.Socket (withSocketsDo)
1010
import Network.HTTP.Client hiding (Proxy)
11-
import Network.HTTP.Client.MultipartFormData
1211
import Network.Wai
1312
import Network.Wai.Handler.Warp
1413
import Servant
1514
import Servant.Multipart
1615
import System.Environment (getArgs)
16+
import Servant.Client (client, runClientM, mkClientEnv)
17+
import Servant.Client.Core (BaseUrl(BaseUrl), Scheme(Http))
1718

1819
import qualified Data.ByteString.Lazy as LBS
1920

@@ -22,9 +23,20 @@ import qualified Data.ByteString.Lazy as LBS
2223
-- pretty-prints the data it got to stdout before returning 0.
2324
type API = MultipartForm Mem (MultipartData Mem) :> Post '[JSON] Integer
2425

26+
-- We want to load our file from disk, so we need to convert
27+
-- the 'Mem's in the serverside API to 'Tmp's
28+
type family MemToTmp api where
29+
MemToTmp (a :<|> b) = MemToTmp a :<|> MemToTmp b
30+
MemToTmp (a :> b) = MemToTmp a :> MemToTmp b
31+
MemToTmp (MultipartForm Mem (MultipartData Mem)) = MultipartForm Tmp (MultipartData Tmp)
32+
MemToTmp a = a
33+
2534
api :: Proxy API
2635
api = Proxy
2736

37+
clientApi :: Proxy (MemToTmp API)
38+
clientApi = Proxy
39+
2840
-- The handler for our single endpoint.
2941
-- Its concrete type is:
3042
-- MultipartData -> Handler Integer
@@ -58,14 +70,17 @@ main = do
5870
-- we fork the server in a separate thread and send a test
5971
-- request to it from the main thread.
6072
manager <- newManager defaultManagerSettings
61-
req <- parseRequest "http://localhost:8080/"
62-
resp <- flip httpLbs manager =<< formDataBody form req
73+
boundary <- genBoundary
74+
let burl = BaseUrl Http "localhost" 8080 ""
75+
run cli = runClientM cli (mkClientEnv manager burl)
76+
resp <- run $ client clientApi (boundary, form)
6377
print resp
6478
_ -> putStrLn "Pass run to run"
6579

66-
where form =
67-
[ partBS "title" "World"
68-
, partBS "text" $ encodeUtf8 "Hello"
69-
, partFileSource "file" "./servant-multipart.cabal"
70-
, partFileSource "otherfile" "./Setup.hs"
71-
]
80+
where form = MultipartData [ Input "title" "World"
81+
, Input "text" "Hello"
82+
]
83+
[ FileData "file" "./servant-multipart.cabal"
84+
"text/plain" "./servant-multipart.cabal"
85+
, FileData "otherfile" "./Setup.hs" "text/plain" "./Setup.hs"
86+
]

servant-multipart.cabal

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,26 @@ library
2727

2828
-- ghc boot libs
2929
build-depends:
30-
base >=4.9 && <5
30+
array >=0.5.1.1 && <0.6
31+
, base >=4.9 && <5
3132
, bytestring >=0.10.8.1 && <0.11
3233
, directory >=1.3 && <1.4
3334
, text >=1.2.3.0 && <1.3
3435
, transformers >=0.5.2.0 && <0.6
36+
, random >=0.1.1 && <1.2
3537

3638
-- other dependencies
3739
build-depends:
38-
http-media >=0.7.1.3 && <0.9
39-
, lens >=4.17 && <4.19
40-
, resourcet >=1.2.2 && <1.3
41-
, servant >=0.15 && <0.17
42-
, servant-docs >=0.10 && <0.15
43-
, servant-foreign >=0.15 && <0.16
44-
, servant-server >=0.15 && <0.17
45-
, wai >=3.2.1.2 && <3.3
46-
, wai-extra >=3.0.24.3 && <3.1
40+
http-media >=0.7.1.3 && <0.9
41+
, lens >=4.17 && <4.19
42+
, resourcet >=1.2.2 && <1.3
43+
, servant >=0.16 && <0.17
44+
, servant-client-core >=0.16 && <0.17
45+
, servant-docs >=0.10 && <0.15
46+
, servant-foreign >=0.15 && <0.16
47+
, servant-server >=0.16 && <0.17
48+
, wai >=3.2.1.2 && <3.3
49+
, wai-extra >=3.0.24.3 && <3.1
4750

4851
test-suite upload
4952
type: exitcode-stdio-1.0
@@ -58,6 +61,8 @@ test-suite upload
5861
, servant
5962
, servant-multipart
6063
, servant-server
64+
, servant-client
65+
, servant-client-core
6166
, text
6267
, transformers
6368
, wai

src/Servant/Multipart.hs

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,27 +32,38 @@ module Servant.Multipart
3232
, defaultTmpBackendOptions
3333
, Input(..)
3434
, FileData(..)
35+
-- * servant-client
36+
, genBoundary
37+
, ToMultipart(..)
38+
, multipartToBody
3539
-- * servant-docs
3640
, ToMultipartSample(..)
3741
) where
3842

3943
import Control.Lens ((<>~), (&), view, (.~))
44+
import Control.Monad (replicateM)
4045
import Control.Monad.IO.Class
4146
import Control.Monad.Trans.Resource
42-
import Data.Foldable (foldMap)
47+
import Data.Array (listArray, (!))
48+
import Data.Foldable (foldMap, foldl')
4349
import Data.List (find)
4450
import Data.Maybe
4551
import Data.Monoid
4652
import Data.Text (Text, unpack)
47-
import Data.Text.Encoding (decodeUtf8)
53+
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
4854
import Data.Typeable
55+
import Network.HTTP.Media.MediaType ((//), (/:))
4956
import Network.Wai
5057
import Network.Wai.Parse
5158
import Servant
59+
import Servant.Client.Core (HasClient(..), RequestBody(RequestBodySource), setRequestBody)
5260
import Servant.Docs
5361
import Servant.Foreign
5462
import Servant.Server.Internal
63+
import Servant.Types.SourceT (SourceT(..), source, StepT(..), fromActionStep)
5564
import System.Directory
65+
import System.IO (IOMode(ReadMode), withFile)
66+
import System.Random (getStdRandom, Random(randomR))
5667

5768
import qualified Data.ByteString as SBS
5869
import qualified Data.ByteString.Lazy as LBS
@@ -240,6 +251,29 @@ class FromMultipart tag a where
240251
instance FromMultipart tag (MultipartData tag) where
241252
fromMultipart = Just
242253

254+
-- | Allows you to tell servant how to turn a more structured type
255+
-- into a 'MultipartData', which is what is actually sent by the
256+
-- client.
257+
--
258+
-- @
259+
-- data User = User { username :: Text, pic :: FilePath }
260+
--
261+
-- instance toMultipart Tmp User where
262+
-- toMultipart user = MultipartData [Input "username" $ username user]
263+
-- [FileData "pic"
264+
-- (pic user)
265+
-- "image/png"
266+
-- (pic user)
267+
-- ]
268+
-- @
269+
class ToMultipart tag a where
270+
-- | Given a value of type 'a', convert it to a
271+
-- 'MultipartData'.
272+
toMultipart :: a -> MultipartData tag
273+
274+
instance ToMultipart tag (MultipartData tag) where
275+
toMultipart = id
276+
243277
-- | Upon seeing @MultipartForm a :> ...@ in an API type,
244278
--- servant-server will hand a value of type @a@ to your handler
245279
-- assuming the request body's content type is
@@ -267,6 +301,100 @@ instance ( FromMultipart tag a
267301
$ lookupContext popts config
268302
subserver' = addMultipartHandling pbak multipartOpts subserver
269303

304+
-- | Upon seeing @MultipartForm a :> ...@ in an API type,
305+
-- servant-client will take a parameter of type @(LBS.ByteString, a)@,
306+
-- where the bytestring is the boundary to use (see 'genBoundary'), and
307+
-- replace the request body with the contents of the form.
308+
instance (ToMultipart tag a, HasClient m api, MultipartBackend tag)
309+
=> HasClient m (MultipartForm tag a :> api) where
310+
311+
type Client m (MultipartForm tag a :> api) =
312+
(LBS.ByteString, a) -> Client m api
313+
314+
clientWithRoute pm _ req (boundary, param) =
315+
clientWithRoute pm (Proxy @api) $ setRequestBody newBody newMedia req
316+
where
317+
newBody = multipartToBody boundary $ toMultipart @tag param
318+
newMedia = "multipart" // "form-data" /: ("boundary", LBS.toStrict boundary)
319+
320+
hoistClientMonad pm _ f cl = \a ->
321+
hoistClientMonad pm (Proxy @api) f (cl a)
322+
323+
-- | Generates a boundary to be used to separate parts of the multipart.
324+
-- Requires 'IO' because it is randomized.
325+
genBoundary :: IO LBS.ByteString
326+
genBoundary = LBS.pack
327+
. foldr (\x acc -> validChars ! x : acc) []
328+
<$> indices
329+
where
330+
-- the standard allows up to 70 chars, but most implementations seem to be
331+
-- in the range of 40-60, so we pick 55
332+
indices = replicateM 55 . getStdRandom $ randomR (0,73)
333+
-- '()+_,=./+?0123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
334+
validChars = listArray (0 :: Int, 73)
335+
[ 0x27, 0x28, 0x29, 0x2b, 0x5f, 0x2c, 0x3d, 0x2e
336+
, 0x2f, 0x2b, 0x3f, 0x30, 0x31, 0x32, 0x33, 0x34
337+
, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x41, 0x42
338+
, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a
339+
, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52
340+
, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a
341+
, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68
342+
, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70
343+
, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78
344+
, 0x79, 0x7a
345+
]
346+
347+
-- | Given a bytestring for the boundary, turns a `MultipartData` into
348+
-- a 'RequestBody'
349+
multipartToBody :: forall tag.
350+
MultipartBackend tag
351+
=> LBS.ByteString
352+
-> MultipartData tag
353+
-> RequestBody
354+
multipartToBody boundary mp = RequestBodySource $ files' <> source ["--", boundary, "--"]
355+
where
356+
-- at time of writing no Semigroup or Monoid instance exists for SourceT and StepT
357+
-- in releases of Servant; they are in master though
358+
(SourceT l) `mappend'` (SourceT r) = SourceT $ \k ->
359+
l $ \lstep ->
360+
r $ \rstep ->
361+
k (appendStep lstep rstep)
362+
appendStep Stop r = r
363+
appendStep (Error err) _ = Error err
364+
appendStep (Skip s) r = appendStep s r
365+
appendStep (Yield x s) r = Yield x (appendStep s r)
366+
appendStep (Effect ms) r = Effect $ (flip appendStep r <$> ms)
367+
mempty' = SourceT ($ Stop)
368+
crlf = "\r\n"
369+
lencode = LBS.fromStrict . encodeUtf8
370+
renderInput input = renderPart (lencode . iName $ input)
371+
"text/plain"
372+
""
373+
(source . pure . lencode . iValue $ input)
374+
inputs' = foldl' (\acc x -> acc `mappend'` renderInput x) mempty' (inputs mp)
375+
renderFile :: FileData tag -> SourceIO LBS.ByteString
376+
renderFile file = renderPart (lencode . fdInputName $ file)
377+
(lencode . fdFileCType $ file)
378+
((flip mappend) "\"" . mappend "; filename=\""
379+
. lencode
380+
. fdFileName $ file)
381+
(loadFile (Proxy @tag) . fdPayload $ file)
382+
files' = foldl' (\acc x -> acc `mappend'` renderFile x) inputs' (files mp)
383+
renderPart name contentType extraParams payload =
384+
source [ "--"
385+
, boundary
386+
, crlf
387+
, "Content-Disposition: form-data; name=\""
388+
, name
389+
, "\""
390+
, extraParams
391+
, crlf
392+
, "Content-Type: "
393+
, contentType
394+
, crlf
395+
, crlf
396+
] `mappend'` payload `mappend'` source [crlf]
397+
270398
-- Try and extract the request body as multipart/form-data,
271399
-- returning the data as well as the resourcet InternalState
272400
-- that allows us to properly clean up the temporary files
@@ -353,6 +481,8 @@ class MultipartBackend tag where
353481
-> IO SBS.ByteString
354482
-> IO (MultipartResult tag)
355483

484+
loadFile :: Proxy tag -> MultipartResult tag -> SourceIO LBS.ByteString
485+
356486
defaultBackendOptions :: Proxy tag -> MultipartBackendOptions tag
357487

358488
-- | Tag for data stored as a temporary file
@@ -366,6 +496,13 @@ instance MultipartBackend Tmp where
366496
type MultipartBackendOptions Tmp = TmpBackendOptions
367497

368498
defaultBackendOptions _ = defaultTmpBackendOptions
499+
-- streams the file from disk
500+
loadFile _ fp =
501+
SourceT $ \k ->
502+
withFile fp ReadMode $ \hdl ->
503+
k (readHandle hdl)
504+
where
505+
readHandle hdl = fromActionStep LBS.null (LBS.hGet hdl 4096)
369506
backend _ opts = tmpBackend
370507
where
371508
tmpBackend = tempFileBackEndOpts (getTmpDir opts) (filenamePat opts)
@@ -375,6 +512,7 @@ instance MultipartBackend Mem where
375512
type MultipartBackendOptions Mem = ()
376513

377514
defaultBackendOptions _ = ()
515+
loadFile _ = source . pure
378516
backend _ opts _ = lbsBackEnd
379517

380518
-- | Configuration for the temporary file based backend.

0 commit comments

Comments
 (0)