Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

URL-decoding query and form parameters + decoding FormData #392

Merged
merged 7 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Web/Scotty.hs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
, capture, regex, function, literal
-- ** Accessing the Request and its fields
, request, header, headers, body, bodyReader
, jsonData
, jsonData, formData
-- ** Accessing Path, Form and Query Parameters
, param, params
, pathParam, captureParam, formParam, queryParam
Expand Down Expand Up @@ -71,6 +71,7 @@
import Network.Wai.Handler.Warp (Port)
import qualified Network.Wai.Parse as W

import Web.FormUrlEncoded (FromForm)
import Web.Scotty.Internal.Types (ScottyT, ActionT, ErrorHandler, Param, RoutePattern, Options, defaultOptions, File, Kilobytes, ScottyState, defaultScottyState, ScottyException, StatusError(..), Content(..))
import UnliftIO.Exception (Handler(..), catch)
import Web.Scotty.Cookie (setSimpleCookie,getCookie,getCookies,deleteCookie,makeSimpleCookie)
Expand Down Expand Up @@ -156,14 +157,14 @@
--
-- Uncaught exceptions turn into HTTP 500 responses.
raise :: Text -> ActionM a
raise = Trans.raise

Check warning on line 160 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.8.2

In the use of ‘raise’

Check warning on line 160 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.2.8

In the use of ‘raise’

Check warning on line 160 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.0.2

In the use of ‘raise’

Check warning on line 160 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.4.6

In the use of ‘raise’

Check warning on line 160 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 8.10.7

In the use of ‘raise’

Check warning on line 160 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.6.4

