@@ -32,27 +32,38 @@ module Servant.Multipart
32
32
, defaultTmpBackendOptions
33
33
, Input (.. )
34
34
, FileData (.. )
35
+ -- * servant-client
36
+ , genBoundary
37
+ , ToMultipart (.. )
38
+ , multipartToBody
35
39
-- * servant-docs
36
40
, ToMultipartSample (.. )
37
41
) where
38
42
39
43
import Control.Lens ((<>~) , (&) , view , (.~) )
44
+ import Control.Monad (replicateM )
40
45
import Control.Monad.IO.Class
41
46
import Control.Monad.Trans.Resource
42
- import Data.Foldable (foldMap )
47
+ import Data.Array (listArray , (!) )
48
+ import Data.Foldable (foldMap , foldl' )
43
49
import Data.List (find )
44
50
import Data.Maybe
45
51
import Data.Monoid
46
52
import Data.Text (Text , unpack )
47
- import Data.Text.Encoding (decodeUtf8 )
53
+ import Data.Text.Encoding (decodeUtf8 , encodeUtf8 )
48
54
import Data.Typeable
55
+ import Network.HTTP.Media.MediaType ((//) , (/:) )
49
56
import Network.Wai
50
57
import Network.Wai.Parse
51
58
import Servant
59
+ import Servant.Client.Core (HasClient (.. ), RequestBody (RequestBodySource ), setRequestBody )
52
60
import Servant.Docs
53
61
import Servant.Foreign
54
62
import Servant.Server.Internal
63
+ import Servant.Types.SourceT (SourceT (.. ), source , StepT (.. ), fromActionStep )
55
64
import System.Directory
65
+ import System.IO (IOMode (ReadMode ), withFile )
66
+ import System.Random (getStdRandom , Random (randomR ))
56
67
57
68
import qualified Data.ByteString as SBS
58
69
import qualified Data.ByteString.Lazy as LBS
@@ -240,6 +251,29 @@ class FromMultipart tag a where
240
251
instance FromMultipart tag (MultipartData tag ) where
241
252
fromMultipart = Just
242
253
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
+
243
277
-- | Upon seeing @MultipartForm a :> ...@ in an API type,
244
278
--- servant-server will hand a value of type @a@ to your handler
245
279
-- assuming the request body's content type is
@@ -267,6 +301,100 @@ instance ( FromMultipart tag a
267
301
$ lookupContext popts config
268
302
subserver' = addMultipartHandling pbak multipartOpts subserver
269
303
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
+
270
398
-- Try and extract the request body as multipart/form-data,
271
399
-- returning the data as well as the resourcet InternalState
272
400
-- that allows us to properly clean up the temporary files
@@ -353,6 +481,8 @@ class MultipartBackend tag where
353
481
-> IO SBS. ByteString
354
482
-> IO (MultipartResult tag )
355
483
484
+ loadFile :: Proxy tag -> MultipartResult tag -> SourceIO LBS. ByteString
485
+
356
486
defaultBackendOptions :: Proxy tag -> MultipartBackendOptions tag
357
487
358
488
-- | Tag for data stored as a temporary file
@@ -366,6 +496,13 @@ instance MultipartBackend Tmp where
366
496
type MultipartBackendOptions Tmp = TmpBackendOptions
367
497
368
498
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 )
369
506
backend _ opts = tmpBackend
370
507
where
371
508
tmpBackend = tempFileBackEndOpts (getTmpDir opts) (filenamePat opts)
@@ -375,6 +512,7 @@ instance MultipartBackend Mem where
375
512
type MultipartBackendOptions Mem = ()
376
513
377
514
defaultBackendOptions _ = ()
515
+ loadFile _ = source . pure
378
516
backend _ opts _ = lbsBackEnd
379
517
380
518
-- | Configuration for the temporary file based backend.
0 commit comments