servant-multipart (empty) → 0.10
raw patch · 5 files changed
+515/−0 lines, 5 filesdep +basedep +bytestringdep +directorysetup-changed
Dependencies added: base, bytestring, directory, http-client, http-media, network, resourcet, servant, servant-multipart, servant-server, text, transformers, wai, wai-extra, warp
Files
- LICENSE +30/−0
- Setup.hs +2/−0
- exe/Upload.hs +65/−0
- servant-multipart.cabal +54/−0
- src/Servant/Multipart.hs +364/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Alp Mestanogullari (c) 2016++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Alp Mestanogullari nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ exe/Upload.hs view
@@ -0,0 +1,65 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE OverloadedStrings #-}++import Control.Concurrent+import Control.Monad+import Control.Monad.IO.Class+import Data.Text.Encoding (encodeUtf8)+import Network (withSocketsDo)+import Network.HTTP.Client hiding (Proxy)+import Network.HTTP.Client.MultipartFormData+import Network.Wai+import Network.Wai.Handler.Warp+import Servant+import Servant.Multipart++-- Our API, which consists in a single POST endpoint at /+-- that takes a multipart/form-data request body and+-- pretty-prints the data it got to stdout before returning 0.+type API = MultipartForm MultipartData :> Post '[JSON] Integer++api :: Proxy API+api = Proxy++-- The handler for our single endpoint.+-- Its concrete type is:+-- MultipartData -> Handler Integer+--+-- MultipartData consists in textual inputs,+-- accessible through its "inputs" field, as well+-- as files, accessible through its "files" field.+upload :: Server API+upload multipartData = do+ liftIO $ do+ putStrLn "Inputs:"+ forM_ (inputs multipartData) $ \input ->+ putStrLn $ " " ++ show (iName input)+ ++ " -> " ++ show (iValue input)++ forM_ (files multipartData) $ \file -> do+ content <- readFile (fdFilePath file)+ putStrLn $ "Content of " ++ show (fdFileName file)+ ++ " at " ++ fdFilePath file+ putStrLn content+ return 0++startServer :: IO ()+startServer = run 8080 (serve api upload)++main :: IO ()+main = withSocketsDo $ do+ forkIO startServer+ -- we fork the server in a separate thread and send a test+ -- request to it from the main thread.+ manager <- newManager defaultManagerSettings+ req <- parseRequest "http://localhost:8080/"+ resp <- flip httpLbs manager =<< formDataBody form req+ print resp++ where form =+ [ partBS "title" "World"+ , partBS "text" $ encodeUtf8 "Hello"+ , partFileSource "file" "./servant-multipart.cabal"+ , partFileSource "otherfile" "./Setup.hs"+ ]
+ servant-multipart.cabal view
@@ -0,0 +1,54 @@+name: servant-multipart+version: 0.10+synopsis: multipart/form-data (e.g file upload) support for servant+description: Please see README.md+homepage: https://github.com/haskell-servant/servant-multipart#readme+license: BSD3+license-file: LICENSE+author: Alp Mestanogullari+maintainer: alpmestan@gmail.com+copyright: 2016-2017 Alp Mestanogullari+category: Web+build-type: Simple+cabal-version: >=1.10+tested-with:+ GHC==7.8.4,+ GHC==7.10.3,+ GHC==8.0.2++library+ hs-source-dirs: src+ exposed-modules: Servant.Multipart+ build-depends:+ base >= 4.7 && < 5,+ bytestring >= 0.10 && <0.11,+ directory,+ http-media >= 0.6 && <0.7,+ resourcet >=1.1 && <1.2,+ servant >=0.10 && <0.11,+ servant-server >=0.10 && <0.11,+ text >=1.2 && <1.3,+ transformers >=0.3 && <0.6,+ wai >= 3.2 && <3.3,+ wai-extra >=3.0 && <3.1+ default-language: Haskell2010++executable upload+ hs-source-dirs: exe+ main-is: Upload.hs+ default-language: Haskell2010+ build-depends:+ base,+ http-client,+ network,+ servant,+ servant-multipart,+ servant-server,+ text,+ transformers,+ warp,+ wai++source-repository head+ type: git+ location: https://github.com/haskell-servent/servant-multipart
+ src/Servant/Multipart.hs view
@@ -0,0 +1,364 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE TypeOperators #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeSynonymInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+-- | @multipart/form-data@ support for servant.+--+-- This is mostly useful for adding file upload support to+-- an API. See haddocks of 'MultipartForm' for an introduction.+module Servant.Multipart+ ( MultipartForm+ , MultipartData(..)+ , FromMultipart(..)+ , lookupInput+ , lookupFile+ , MultipartOptions(..)+ , defaultMultipartOptions+ , TmpBackendOptions(..)+ , defaultTmpBackendOptions+ , Input(..)+ , FileData(..)+ ) where++import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Resource+import Data.ByteString.Lazy (ByteString)+import Data.Function+import Data.List (find)+import Data.Maybe+import Data.Text (Text)+import Data.Text.Encoding (decodeUtf8)+import Data.Typeable+import Network.HTTP.Media ((//))+import Network.Wai+import Network.Wai.Parse+import Servant+import Servant.Server.Internal+import System.Directory+import System.IO++import qualified Data.ByteString as SBS+import qualified Data.ByteString.Lazy as LBS++-- | Combinator for specifying a @multipart/form-data@ request+-- body, typically (but not always) issued from an HTML @\<form\>@.+--+-- @multipart/form-data@ can't be made into an ordinary content+-- type for now in servant because it doesn't just decode the+-- request body from some format but also performs IO in the case+-- of writing the uploaded files to disk, e.g in @/tmp@, which is+-- not compatible with servant's vision of a content type as things+-- stand now. This also means that 'MultipartForm' can't be used in+-- conjunction with 'ReqBody' in an endpoint.+--+-- The 'a' type parameter represents the Haskell type to which+-- you are going to decode the multipart data to, where the+-- multipart data consists in all the usual form inputs along+-- with the files sent along through @\<input type="file"\>@+-- fields in the form.+--+-- One option provided out of the box by this library is to decode+-- to 'MultipartData'.+--+-- Example:+--+-- @+-- type API = MultipartForm MultipartData :> Post '[PlainText] String+--+-- api :: Proxy API+-- api = Proxy+--+-- server :: MultipartData -> Handler String+-- server multipartData = return str+--+-- where str = "The form was submitted with "+-- ++ show nInputs ++ " textual inputs and "+-- ++ show nFiles ++ " files."+-- nInputs = length (inputs multipartData)+-- nFiles = length (files multipartData)+-- @+--+-- You can alternatively provide a 'FromMultipart' instance+-- for some type of yours, allowing you to regroup data+-- into a structured form and potentially selecting+-- a subset of the entire form data that was submitted.+--+-- Example, where we only look extract one input, /username/,+-- and one file, where the corresponding input field's /name/+-- attribute was set to /pic/:+--+-- @+-- data User = User { username :: Text, pic :: FilePath }+--+-- instance FromMultipart User where+-- fromMultipart multipartData =+-- User \<$\> lookupInput "username" multipartData+-- \<*\> fmap fileContent (lookupFile "pic" multipartData)+--+-- type API = MultipartForm User :> Post '[PlainText] String+--+-- server :: User -> Handler String+-- server usr = return str+--+-- where str = username usr ++ "'s profile picture"+-- ++ " got temporarily uploaded to "+-- ++ pic usr ++ " and will be removed from there "+-- ++ " after this handler has run."+-- @+--+-- Note that the behavior of this combinator is configurable,+-- by using 'serveWith' from servant-server instead of 'serve',+-- which takes an additional 'Context' argument. It simply is an+-- heterogeneous list where you can for example store+-- a value of type 'MultipartOptions' that has the configuration that+-- you want, which would then get picked up by servant-multipart.+--+-- __Important__: as mentionned in the example above,+-- the file paths point to temporary files which get removed+-- after your handler has run, if they are still there. It is+-- therefore recommended to move or copy them somewhere in your+-- handler code if you need to keep the content around.+data MultipartForm a++-- | What servant gets out of a @multipart/form-data@ form submission.+--+-- The 'inputs' field contains a list of textual 'Input's, where+-- each input for which a value is provided gets to be in this list,+-- represented by the input name and the input value. See haddocks for+-- 'Input'.+--+-- The 'files' field contains a list of files that were sent along with the+-- other inputs in the form. Each file is represented by a value of type+-- 'FileData' which among other things contains the path to the temporary file+-- (to be removed when your handler is done running) with a given uploaded+-- file's content. See haddocks for 'FileData'.+data MultipartData = MultipartData+ { inputs :: [Input]+ , files :: [FileData]+ }++-- TODO: this is specific to Tmp. we need a version that+-- can handle Mem as well.+fromRaw :: ([Param], [File FilePath]) -> MultipartData+fromRaw (inputs, files) = MultipartData is fs++ where is = map (\(name, val) -> Input (dec name) (dec val)) inputs+ fs = map toFile files++ toFile :: File FilePath -> FileData+ toFile (iname, fileinfo) =+ FileData (dec iname)+ (dec $ fileName fileinfo)+ (dec $ fileContentType fileinfo)+ (fileContent fileinfo)++ dec = decodeUtf8++-- | Representation for an uploaded file, usually resulting from+-- picking a local file for an HTML input that looks like+-- @\<input type="file" name="somefile" /\>@.+data FileData = FileData+ { fdInputName :: Text -- ^ @name@ attribute of the corresponding+ -- HTML @\<input\>@+ , fdFileName :: Text -- ^ name of the file on the client's disk+ , fdFileCType :: Text -- ^ MIME type for the file+ , fdFilePath :: FilePath -- ^ path to the temporary file that has the+ -- content of the user's original file. Only+ -- valid during the execution of your handler as+ -- it gets removed right after, which means you+ -- really want to move or copy it in your handler.+ } deriving (Eq, Show)++-- | Lookup a file input with the given @name@ attribute.+lookupFile :: Text -> MultipartData -> Maybe FileData+lookupFile iname = find ((==iname) . fdInputName) . files++-- | Representation for a textual input (any @\<input\>@ type but @file@).+--+-- @\<input name="foo" value="bar"\ />@ would appear as @'Input' "foo" "bar"@.+data Input = Input+ { iName :: Text -- ^ @name@ attribute of the input+ , iValue :: Text -- ^ value given for that input+ } deriving (Eq, Show)++-- | Lookup a textual input with the given @name@ attribute.+lookupInput :: Text -> MultipartData -> Maybe Text+lookupInput iname = fmap iValue . find ((==iname) . iName) . inputs++-- | 'MultipartData' is the type representing+-- @multipart/form-data@ form inputs. Sometimes+-- you may instead want to work with a more structured type+-- of yours that potentially selects only a fraction of+-- the data that was submitted, or just reshapes it to make+-- it easier to work with. The 'FromMultipart' class is exactly+-- what allows you to tell servant how to turn "raw" multipart+-- data into a value of your nicer type.+--+-- @+-- data User = User { username :: Text, pic :: FilePath }+--+-- instance FromMultipart User where+-- fromMultipart form =+-- User \<$\> lookupInput "username" (inputs form)+-- \<*\> fmap fdFilePath (lookupFile "pic" $ files form)+-- @+class FromMultipart a where+ -- | Given a value of type 'MultipartData', which consists+ -- in a list of textual inputs and another list for+ -- files, try to extract a value of type @a@. When+ -- extraction fails, servant errors out with status code 400.+ fromMultipart :: MultipartData -> Maybe a++instance FromMultipart MultipartData where+ fromMultipart = Just++-- | Upon seeing @MultipartForm a :> ...@ in an API type,+--- servant-server will hand a value of type @a@ to your handler+-- assuming the request body's content type is+-- @multipart/form-data@ and the call to 'fromMultipart' succeeds.+instance ( FromMultipart a+ , LookupContext config MultipartOptions+ , HasServer sublayout config )+ => HasServer (MultipartForm a :> sublayout) config where++ type ServerT (MultipartForm a :> sublayout) m =+ a -> ServerT sublayout m++ route Proxy config subserver =+ route psub config subserver'+ where+ psub = Proxy :: Proxy sublayout+ pbak = Proxy :: Proxy b+ popts = Proxy :: Proxy MultipartOptions+ multipartOpts = fromMaybe defaultMultipartOptions+ $ lookupContext popts config+ subserver' = addMultipartHandling multipartOpts subserver++-- Try and extract the request body as multipart/form-data,+-- returning the data as well as the resourcet InternalState+-- that allows us to properly clean up the temporary files+-- later on.+check :: MultipartOptions -> DelayedIO MultipartData+check opts = withRequest $ \request -> do+ st <- liftResourceT getInternalState+ rawData <- liftIO $ parseRequestBodyEx parseOpts (tmpBackend opts st) request+ return (fromRaw rawData)+ where parseOpts = generalOptions opts++-- Add multipart extraction support to a Delayed.+addMultipartHandling :: FromMultipart multipart+ => MultipartOptions+ -> Delayed env (multipart -> a)+ -> Delayed env a+addMultipartHandling opts subserver =+ addBodyCheck subserver contentCheck bodyCheck+ where+ contentCheck = withRequest $ \request ->+ fuzzyMultipartCTCheck (contentTypeH request)++ bodyCheck () = do+ mpd <- check opts :: DelayedIO MultipartData+ case fromMultipart mpd of+ Nothing -> liftRouteResult $ FailFatal+ err400 { errBody = "fromMultipart returned Nothing" }+ Just x -> return x++ contentTypeH req = fromMaybe "application/octet-stream" $+ lookup "Content-Type" (requestHeaders req)++-- Check that the content type is one of:+-- - application/x-www-form-urlencoded+-- - multipart/form-data; boundary=something+fuzzyMultipartCTCheck :: SBS.ByteString -> DelayedIO ()+fuzzyMultipartCTCheck ct+ | ctMatches = return ()+ | otherwise = delayedFailFatal err400 {+ errBody = "The content type of the request body is not in application/x-www-form-urlencoded or multipart/form-data"+ }++ where (ctype, attrs) = parseContentType ct+ ctMatches = case ctype of+ "application/x-www-form-urlencoded" -> True+ "multipart/form-data" | Just _bound <- lookup "boundary" attrs -> True+ _ -> False++tmpBackend :: MultipartOptions+ -> InternalState+ -> ignored1+ -> ignored2+ -> IO SBS.ByteString+ -> IO FilePath+tmpBackend opts =+ tempFileBackEndOpts (getTmpDir tmpOpts) (filenamePat tmpOpts)+ where+ tmpOpts = tmpOptions opts++-- | Global options for configuring how the+-- server should handle multipart data.+--+-- 'generalOptions' lets you specify mostly multipart parsing+-- related options, such as the maximum file size, while+-- 'tmpOptions' lets you configure aspects specific to+-- the temporary file backend. See haddocks for+-- 'ParseRequestBodyOptions' and 'TmpBackendOptions' respectively+-- for more information on what you can tweak.+data MultipartOptions = MultipartOptions+ { generalOptions :: ParseRequestBodyOptions+ , tmpOptions :: TmpBackendOptions+ }++-- | Configuration for the temporary file based backend.+--+-- You can configure the way servant-multipart gets its hands+-- on a temporary directory (defaults to 'getTemporaryDirectory')+-- as well as the filename pattern used for generating the temporary files+-- (defaults to calling them /servant-multipartXXX.buf/, where /XXX/ is some+-- random number).+data TmpBackendOptions = TmpBackendOptions+ { getTmpDir :: IO FilePath+ , filenamePat :: String+ }++-- | Default options for the temporary file backend:+-- 'getTemporaryDirectory' and "servant-multipart.buf"+defaultTmpBackendOptions :: TmpBackendOptions+defaultTmpBackendOptions = TmpBackendOptions+ { getTmpDir = getTemporaryDirectory+ , filenamePat = "servant-multipart.buf"+ }++-- | Default configuration for multipart handling.+--+-- Uses 'defaultParseRequestBodyOptions' and+-- 'defaultTmpBackendOptions' respectively.+defaultMultipartOptions :: MultipartOptions+defaultMultipartOptions = MultipartOptions+ { generalOptions = defaultParseRequestBodyOptions+ , tmpOptions = defaultTmpBackendOptions+ }++-- Utility class that's like HasContextEntry+-- but allows the lookup to fail, to make a context+-- entry for upload config optional (hence using+-- some default configuration when missing)+class LookupContext ctx a where+ lookupContext :: Proxy a -> Context ctx -> Maybe a++instance LookupContext '[] a where+ lookupContext _ _ = Nothing++instance {-# OVERLAPPABLE #-}+ LookupContext cs a => LookupContext (c ': cs) a where+ lookupContext p (c :. cs) =+ lookupContext p cs++instance {-# OVERLAPPING #-}+ LookupContext cs a => LookupContext (a ': cs) a where+ lookupContext _ (c :. _) = Just c