In the use of ‘raise’
{-# DEPRECATED raise "Throw an exception instead" #-}

-- | Throw a 'StatusError' exception that has an associated HTTP error code and can be caught with 'catch'.
--
-- Uncaught exceptions turn into HTTP responses corresponding to the given status.
raiseStatus :: Status -> Text -> ActionM a
raiseStatus = Trans.raiseStatus

Check warning on line 167 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.8.2

In the use of ‘raiseStatus’

Check warning on line 167 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.2.8

In the use of ‘raiseStatus’

Check warning on line 167 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.0.2

In the use of ‘raiseStatus’

Check warning on line 167 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.4.6

In the use of ‘raiseStatus’

Check warning on line 167 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 8.10.7

In the use of ‘raiseStatus’

Check warning on line 167 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.6.4

In the use of ‘raiseStatus’
{-# DEPRECATED raiseStatus "Use status, text, and finish instead" #-}

-- | Throw an exception which can be caught within the scope of the current Action with 'catch'.
Expand Down Expand Up @@ -213,7 +214,7 @@
--
-- > raise JustKidding `catch` (\msg -> text msg)
rescue :: E.Exception e => ActionM a -> (e -> ActionM a) -> ActionM a
rescue = Trans.rescue

Check warning on line 217 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.2.8

In the use of ‘rescue’

Check warning on line 217 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.0.2

In the use of ‘rescue’

Check warning on line 217 in Web/Scotty.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 8.10.7

In the use of ‘rescue’
{-# DEPRECATED rescue "Use catch instead" #-}

-- | Like 'liftIO', but catch any IO exceptions and turn them into Scotty exceptions.
Expand Down Expand Up @@ -276,6 +277,12 @@
jsonData :: FromJSON a => ActionM a
jsonData = Trans.jsonData

-- | Parse the request body as @x-www-form-urlencoded@ form data and return it. Raises an exception if parse is unsuccessful.
--
-- NB: uses 'body' internally
formData :: FromForm a => ActionM a
formData = Trans.formData

-- | Get a parameter. First looks in captures, then form data, then query parameters.
--
-- * Raises an exception which can be caught by 'catch' if parameter is not found.
Expand Down
31 changes: 31 additions & 0 deletions Web/Scotty/Action.hs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
, liftAndCatchIO
, json
, jsonData
, formData
, next
, param
, pathParam
Expand Down Expand Up @@ -79,7 +80,9 @@
import qualified Data.ByteString.Lazy.Char8 as BL
import qualified Data.CaseInsensitive as CI
import Data.Traversable (for)
import qualified Data.HashMap.Strict as HashMap
import Data.Int
import Data.List (foldl')
import Data.Maybe (maybeToList)
import qualified Data.Text as T
import Data.Text.Encoding as STE
Expand All @@ -101,8 +104,9 @@

import Numeric.Natural

import Web.FormUrlEncoded (Form(..), FromForm(..))
import Web.Scotty.Internal.Types
import Web.Scotty.Util (mkResponse, addIfNotPresent, add, replace, lazyTextToStrictByteString, decodeUtf8Lenient)

Check warning on line 109 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.8.2

The import of ‘decodeUtf8Lenient’

Check warning on line 109 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.4.6

The import of ‘decodeUtf8Lenient’

Check warning on line 109 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.6.4

The import of ‘decodeUtf8Lenient’
import UnliftIO.Exception (Handler(..), catch, catches, throwIO)
import System.IO (hPutStrLn, stderr)

Expand Down Expand Up @@ -132,7 +136,7 @@
-- | Catches 'StatusError' and produces an appropriate HTTP response.
statusErrorHandler :: MonadIO m => ErrorHandler m
statusErrorHandler = Handler $ \case
StatusError s e -> do

Check warning on line 139 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.8.2

In the use of data constructor ‘StatusError’

Check warning on line 139 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.2.8

In the use of data constructor ‘StatusError’

Check warning on line 139 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.0.2

In the use of data constructor ‘StatusError’

Check warning on line 139 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.4.6

In the use of data constructor ‘StatusError’

Check warning on line 139 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 8.10.7

In the use of data constructor ‘StatusError’

Check warning on line 139 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.6.4

In the use of data constructor ‘StatusError’
status s
let code = T.pack $ show $ statusCode s
let msg = decodeUtf8Lenient $ statusMessage s
Expand Down Expand Up @@ -168,6 +172,12 @@
, "Body: " <> bs
, "Error: " <> BL.fromStrict (encodeUtf8 err)
]
MalformedForm err -> do
status status400
raw $ BL.unlines
[ "formData: malformed"
, "Error: " <> BL.fromStrict (encodeUtf8 err)
]
PathParameterNotFound k -> do
status status500
text $ T.unwords [ "Path parameter", k, "not found"]
Expand Down Expand Up @@ -217,7 +227,7 @@
--
-- Uncaught exceptions turn into HTTP responses corresponding to the given status.
raiseStatus :: Monad m => Status -> T.Text -> ActionT m a
raiseStatus s = E.throw . StatusError s

Check warning on line 230 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.8.2

In the use of data constructor ‘StatusError’

Check warning on line 230 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.2.8

In the use of data constructor ‘StatusError’

Check warning on line 230 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.0.2

In the use of data constructor ‘StatusError’

Check warning on line 230 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.4.6

In the use of data constructor ‘StatusError’

Check warning on line 230 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 8.10.7

In the use of data constructor ‘StatusError’

Check warning on line 230 in Web/Scotty/Action.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.6.4

In the use of data constructor ‘StatusError’
{-# DEPRECATED raiseStatus "Use status, text, and finish instead" #-}

-- | Throw an exception which can be caught within the scope of the current Action with 'catch'.
Expand Down Expand Up @@ -354,6 +364,27 @@
A.Error err -> throwIO $ FailedToParseJSON b $ T.pack err
A.Success a -> return a

-- | Parse the request body as @x-www-form-urlencoded@ form data and return it.
--
-- The form is parsed using 'urlDecodeAsForm'. If that returns 'Left', the
-- status is set to 400 and an exception is thrown.
formData :: (FromForm a, MonadUnliftIO m) => ActionT m a
formData = do
form <- paramListToForm <$> formParams
case fromForm form of
Left err -> throwIO $ MalformedForm err
Right value -> return value
where
-- This rather contrived implementation uses cons and reverse to avoid
-- quadratic complexity when constructing a Form from a list of Param.
-- It's equivalent to using HashMap.insertWith (++) which does have
-- quadratic complexity due to appending at the end of list.
paramListToForm :: [Param] -> Form
paramListToForm = Form . fmap reverse . foldl' (\f (k, v) -> HashMap.alter (prependValue v) k f) HashMap.empty
pbrinkmeier marked this conversation as resolved.
Show resolved Hide resolved

prependValue :: a -> Maybe [a] -> Maybe [a]
prependValue v = Just . maybe [v] (v :)

-- | Get a parameter. First looks in captures, then form data, then query parameters.
--
-- * Raises an exception which can be caught by 'catch' if parameter is not found.
Expand Down
1 change: 1 addition & 0 deletions Web/Scotty/Internal/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import Control.Monad.IO.Class (MonadIO(..))
import UnliftIO (MonadUnliftIO(..))
import Control.Monad.Reader (MonadReader(..), ReaderT, asks, mapReaderT)
import Control.Monad.State.Strict (State, StateT(..))

Check warning on line 25 in Web/Scotty/Internal/Types.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.8.2

The import of ‘StateT’

Check warning on line 25 in Web/Scotty/Internal/Types.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.2.8

The import of ‘StateT’

Check warning on line 25 in Web/Scotty/Internal/Types.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.0.2

The import of ‘StateT’

Check warning on line 25 in Web/Scotty/Internal/Types.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.4.6

The import of ‘StateT’

Check warning on line 25 in Web/Scotty/Internal/Types.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 8.10.7

The import of ‘StateT’

Check warning on line 25 in Web/Scotty/Internal/Types.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.6.4

The import of ‘StateT’
import Control.Monad.Trans.Class (MonadTrans(..))
import Control.Monad.Trans.Control (MonadBaseControl, MonadTransControl)
import qualified Control.Monad.Trans.Resource as RT (InternalState, InvalidAccess)
Expand Down Expand Up @@ -147,6 +147,7 @@
= RequestTooLarge
| MalformedJSON LBS8.ByteString T.Text
| FailedToParseJSON LBS8.ByteString T.Text
| MalformedForm T.Text
| PathParameterNotFound T.Text
| QueryParameterNotFound T.Text
| FormFieldNotFound T.Text
Expand Down
2 changes: 1 addition & 1 deletion Web/Scotty/Trans.hs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
, capture, regex, function, literal
-- ** Accessing the Request and its fields
, request, Lazy.header, Lazy.headers, body, bodyReader
, jsonData
, jsonData, formData

-- ** Accessing Path, Form and Query Parameters
, param, params
Expand Down Expand Up @@ -133,7 +133,7 @@
-> (m W.Response -> IO W.Response) -- ^ Run monad 'm' into 'IO', called at each action.
-> ScottyT m ()
-> n W.Application
scottyAppT options runActionToIO defs = do

Check warning on line 136 in Web/Scotty/Trans.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.8.2

This binding for ‘options’ shadows the existing binding

Check warning on line 136 in Web/Scotty/Trans.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.2.8

This binding for ‘options’ shadows the existing binding

Check warning on line 136 in Web/Scotty/Trans.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.0.2

This binding for ‘options’ shadows the existing binding

Check warning on line 136 in Web/Scotty/Trans.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.4.6

This binding for ‘options’ shadows the existing binding

Check warning on line 136 in Web/Scotty/Trans.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 8.10.7

This binding for ‘options’ shadows the existing binding

Check warning on line 136 in Web/Scotty/Trans.hs

View workflow job for this annotation

GitHub Actions / ubuntu-latest / ghc 9.6.4

This binding for ‘options’ shadows the existing binding
let s = execState (runReaderT (runS defs) options) defaultScottyState
let rapp req callback = do
bodyInfo <- newBodyInfo req
Expand Down
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

* Fixed cookie example from `Cookie` module documentation. `getCookie` Function would return strict variant of `Text`. Will convert it into lazy variant using `fromStrict`.
* Exposed simple functions of `Cookie` module via `Web.Scotty` & `Web.Scotty.Trans`.
* Add tests for URL encoding of query parameters and form parameters. Add `formData` action for decoding `FromForm` instances (#321).

### Breaking changes
* Remove dependency on data-default class (#386). We have been exporting constants for default config values since 0.20, and this dependency was simply unnecessary.
Expand Down
3 changes: 3 additions & 0 deletions scotty.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Library
case-insensitive >= 1.0.0.1 && < 1.3,
cookie >= 0.4,
exceptions >= 0.7 && < 0.11,
http-api-data >= 0.5.1,
http-types >= 0.9.1 && < 0.13,
monad-control >= 1.0.0.3 && < 1.1,
mtl >= 2.1.2 && < 2.4,
Expand All @@ -89,6 +90,7 @@ Library
transformers >= 0.3.0.0 && < 0.7,
transformers-base >= 0.4.1 && < 0.5,
unliftio >= 0.2,
unordered-containers >= 0.2.10.0 && < 0.3,
wai >= 3.0.0 && < 3.3,
wai-extra >= 3.1.14,
warp >= 3.0.13
Expand All @@ -114,6 +116,7 @@ test-suite spec
directory,
hspec == 2.*,
hspec-wai >= 0.6.3,
http-api-data,
http-types,
lifted-base,
network,
Expand Down
44 changes: 37 additions & 7 deletions test/Web/ScottySpec.hs
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
{-# LANGUAGE OverloadedStrings, CPP, ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings, CPP, ScopedTypeVariables, DeriveGeneric #-}
module Web.ScottySpec (main, spec) where

import Test.Hspec
import Test.Hspec.Wai (with, request, get, post, put, patch, delete, options, (<:>), shouldRespondWith, postHtmlForm, matchHeaders, matchBody, matchStatus)
import Test.Hspec.Wai (WaiSession, with, request, get, post, put, patch, delete, options, (<:>), shouldRespondWith, matchHeaders, matchBody, matchStatus)
import Test.Hspec.Wai.Extra (postMultipartForm, FileMeta(..))

import Control.Applicative
import Control.Monad
import Data.Char
import Data.String
import Data.Text.Lazy (Text)
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import Data.Time (UTCTime(..))
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (secondsToDiffTime)

import GHC.Generics (Generic)

import Network.HTTP.Types
import Network.Wai (Application, Request(queryString), responseLBS)
import Network.Wai.Parse (defaultParseRequestBodyOptions)
import Network.Wai.Test (SResponse)
import qualified Control.Exception.Lifted as EL
import qualified Control.Exception as E

import Web.FormUrlEncoded (FromForm)
import Web.Scotty as Scotty hiding (get, post, put, patch, delete, request, options)
import qualified Web.Scotty as Scotty
import qualified Web.Scotty.Cookie as SC (getCookie, setSimpleCookie, deleteCookie)
Expand All @@ -30,6 +35,7 @@ import Control.Concurrent.Async (withAsync)
import Control.Exception (bracketOnError)
import qualified Data.ByteString as BS
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as LBS
import Network.Socket (Family(..), SockAddr(..), Socket, SocketOption(..), SocketType(..), bind, close, connect, listen, maxListenQueue, setSocketOption, socket)
import Network.Socket.ByteString (send, recv)
import System.Directory (removeFile)
Expand All @@ -41,6 +47,16 @@ main = hspec spec
availableMethods :: [StdMethod]
availableMethods = [GET, POST, HEAD, PUT, PATCH, DELETE, OPTIONS]

data SearchForm = SearchForm
{ sfQuery :: Text
, sfYear :: Int
} deriving (Generic)

instance FromForm SearchForm where

postForm :: ByteString -> LBS.ByteString -> WaiSession st SResponse
postForm p = request "POST" p [("Content-Type","application/x-www-form-urlencoded")]
pbrinkmeier marked this conversation as resolved.
Show resolved Hide resolved

spec :: Spec
spec = do
let withApp = with . scottyApp
Expand Down Expand Up @@ -255,6 +271,8 @@ spec = do
withApp (Scotty.get "/search" $ queryParam "query" >>= text) $ do
it "returns query parameter with given name" $ do
get "/search?query=haskell" `shouldRespondWith` "haskell"
it "decodes URL-encoding" $ do
get "/search?query=Kurf%C3%BCrstendamm" `shouldRespondWith` "Kurfürstendamm"
withApp (Scotty.matchAny "/search" (do
v <- queryParam "query"
json (v :: Int) )) $ do
Expand All @@ -268,16 +286,28 @@ spec = do
) $ do
it "catches a ScottyException" $ do
get "/search?query=potato" `shouldRespondWith` 200 { matchBody = "z"}

describe "formData" $ do
withApp (Scotty.post "/search" $ formData >>= (text . sfQuery)) $ do
it "decodes the form" $ do
postForm "/search" "sfQuery=Haskell&sfYear=2024" `shouldRespondWith` "Haskell"

it "decodes URL-encoding" $ do
postForm "/search" "sfQuery=Kurf%C3%BCrstendamm&sfYear=2024" `shouldRespondWith` "Kurfürstendamm"

it "returns 400 when the form is malformed" $ do
postForm "/search" "sfQuery=Haskell" `shouldRespondWith` 400

describe "formParam" $ do
let
postForm p bdy = request "POST" p [("Content-Type","application/x-www-form-urlencoded")] bdy
withApp (Scotty.post "/search" $ formParam "query" >>= text) $ do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where I got the implementation from.

it "returns form parameter with given name" $ do
postForm "/search" "query=haskell" `shouldRespondWith` "haskell"

it "replaces non UTF-8 bytes with Unicode replacement character" $ do
postForm "/search" "query=\xe9" `shouldRespondWith` "\xfffd"

it "decodes URL-encoding" $ do
postForm "/search" "query=Kurf%C3%BCrstendamm" `shouldRespondWith` "Kurfürstendamm"
withApp (Scotty.post "/search" (do
v <- formParam "query"
json (v :: Int))) $ do
Expand Down Expand Up @@ -354,7 +384,7 @@ spec = do

describe "filesOpts" $ do
let
postForm = postMultipartForm "/files" "ABC123" [
postMpForm = postMultipartForm "/files" "ABC123" [
(FMFile "file1.txt", "text/plain;charset=UTF-8", "first_file", "xxx"),
(FMFile "file2.txt", "text/plain;charset=UTF-8", "second_file", "yyy")
]
Expand All @@ -364,13 +394,13 @@ spec = do
withApp (Scotty.post "/files" processForm
) $ do
it "loads uploaded files in memory" $ do
postForm `shouldRespondWith` 200 { matchBody = "2"}
postMpForm `shouldRespondWith` 200 { matchBody = "2"}
context "preserves the body of a POST request even after 'next' (#147)" $ do
withApp (do
Scotty.post "/files" next
Scotty.post "/files" processForm) $ do
it "loads uploaded files in memory" $ do
postForm `shouldRespondWith` 200 { matchBody = "2"}
postMpForm `shouldRespondWith` 200 { matchBody = "2"}


describe "text" $ do
Expand Down
Loading