servant-multipart 0.12.1 → 0.13.0
raw patch · 4 files changed
+401/−115 lines, 4 filesdep +deepseqdep +warpdep −string-conversionsdep ~basedep ~bytestringdep ~resourcetnew-uploaderPVP ok
version bump matches the API change (PVP)
Dependencies added: deepseq, warp
Dependencies removed: string-conversions
Dependency ranges changed: base, bytestring, resourcet, servant, servant-docs, servant-foreign, servant-multipart-api, servant-server, text, wai, wai-extra
API changes (from Hackage documentation)
- Servant.Multipart: instance (Servant.Docs.Internal.HasDocs api, Servant.Multipart.ToMultipartSample tag a) => Servant.Docs.Internal.HasDocs (Servant.Multipart.API.MultipartForm tag a Servant.API.Sub.:> api)
- Servant.Multipart: instance (Servant.Foreign.Internal.HasForeignType lang ftype a, Servant.Foreign.Internal.HasForeign lang ftype api) => Servant.Foreign.Internal.HasForeign lang ftype (Servant.Multipart.API.MultipartForm t a Servant.API.Sub.:> api)
- Servant.Multipart: type family MultipartBackendOptions tag :: *;
+ Servant.Multipart: DecodeError :: InvalidUtf8 -> CheckError
+ Servant.Multipart: InvalidUtf8 :: String -> String -> ByteString -> InvalidUtf8
+ Servant.Multipart: LimitError :: LimitExceeded -> CheckError
+ Servant.Multipart: LimitExceeded :: Maybe (Int, String) -> String -> LimitExceeded
+ Servant.Multipart: ParseError :: String -> CheckError
+ Servant.Multipart: [iname] :: InvalidUtf8 -> ByteString
+ Servant.Multipart: [kind] :: InvalidUtf8 -> String
+ Servant.Multipart: [limitMessage] :: LimitExceeded -> String
+ Servant.Multipart: [part] :: InvalidUtf8 -> String
+ Servant.Multipart: [statusOverride] :: LimitExceeded -> Maybe (Int, String)
+ Servant.Multipart: class LookupContext (ctx :: [Type]) a
+ Servant.Multipart: data CheckError
+ Servant.Multipart: data InvalidUtf8
+ Servant.Multipart: data LimitExceeded
+ Servant.Multipart: instance (Servant.Docs.Internal.HasDocs api, Servant.Multipart.ToMultipartSample tag a) => Servant.Docs.Internal.HasDocs (Servant.Multipart.API.MultipartForm' mods tag a Servant.API.Sub.:> api)
+ Servant.Multipart: instance (Servant.Foreign.Internal.HasForeignType lang ftype a, Servant.Foreign.Internal.HasForeign lang ftype api) => Servant.Foreign.Internal.HasForeign lang ftype (Servant.Multipart.API.MultipartForm' mods t a Servant.API.Sub.:> api)
+ Servant.Multipart: instance Control.DeepSeq.NFData Servant.Multipart.CheckError
+ Servant.Multipart: instance Control.DeepSeq.NFData Servant.Multipart.InvalidUtf8
+ Servant.Multipart: instance Control.DeepSeq.NFData Servant.Multipart.LimitExceeded
+ Servant.Multipart: instance GHC.Classes.Eq Servant.Multipart.CheckError
+ Servant.Multipart: instance GHC.Classes.Eq Servant.Multipart.InvalidUtf8
+ Servant.Multipart: instance GHC.Classes.Eq Servant.Multipart.LimitExceeded
+ Servant.Multipart: instance GHC.Show.Show Servant.Multipart.CheckError
+ Servant.Multipart: instance GHC.Show.Show Servant.Multipart.InvalidUtf8
+ Servant.Multipart: instance GHC.Show.Show Servant.Multipart.LimitExceeded
+ Servant.Multipart: lookupAllFiles :: Text -> MultipartData tag -> [FileData tag]
+ Servant.Multipart: lookupAllInputs :: Text -> MultipartData tag -> [Text]
+ Servant.Multipart: lookupAllInputsAs :: FromHttpApiData a => Text -> MultipartData tag -> Either String [a]
+ Servant.Multipart: lookupContext :: LookupContext ctx a => Proxy a -> Context ctx -> Maybe a
+ Servant.Multipart: lookupInputAs :: FromHttpApiData a => Text -> MultipartData tag -> Either String a
+ Servant.Multipart: type MultipartBackendOptions tag;
Files
- CHANGELOG.md +39/−0
- servant-multipart.cabal +22/−16
- src/Servant/Multipart.hs +170/−87
- test/Test.hs +170/−12
CHANGELOG.md view
@@ -1,3 +1,42 @@+0.13.0+------++- **Breaking:** under `Lenient`, the handler now receives+ `Either CheckError a` instead of `Either String a`. `CheckError`+ distinguishes `fromMultipart` failures (`ParseError`), invalid UTF-8+ (`DecodeError`) and exceeded body parsing limits (`LimitError`).+- **Breaking:** forms whose input names, input values, file names or file+ content types are not valid UTF-8 are rejected with a 400 response instead+ of throwing an exception+ [#84](https://github.com/haskell-servant/servant-multipart/pull/84).+- **Breaking:** forms that exceed a `generalOptions` limit are rejected with+ a 4xx response instead of a 500. Exceeding a size limit responds with 413,+ exceeding a part header limit responds with 431, and other limits respond+ through the `ErrorFormatters` in the context+ [#85](https://github.com/haskell-servant/servant-multipart/pull/85).+ Under Warp, size and part header limits already responded with 413 and+ 431, but with Warp's plain-text body; the body now comes from the+ `ErrorFormatters`.+- **Breaking:** `defaultMultipartOptions` now limits each file to 25 MiB.+- **Breaking:** requests whose content type is not+ `application/x-www-form-urlencoded` or `multipart/form-data` with a+ boundary are rejected with 415 instead of 400, and the response is built+ by the `ErrorFormatters` in the context. The rejection is no longer+ fatal, so later alternatives of `:<|>` are tried, as with `ReqBody`.+- `lookupInput` and `lookupFile` moved to `servant-multipart-api`; they+ are still re-exported from `Servant.Multipart`.+- Re-export the new `lookupAllInputs`, `lookupAllFiles`, `lookupInputAs`+ and `lookupAllInputsAs` from `servant-multipart-api`+ [#75](https://github.com/haskell-servant/servant-multipart/pull/75).+- Export the `LookupContext` class.+- **Breaking:** the `HasDocs` and `HasForeign` instances now cover+ `MultipartForm'` with any modifiers, not only `MultipartForm`. Remove any+ instances you wrote for `MultipartForm' '[Lenient]`, since they now+ overlap.+- Drop the `string-conversions` dependency.+- Require GHC >= 9.4 and servant >= 0.20.3; support up to GHC 9.12+ [#81](https://github.com/haskell-servant/servant-multipart/pull/81).+ 0.12.1 ------
servant-multipart.cabal view
@@ -1,5 +1,5 @@ name: servant-multipart-version: 0.12.1+version: 0.13.0 synopsis: multipart/form-data (e.g file upload) support for servant description: This package adds server-side support of file upload to the servant ecosystem.@@ -14,7 +14,12 @@ build-type: Simple cabal-version: >=1.10 extra-source-files: CHANGELOG.md-tested-with: GHC ==8.0.2 || ==8.2.2 || ==8.4.4 || ==8.6.5 || ==8.8.4 || ==8.10.4+tested-with:+ GHC ==9.4.8+ || ==9.6.3+ || ==9.8.4+ || ==9.10.3+ || ==9.12.4 library default-language: Haskell2010@@ -23,23 +28,24 @@ -- ghc boot libs build-depends:- base >=4.9 && <5- , bytestring >=0.10.8.1 && <0.11+ base >=4.17 && <5+ , bytestring >=0.11 && <0.13+ , deepseq >=1.4.8 && <1.6 , directory >=1.3 && <1.4- , text >=1.2.3.0 && <1.3+ , text >=2.0 && <2.2 -- other dependencies build-depends:- servant-multipart-api == 0.12.*- , lens >=4.17 && <5.1- , resourcet >=1.2.2 && <1.3- , servant >=0.16 && <0.19- , servant-docs >=0.10 && <0.19- , servant-foreign >=0.15 && <0.19- , servant-server >=0.16 && <0.19- , string-conversions >=0.4.0.1 && <0.5- , wai >=3.2.1.2 && <3.3- , wai-extra >=3.0.24.3 && <3.2+ servant-multipart-api == 0.13.*+ , lens >=4.17 && <5.4+ , resourcet >=1.3.0 && <1.4+ , servant >=0.20.3 && <0.21+ , servant-docs >=0.13 && <0.14+ , servant-foreign >=0.16 && <0.17+ , servant-server >=0.20.3 && <0.21+ , wai >=3.2.5 && <3.3+ , wai-extra >=3.1.18 && <3.2+ , warp >=3.3.22 && <3.5 test-suite servant-multipart-test type: exitcode-stdio-1.0@@ -52,10 +58,10 @@ , http-types , servant-multipart , servant-server- , string-conversions , tasty , tasty-wai , text+ , wai-extra source-repository head type: git
src/Servant/Multipart.hs view
@@ -1,4 +1,3 @@-{-# LANGUAGE CPP #-} {-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE TypeFamilies #-}@@ -23,6 +22,13 @@ , FromMultipart(..) , lookupInput , lookupFile+ , lookupAllInputs+ , lookupAllFiles + , lookupInputAs + , lookupAllInputsAs + , CheckError(..)+ , LimitExceeded(..)+ , InvalidUtf8(..) , MultipartOptions(..) , defaultMultipartOptions , MultipartBackend(..)@@ -34,23 +40,23 @@ , FileData(..) -- * servant-docs , ToMultipartSample(..)+ , LookupContext(..) ) where import Servant.Multipart.API +import Control.DeepSeq (NFData (rnf)) import Control.Lens ((<>~), (&), view, (.~))+import Control.Monad (unless) import Control.Monad.IO.Class import Control.Monad.Trans.Resource-import Data.List (find)+import Data.Bifunctor (first) import Data.Maybe-#if !MIN_VERSION_base(4,11,0)-import Data.Monoid ((<>))-#endif-import Data.String.Conversions (cs) import Data.Text (Text, unpack)-import Data.Text.Encoding (decodeUtf8)+import Data.Text.Encoding (decodeUtf8') import Data.Typeable import Network.Wai+import Network.Wai.Handler.Warp (InvalidRequest (PayloadTooLarge, RequestHeaderFieldsTooLarge)) import Network.Wai.Parse import Servant hiding (contentType) import Servant.API.Modifiers (FoldLenient)@@ -59,38 +65,37 @@ import Servant.Server.Internal import System.Directory -import qualified Data.ByteString as SBS---- | Lookup a textual input with the given @name@ attribute.-lookupInput :: Text -> MultipartData tag -> Either String Text-lookupInput iname =- maybe (Left $ "Field " <> cs iname <> " not found") (Right . iValue)- . find ((==iname) . iName)- . inputs---- | Lookup a file input with the given @name@ attribute.-lookupFile :: Text -> MultipartData tag -> Either String (FileData tag)-lookupFile iname =- maybe (Left $ "File " <> cs iname <> " not found") Right- . find ((==iname) . fdInputName)- . files+import qualified Control.Exception as E+import qualified Data.ByteString as SBS+import qualified Data.Text.Lazy as TL+import qualified Data.Text.Lazy.Encoding as TLE fromRaw :: forall tag. ([Network.Wai.Parse.Param], [File (MultipartResult tag)])- -> MultipartData tag-fromRaw (inputs, files) = MultipartData is fs+ -> Either CheckError (MultipartData tag)+fromRaw (inputs, files) =+ MultipartData <$> traverse toInput inputs <*> traverse toFile files - where is = map (\(name, val) -> Input (dec name) (dec val)) inputs- fs = map toFile files+ where toInput (iname, val) =+ Input <$> decInput "name" iname iname+ <*> decInput "value" iname val - toFile :: File (MultipartResult tag) -> FileData tag+ toFile :: File (MultipartResult tag) -> Either CheckError (FileData tag) toFile (iname, fileinfo) =- FileData (dec iname)- (dec $ fileName fileinfo)- (dec $ fileContentType fileinfo)- (fileContent fileinfo)+ FileData <$> decFile "name" iname iname+ <*> decFile "file name" iname (fileName fileinfo)+ <*> decFile "content type" iname (fileContentType fileinfo)+ <*> pure (fileContent fileinfo) - dec = decodeUtf8+ decInput = dec "input"+ decFile = dec "file input" + dec :: String -> String -> SBS.ByteString -> SBS.ByteString+ -> Either CheckError Text+ dec kind part iname raw =+ case decodeUtf8' raw of+ Right text -> Right text+ Left _ -> Left $ DecodeError $ InvalidUtf8 part kind iname+ class MultipartBackend tag where type MultipartBackendOptions tag :: * @@ -107,23 +112,21 @@ -- | 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.+-- @multipart/form-data@, the form's names, values, file names and+-- content types are valid UTF-8, and the call to 'fromMultipart'+-- succeeds. instance ( FromMultipart tag a , MultipartBackend tag , LookupContext config (MultipartOptions tag)-#if MIN_VERSION_servant_server(0,18,0) , LookupContext config ErrorFormatters-#endif , SBoolI (FoldLenient mods) , HasServer sublayout config ) => HasServer (MultipartForm' mods tag a :> sublayout) config where type ServerT (MultipartForm' mods tag a :> sublayout) m =- If (FoldLenient mods) (Either String a) a -> ServerT sublayout m+ If (FoldLenient mods) (Either CheckError a) a -> ServerT sublayout m -#if MIN_VERSION_servant_server(0,12,0) hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy :: Proxy sublayout) pc nt . s-#endif route Proxy config subserver = route psub config subserver'@@ -135,81 +138,137 @@ $ lookupContext popts config subserver' = addMultipartHandling @tag @a @mods @config pbak multipartOpts config 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 :: MultipartBackend tag => Proxy tag -> MultipartOptions tag- -> DelayedIO (MultipartData tag)+ -> DelayedIO (Either CheckError (MultipartData tag)) check pTag tag = withRequest $ \request -> do st <- liftResourceT getInternalState- rawData <- liftIO- $ parseRequestBodyEx- parseOpts- (backend pTag (backendOptions tag) st)- request- return (fromRaw rawData)+ let parse = fromRaw <$> parseRequestBodyEx parseOpts (backend pTag (backendOptions tag) st) request+ liftIO $+ E.catchJust invalidRequestLimit+ (E.handle (pure . Left . LimitError . requestParseLimit) parse)+ (pure . Left . LimitError) where parseOpts = generalOptions tag +-- | Why a @multipart/form-data@ request body was not decoded. Under+-- 'Servant.API.Modifiers.Lenient', the handler is passed this instead of+-- the request being rejected.+data CheckError+ = ParseError String+ -- ^ 'fromMultipart' failed.+ | DecodeError InvalidUtf8+ -- ^ The form's text is not valid UTF-8.+ | LimitError LimitExceeded+ -- ^ The form exceeds one of the 'generalOptions' limits.+ deriving (Eq, Show)++instance NFData CheckError where+ rnf (ParseError message) = rnf message+ rnf (DecodeError limit) = rnf limit+ rnf (LimitError limit) = rnf limit++-- | A @multipart/form-data@ request body that exceeds one of the+-- 'generalOptions' limits.+data LimitExceeded = LimitExceeded+ { statusOverride :: Maybe (Int, String)+ -- ^ The status code and reason phrase that the rejection responds with+ -- in place of the one from the 'ErrorFormatters', if any.+ , limitMessage :: String+ } deriving (Eq, Show)++instance NFData LimitExceeded where+ rnf (LimitExceeded override message) = rnf override `seq` rnf message++data InvalidUtf8 = InvalidUtf8+ { kind :: String+ , part :: String+ , iname :: SBS.ByteString+ } deriving (Eq, Show)++instance NFData InvalidUtf8 where+ rnf (InvalidUtf8 {..}) = rnf kind `seq` rnf part `seq` rnf iname++requestParseLimit :: RequestParseException -> LimitExceeded+requestParseLimit e = case e of+ MaxParamSizeExceeded _ -> LimitExceeded payloadTooLarge "the form exceeds a size limit"+ ParamNameTooLong _ maxLength ->+ LimitExceeded Nothing $ "an input name exceeds " <> show maxLength <> " bytes"+ FilenameTooLong _ maxLength ->+ LimitExceeded Nothing $ "a file input name exceeds " <> show maxLength <> " bytes"+ MaxFileNumberExceeded maxFiles ->+ LimitExceeded Nothing $ "the form has more than " <> show maxFiles <> " files"+ TooManyHeaderLines _ -> LimitExceeded headerFieldsTooLarge "a part has too many header lines"++invalidRequestLimit :: InvalidRequest -> Maybe LimitExceeded+invalidRequestLimit e = case e of+ PayloadTooLarge -> Just $ LimitExceeded payloadTooLarge "the form exceeds a size limit"+ RequestHeaderFieldsTooLarge ->+ Just $ LimitExceeded headerFieldsTooLarge "a part header line exceeds the length limit"+ _ -> Nothing++payloadTooLarge :: Maybe (Int, String)+payloadTooLarge = Just (errHTTPCode err413, errReasonPhrase err413)++headerFieldsTooLarge :: Maybe (Int, String)+headerFieldsTooLarge = Just (431, "Request Header Fields Too Large")++unsupportedMediaType :: Maybe (Int, String)+unsupportedMediaType = Just (errHTTPCode err415, errReasonPhrase err415)+ -- Add multipart extraction support to a Delayed. addMultipartHandling :: forall tag multipart (mods :: [*]) config env a. ( FromMultipart tag multipart , MultipartBackend tag-#if MIN_VERSION_servant_server(0,18,0) , LookupContext config ErrorFormatters-#endif ) => SBoolI (FoldLenient mods) => Proxy tag -> MultipartOptions tag -> Context config- -> Delayed env (If (FoldLenient mods) (Either String multipart) multipart -> a)+ -> Delayed env (If (FoldLenient mods) (Either CheckError multipart) multipart -> a) -> Delayed env a-addMultipartHandling pTag opts _config subserver =+addMultipartHandling pTag opts config subserver = addBodyCheck subserver contentCheck bodyCheck where contentCheck = withRequest $ \request ->- fuzzyMultipartCTCheck (contentTypeH request)+ unless (isFormContentType (contentTypeH request)) $+ liftRouteResult $ Fail $ withStatus unsupportedMediaType $ formatError request+ "the content type of the request body is not application/x-www-form-urlencoded or multipart/form-data" bodyCheck () = withRequest $ \ request -> do- mpd <- check pTag opts :: DelayedIO (MultipartData tag)- case (sbool :: SBool (FoldLenient mods), fromMultipart @tag @multipart mpd) of- (SFalse, Left msg) -> liftRouteResult $ FailFatal $ formatError request msg+ checked <- check pTag opts+ case (sbool :: SBool (FoldLenient mods), checked >>= first ParseError . fromMultipart @tag @multipart) of+ (SFalse, Left (ParseError msg)) -> liftRouteResult $ FailFatal $ formatError request msg+ (SFalse, Left (LimitError LimitExceeded {..})) ->+ liftRouteResult $ FailFatal $ withStatus statusOverride (formatError request limitMessage)+ (SFalse, Left (DecodeError InvalidUtf8 {..})) ->+ liftRouteResult $ FailFatal $ formatError request $+ part <> " of " <> kind <> " " <> show iname+ <> " is not valid UTF-8" (SFalse, Right x) -> return x- (STrue, res) -> return $ either (Left . cs) Right res+ (STrue, res) -> return res contentTypeH req = fromMaybe "application/octet-stream" $ lookup "Content-Type" (requestHeaders req) - defaultFormatError msg = err400 { errBody = "Could not decode multipart mime body: " <> cs msg }-#if MIN_VERSION_servant_server(0,18,0)+ withStatus = maybe id $ \(code, phrase) err ->+ err { errHTTPCode = code, errReasonPhrase = phrase }+ defaultFormatError msg = err400 { errBody = "Could not decode multipart mime body: " <> TLE.encodeUtf8 (TL.pack msg) } pFormatters = Proxy :: Proxy ErrorFormatters rep = typeRep (Proxy :: Proxy MultipartForm') formatError request =- case lookupContext pFormatters _config of+ case lookupContext pFormatters config of Nothing -> defaultFormatError Just fmts -> bodyParserErrorFormatter fmts rep request-#else- formatError _ = defaultFormatError-#endif --- 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"- }-+isFormContentType :: SBS.ByteString -> Bool+isFormContentType ct =+ case ctype of+ "application/x-www-form-urlencoded" -> True+ "multipart/form-data" | Just _bound <- lookup "boundary" attrs -> True+ _ -> False 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 -- | Global options for configuring how the -- server should handle multipart data.@@ -224,6 +283,14 @@ -- See haddocks for 'ParseRequestBodyOptions' and -- 'TmpBackendOptions' respectively for more information on -- what you can tweak.+--+-- A form that exceeds one of the 'generalOptions' limits is rejected+-- before the handler runs, unless 'Servant.API.Modifiers.Lenient' is used,+-- in which case the handler is passed a 'LimitError'. The response is+-- built by the 'ErrorFormatters' in the context, if any, like other+-- request body errors, except that exceeding a size limit+-- always responds with status 413 and exceeding a part header limit+-- always responds with status 431. data MultipartOptions tag = MultipartOptions { generalOptions :: ParseRequestBodyOptions , backendOptions :: MultipartBackendOptions tag@@ -265,11 +332,26 @@ -- | Default configuration for multipart handling. ----- Uses 'defaultParseRequestBodyOptions' and--- 'defaultBackendOptions' respectively.+-- Uses 'defaultBackendOptions', and 'defaultParseRequestBodyOptions' with+-- a per-file size limit added. The per-file limit is set here, and the+-- others are the defaults of wai-extra 3.1.18. The resulting limits are:+--+-- * at most 25 MiB (@25 * 1024 * 1024@ bytes) per file+-- * at most 10 files+-- * no limit on the total size of all files+-- * at most 65336 bytes of textual inputs in total+-- * input and file input names of at most 32 bytes+-- * at most 32 header lines per part, each of at most 8190 bytes+--+-- Since the total size of all files is not limited, a single request can+-- carry up to 250 MiB of files, which the 'Mem' backend holds in memory+-- and the 'Tmp' backend writes to disk. Use 'setMaxRequestFileSize',+-- 'setMaxRequestFilesSize', 'setMaxRequestNumFiles' and the other setters+-- from "Network.Wai.Parse" on 'generalOptions' to change these limits, or+-- 'noLimitParseRequestBodyOptions' to remove them. defaultMultipartOptions :: MultipartBackend tag => Proxy tag -> MultipartOptions tag defaultMultipartOptions pTag = MultipartOptions- { generalOptions = defaultParseRequestBodyOptions+ { generalOptions = setMaxRequestFileSize (25 * 1024 * 1024) defaultParseRequestBodyOptions , backendOptions = defaultBackendOptions pTag } @@ -296,8 +378,9 @@ -- inputs for your type for use with "Servant.Docs". This is used by the -- 'HasDocs' instance for 'MultipartForm'. ----- Given the example 'User' type and 'FromMultipart' instance above, here is a--- corresponding 'ToMultipartSample' instance:+-- Given the example @User@ type and 'FromMultipart' instance from the+-- 'MultipartForm' documentation, here is a corresponding 'ToMultipartSample'+-- instance: -- -- @ -- data User = User { username :: Text, pic :: FilePath }@@ -368,9 +451,9 @@ -- | Declare an instance of 'ToMultipartSample' for your 'MultipartForm' type -- to be able to use this 'HasDocs' instance.-instance (HasDocs api, ToMultipartSample tag a) => HasDocs (MultipartForm tag a :> api) where+instance (HasDocs api, ToMultipartSample tag a) => HasDocs (MultipartForm' mods tag a :> api) where docsFor- :: Proxy (MultipartForm tag a :> api)+ :: Proxy (MultipartForm' mods tag a :> api) -> (Endpoint, Action) -> DocOptions -> API@@ -386,8 +469,8 @@ in docsFor (Proxy :: Proxy api) (endpoint, newAction) opts instance (HasForeignType lang ftype a, HasForeign lang ftype api)- => HasForeign lang ftype (MultipartForm t a :> api) where- type Foreign ftype (MultipartForm t a :> api) = Foreign ftype api+ => HasForeign lang ftype (MultipartForm' mods t a :> api) where+ type Foreign ftype (MultipartForm' mods t a :> api) = Foreign ftype api foreignFor lang ftype Proxy req = foreignFor lang ftype (Proxy @api) $
test/Test.hs view
@@ -1,16 +1,20 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} import Data.ByteString as BS (ByteString)-import Data.ByteString.Lazy as BSL (ByteString)+import Data.ByteString.Lazy as BSL (ByteString, toStrict)+import qualified Data.ByteString.Lazy as BSL (replicate)+import qualified Data.ByteString.Lazy.Char8 as BSL8 (pack) import Data.List (intersperse) import Data.Monoid-import Data.String.Conversions (cs)-import Data.Text (Text)+import Data.Text (Text, pack)+import Data.Text.Encoding (decodeUtf8) import Network.HTTP.Types.Header (HeaderName, hContentType)+import Network.Wai.Parse (defaultParseRequestBodyOptions, setMaxRequestFileSize) import Test.Tasty import Test.Tasty.Wai@@ -32,6 +36,26 @@ , testGroup "strict handler with raw MultipartData" [ testWai testApp "correct body" testBlogPostRawHandler ]+ , testGroup "form limits"+ [ testWai testApp "field name too long" testFieldNameTooLong+ , testWai testApp "too many files" testTooManyFiles+ , testWai testApp "too many files with lenient handler" testTooManyFilesLenient+ , testWai testApp "part header line too long" testPartHeaderLineTooLong+ , testWai testApp "too many part header lines" testTooManyPartHeaderLines+ , testWai limitedApp "file under size limit" testFileUnderSizeLimit+ , testWai limitedApp "file over size limit" testFileOverSizeLimit+ ]+ , testGroup "form limits with custom ErrorFormatters"+ [ testWai customFormatterApp "too many files keeps formatter status" testTooManyFilesCustomFormatter+ , testWai customFormatterApp "file over size limit is 413" testFileOverSizeLimitCustomFormatter+ , testWai customFormatterApp "unsupported content type is 415" testUnsupportedContentTypeCustomFormatter+ ]+ , testGroup "content type"+ [ testWai testApp "unsupported content type" testUnsupportedContentType+ , testWai testApp "multipart without boundary" testMultipartWithoutBoundary+ , testWai alternativeApp "unsupported content type falls through to later route" testUnsupportedContentTypeFallsThrough+ , testWai alternativeApp "multipart request matches multipart route" testMultipartBeforeAlternative+ ] ] data BlogPost@@ -44,30 +68,58 @@ fromMultipart md = BlogPost <$> lookupInput "title" md- <*> fmap (cs . fdPayload) (lookupFile "body" md)+ <*> fmap (decodeUtf8 . BSL.toStrict . fdPayload) (lookupFile "body" md) type TestAPI = "blogPostStrict" :> MultipartForm Mem BlogPost :> Post '[PlainText] Text- :<|> "blogPostLenient" :> MultipartForm' '[Lenient] Mem BlogPost :> Post '[JSON] Bool+ :<|> "blogPostLenient" :> MultipartForm' '[Lenient] Mem BlogPost :> Post '[PlainText] Text :<|> "blogPostRaw" :> MultipartForm Mem (MultipartData Mem) :> Post '[PlainText] Text blogPostStrictHandler :: BlogPost -> Handler Text blogPostStrictHandler bp = return $ title bp <> "\n" <> body bp -blogPostLenientHandler :: Either String BlogPost -> Handler Bool+blogPostLenientHandler :: Either CheckError BlogPost -> Handler Text blogPostLenientHandler eitherBP =- case eitherBP of- Left _ -> return False- Right _ -> return True+ return $ case eitherBP of+ Left (ParseError msg) -> "parse error: " <> pack msg+ Left (LimitError limit) -> "limit exceeded: " <> pack (limitMessage limit)+ Left (DecodeError InvalidUtf8 {..}) -> "decoding error: " <> pack (part <> " of " <> kind <> " " <> show iname <> " is not valid UTF-8")+ Right bp -> title bp blogPostRawHandler :: MultipartData Mem -> Handler Text blogPostRawHandler md = return $ mconcat $ intersperse " " $ map iName (inputs md) <> map fdInputName (files md) +testServer :: Server TestAPI+testServer = blogPostStrictHandler :<|> blogPostLenientHandler :<|> blogPostRawHandler+ testApp :: Application-testApp = serve @TestAPI Proxy $ blogPostStrictHandler :<|> blogPostLenientHandler :<|> blogPostRawHandler+testApp = serve @TestAPI Proxy testServer +limitedOptions :: MultipartOptions Mem+limitedOptions = (defaultMultipartOptions (Proxy @Mem))+ { generalOptions = setMaxRequestFileSize 100 defaultParseRequestBodyOptions }++limitedApp :: Application+limitedApp = serveWithContext @TestAPI Proxy (limitedOptions :. EmptyContext) testServer++customFormatterApp :: Application+customFormatterApp =+ serveWithContext @TestAPI Proxy (limitedOptions :. customFormatters :. EmptyContext) testServer+ where+ customFormatters = defaultErrorFormatters+ { bodyParserErrorFormatter = \_ _ msg -> err422 { errBody = "custom: " <> BSL8.pack msg } }++type AlternativeAPI+ = "upload" :> MultipartForm Mem (MultipartData Mem) :> Post '[PlainText] Text+ :<|> "upload" :> ReqBody '[PlainText] Text :> Post '[PlainText] Text++alternativeApp :: Application+alternativeApp =+ serve @AlternativeAPI Proxy $+ blogPostRawHandler :<|> (\txt -> return $ "plain text: " <> txt)+ multipartHeaders :: [(HeaderName, BS.ByteString)] multipartHeaders = [(hContentType, "multipart/form-data; boundary=XX")] @@ -93,13 +145,13 @@ testBlogPostLenientHandler = do res <- srequest $ buildRequestWithHeaders POST "/blogPostLenient" correctBody multipartHeaders assertStatus 200 res- assertBody "true" res+ assertBody "Foo post" res testBlogPostLenientHandlerPartialBody :: Session () testBlogPostLenientHandlerPartialBody = do res <- srequest $ buildRequestWithHeaders POST "/blogPostLenient" partialBody multipartHeaders assertStatus 200 res- assertBody "false" res+ assertBody "parse error: File body not found" res testBlogPostRawHandler :: Session () testBlogPostRawHandler = do@@ -130,3 +182,109 @@ , "" , "--XX--" ]++testFieldNameTooLong :: Session ()+testFieldNameTooLong = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" (formBody [fieldPart (BSL.replicate 33 0x61)]) multipartHeaders+ assertStatus 400 res+ assertBody "Could not decode multipart mime body: an input name exceeds 32 bytes" res++testTooManyFiles :: Session ()+testTooManyFiles = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" elevenFiles multipartHeaders+ assertStatus 400 res+ assertBody "Could not decode multipart mime body: the form has more than 10 files" res++testTooManyFilesLenient :: Session ()+testTooManyFilesLenient = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostLenient" elevenFiles multipartHeaders+ assertStatus 200 res+ assertBody "limit exceeded: the form has more than 10 files" res++testPartHeaderLineTooLong :: Session ()+testPartHeaderLineTooLong = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" (formBody [fieldPart (BSL.replicate 9000 0x61)]) multipartHeaders+ assertStatus 431 res+ assertBody "Could not decode multipart mime body: a part header line exceeds the length limit" res++testTooManyPartHeaderLines :: Session ()+testTooManyPartHeaderLines = do+ let manyHeaders = "--XX" : replicate 40 "X-Extra: 1" <> drop 1 (fieldPart "title")+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" (formBody [manyHeaders]) multipartHeaders+ assertStatus 431 res+ assertBody "Could not decode multipart mime body: a part has too many header lines" res++testFileUnderSizeLimit :: Session ()+testFileUnderSizeLimit = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" (formBody [filePart "file" (BSL.replicate 20 0x78)]) multipartHeaders+ assertStatus 200 res+ assertBody "file" res++testFileOverSizeLimit :: Session ()+testFileOverSizeLimit = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" (formBody [filePart "file" (BSL.replicate 200 0x78)]) multipartHeaders+ assertStatus 413 res+ assertBody "Could not decode multipart mime body: the form exceeds a size limit" res++testTooManyFilesCustomFormatter :: Session ()+testTooManyFilesCustomFormatter = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" elevenFiles multipartHeaders+ assertStatus 422 res+ assertBody "custom: the form has more than 10 files" res++testFileOverSizeLimitCustomFormatter :: Session ()+testFileOverSizeLimitCustomFormatter = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" (formBody [filePart "file" (BSL.replicate 200 0x78)]) multipartHeaders+ assertStatus 413 res+ assertBody "custom: the form exceeds a size limit" res++testUnsupportedContentType :: Session ()+testUnsupportedContentType = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" correctBody [(hContentType, "application/json")]+ assertStatus 415 res+ assertBody "Could not decode multipart mime body: the content type of the request body is not application/x-www-form-urlencoded or multipart/form-data" res++testMultipartWithoutBoundary :: Session ()+testMultipartWithoutBoundary = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" correctBody [(hContentType, "multipart/form-data")]+ assertStatus 415 res++testUnsupportedContentTypeCustomFormatter :: Session ()+testUnsupportedContentTypeCustomFormatter = do+ res <- srequest $ buildRequestWithHeaders POST "/blogPostRaw" correctBody [(hContentType, "application/json")]+ assertStatus 415 res+ assertBody "custom: the content type of the request body is not application/x-www-form-urlencoded or multipart/form-data" res++testUnsupportedContentTypeFallsThrough :: Session ()+testUnsupportedContentTypeFallsThrough = do+ res <- srequest $ buildRequestWithHeaders POST "/upload" "hello" [(hContentType, "text/plain;charset=utf-8")]+ assertStatus 200 res+ assertBody "plain text: hello" res++testMultipartBeforeAlternative :: Session ()+testMultipartBeforeAlternative = do+ res <- srequest $ buildRequestWithHeaders POST "/upload" correctBody multipartHeaders+ assertStatus 200 res+ assertBody "title body" res++elevenFiles :: BSL.ByteString+elevenFiles = formBody (replicate 11 (filePart "file" "contents"))++fieldPart :: BSL.ByteString -> [BSL.ByteString]+fieldPart name =+ [ "--XX"+ , "Content-Disposition: form-data; name=\"" <> name <> "\""+ , ""+ , "value"+ ]++filePart :: BSL.ByteString -> BSL.ByteString -> [BSL.ByteString]+filePart name contents =+ [ "--XX"+ , "Content-Disposition: form-data; name=\"" <> name <> "\"; filename=\"file.txt\""+ , ""+ , contents+ ]++formBody :: [[BSL.ByteString]] -> BSL.ByteString+formBody parts = mconcat $ intersperse "\n" (concat parts <> ["--XX--"])