git.iko.soy/typeable/http-api-data
on master help sign in
Stable URL-encoding for Forms (#67)

* Introduce urlEncodeParams and urlDecodeParams * Expose urlEncodeParams/urlDecodeParams helpers * Introduce urlEncodeAsFormStable and use stable encoding for doctests * Fix parameter order for one doctest * Add toEntriesByKeyStable and toListStable * Define custom sortOn to fix GHC 7.8 build

commitb61b1525a7cffb5cde93a080a991427a74cf5489 authorNickolay Kudasov <fizruk@users.noreply.github.com> dateTue 30 Jan 2018, 16:47 (9 years ago) parent5b7bf64
2 files changed+79−20
src/Web/FormUrlEncoded.hs+7 src/Web/Internal/FormUrlEncoded.hs+72−20
src/Web/FormUrlEncoded.hs+7
@@ -14,9 +14,11 @@ module Web.FormUrlEncoded (
1414
1515 -- * Encoding and decoding @'Form'@s
1616 urlEncodeAsForm,
17+ urlEncodeAsFormStable,
1718 urlDecodeAsForm,
1819
1920 urlEncodeForm,
21+ urlEncodeFormStable,
2022 urlDecodeForm,
2123
2224 -- * 'Generic's
@@ -28,7 +30,9 @@ module Web.FormUrlEncoded (
2830 defaultFormOptions,
2931
3032 -- * Helpers
33+ toListStable,
3134 toEntriesByKey,
35+ toEntriesByKeyStable,
3236 fromEntriesByKey,
3337
3438 lookupAll,
@@ -38,6 +42,9 @@ module Web.FormUrlEncoded (
3842 parseAll,
3943 parseMaybe,
4044 parseUnique,
45+
46+ urlEncodeParams,
47+ urlDecodeParams,
4148) where
4249
4350import Web.Internal.FormUrlEncoded
src/Web/Internal/FormUrlEncoded.hs+72−20
@@ -35,10 +35,11 @@ import qualified Data.HashMap.Strict as HashMap
3535import Data.Int
3636import Data.IntMap (IntMap)
3737import qualified Data.IntMap as IntMap
38−import Data.List (intersperse)
38+import Data.List (intersperse, sortBy)
3939import Data.Map (Map)
4040import qualified Data.Map as Map
4141import Data.Monoid
42+import Data.Ord (comparing)
4243
4344import Data.Text (Text)
4445import qualified Data.Text as Text
@@ -186,13 +187,19 @@ newtype Form = Form { unForm :: HashMap Text [Text] }
186187
187188instance Show Form where
188189 showsPrec d form = showParen (d > 10) $
189− showString "fromList " . shows (toList form)
190+ showString "fromList " . shows (toListStable form)
190191
192+-- | _NOTE:_ 'toList' is unstable and may result in different key order (but not values).
193+-- For a stable conversion use 'toListStable'.
191194instance IsList Form where
192195 type Item Form = (Text, Text)
193196 fromList = Form . HashMap.fromListWith (flip (<>)) . fmap (\(k, v) -> (k, [v]))
194197 toList = concatMap (\(k, vs) -> map ((,) k) vs) . HashMap.toList . unForm
195198
199+-- | A stable version of 'toList'.
200+toListStable :: Form -> [(Text, Text)]
201+toListStable = sortOn fst . toList
202+
196203-- | Convert a value into 'Form'.
197204--
198205-- An example type and instance:
@@ -303,7 +310,7 @@ type family NotSupported (cls :: k1) (a :: k2) (reason :: Symbol) :: Constraint
303310-- instance 'ToForm' Post
304311-- @
305312--
306−-- >>> urlEncodeAsForm Post { title = "Test", subtitle = Nothing, comments = ["Nice post!", "+1"] }
313+-- >>> urlEncodeAsFormStable Post { title = "Test", subtitle = Nothing, comments = ["Nice post!", "+1"] }
307314-- "comments=Nice%20post%21&comments=%2B1&title=Test"
308315genericToForm :: forall a. (Generic a, GToForm a (Rep a)) => FormOptions -> a -> Form
309316genericToForm opts = gToForm (Proxy :: Proxy a) opts . from
@@ -388,6 +395,7 @@ class FromForm a where
388395
389396instance FromForm Form where fromForm = pure
390397
398+-- | _NOTE:_ this conversion is unstable and may result in different key order (but not values).
391399instance (FromFormKey k, FromHttpApiData v) => FromForm [(k, v)] where
392400 fromForm = fmap (concatMap (\(k, vs) -> map ((,) k) vs)) . toEntriesByKey
393401
@@ -402,13 +410,22 @@ instance FromHttpApiData v => FromForm (IntMap [v]) where
402410
403411-- | Parse a 'Form' into a list of entries groupped by key.
404412--
405−-- >>> toEntriesByKey [("name", "Nick"), ("color", "red"), ("color", "white")] :: Either Text [(Text, [Text])]
406−-- Right [("color",["red","white"]),("name",["Nick"])]
413+-- _NOTE:_ this conversion is unstable and may result in different key order
414+-- (but not values). For a stable encoding see 'toEntriesByKeyStable'.
407415toEntriesByKey :: (FromFormKey k, FromHttpApiData v) => Form -> Either Text [(k, [v])]
408416toEntriesByKey = traverse parseGroup . HashMap.toList . unForm
409417 where
410418 parseGroup (k, vs) = (,) <$> parseFormKey k <*> traverse parseQueryParam vs
411419
420+-- | Parse a 'Form' into a list of entries groupped by key.
421+--
422+-- >>> toEntriesByKeyStable [("name", "Nick"), ("color", "red"), ("color", "white")] :: Either Text [(Text, [Text])]
423+-- Right [("color",["red","white"]),("name",["Nick"])]
424+--
425+-- For an unstable (but faster) conversion see 'toEntriesByKey'.
426+toEntriesByKeyStable :: (Ord k, FromFormKey k, FromHttpApiData v) => Form -> Either Text [(k, [v])]
427+toEntriesByKeyStable = fmap (sortOn fst) . toEntriesByKey
428+
412429-- | A 'Generic'-based implementation of 'fromForm'.
413430-- This is used as a default implementation in 'FromForm'.
414431--
@@ -482,40 +499,54 @@ instance NotSupported FromForm t "is a sum type" => GFromForm t (f :+: g) where
482499
483500-- | Encode a 'Form' to an @application/x-www-form-urlencoded@ 'BSL.ByteString'.
484501--
502+-- _NOTE:_ this encoding is unstable and may result in different key order
503+-- (but not values). For a stable encoding see 'urlEncodeFormStable'.
504+urlEncodeForm :: Form -> BSL.ByteString
505+urlEncodeForm = urlEncodeParams . toList
506+
507+-- | Encode a 'Form' to an @application/x-www-form-urlencoded@ 'BSL.ByteString'.
508+--
509+-- For an unstable (but faster) encoding see 'urlEncodeForm'.
510+--
485511-- Key-value pairs get encoded to @key=value@ and separated by @&@:
486512--
487−-- >>> urlEncodeForm [("name", "Julian"), ("lastname", "Arni")]
513+-- >>> urlEncodeFormStable [("name", "Julian"), ("lastname", "Arni")]
488514-- "lastname=Arni&name=Julian"
489515--
490516-- Keys with empty values get encoded to just @key@ (without the @=@ sign):
491517--
492−-- >>> urlEncodeForm [("is_test", "")]
518+-- >>> urlEncodeFormStable [("is_test", "")]
493519-- "is_test"
494520--
495521-- Empty keys are allowed too:
496522--
497−-- >>> urlEncodeForm [("", "foobar")]
523+-- >>> urlEncodeFormStable [("", "foobar")]
498524-- "=foobar"
499525--
500−-- However, if not key and value are empty, the key-value pair is ignored.
501−-- (This prevents @'urlDecodeForm' . 'urlEncodeForm'@ from being a true isomorphism).
526+-- However, if both key and value are empty, the key-value pair is ignored.
527+-- (This prevents @'urlDecodeForm' . 'urlEncodeFormStable'@ from being a true isomorphism).
502528--
503−-- >>> urlEncodeForm [("", "")]
529+-- >>> urlEncodeFormStable [("", "")]
504530-- ""
505531--
506532-- Everything is escaped with @'escapeURIString' 'isUnreserved'@:
507533--
508−-- >>> urlEncodeForm [("fullname", "Andres Löh")]
534+-- >>> urlEncodeFormStable [("fullname", "Andres Löh")]
509535-- "fullname=Andres%20L%C3%B6h"
510−urlEncodeForm :: Form -> BSL.ByteString
511−urlEncodeForm = toLazyByteString . mconcat . intersperse (shortByteString "&") . map encodePair . toList
536+urlEncodeFormStable :: Form -> BSL.ByteString
537+urlEncodeFormStable = urlEncodeParams . sortOn fst . toList
538+
539+-- | Encode a list of key-value pairs to an @application/x-www-form-urlencoded@ 'BSL.ByteString'.
540+--
541+-- See also 'urlEncodeFormStable'.
542+urlEncodeParams :: [(Text, Text)] -> BSL.ByteString
543+urlEncodeParams = toLazyByteString . mconcat . intersperse (shortByteString "&") . map encodePair
512544 where
513545 escape = urlEncodeQuery . Text.encodeUtf8
514546
515547 encodePair (k, "") = escape k
516548 encodePair (k, v) = escape k <> shortByteString "=" <> escape v
517549
518−
519550-- | Decode an @application/x-www-form-urlencoded@ 'BSL.ByteString' to a 'Form'.
520551--
521552-- Key-value pairs get decoded normally:
@@ -548,7 +579,13 @@ urlEncodeForm = toLazyByteString . mconcat . intersperse (shortByteString "&") .
548579-- >>> urlDecodeForm "this=has=too=many=equals"
549580-- Left "not a valid pair: this=has=too=many=equals"
550581urlDecodeForm :: BSL.ByteString -> Either Text Form
551−urlDecodeForm bs = toForm <$> traverse parsePair pairs
582+urlDecodeForm = fmap toForm . urlDecodeParams
583+
584+-- | Decode an @application/x-www-form-urlencoded@ 'BSL.ByteString' to a list of key-value pairs.
585+--
586+-- See also 'urlDecodeForm'.
587+urlDecodeParams :: BSL.ByteString -> Either Text [(Text, Text)]
588+urlDecodeParams bs = traverse parsePair pairs
552589 where
553590 pairs = map (BSL8.split '=') (BSL8.split '&' bs)
554591
@@ -560,6 +597,7 @@ urlDecodeForm bs = toForm <$> traverse parsePair pairs
560597 [k] -> return (k, "")
561598 xs -> Left $ "not a valid pair: " <> Text.intercalate "=" xs
562599
600+
563601-- | This is a convenience function for decoding a
564602-- @application/x-www-form-urlencoded@ 'BSL.ByteString' directly to a datatype
565603-- that has an instance of 'FromForm'.
@@ -577,11 +615,22 @@ urlDecodeAsForm = fromForm <=< urlDecodeForm
577615--
578616-- This is effectively @'urlEncodeForm' . 'toForm'@.
579617--
580−-- >>> urlEncodeAsForm Person {name = "Dennis", age = 22}
581−-- "age=22&name=Dennis"
618+-- _NOTE:_ this encoding is unstable and may result in different key order
619+-- (but not values). For a stable encoding see 'urlEncodeAsFormStable'.
582620urlEncodeAsForm :: ToForm a => a -> BSL.ByteString
583621urlEncodeAsForm = urlEncodeForm . toForm
584622
623+-- | This is a convenience function for encoding a datatype that has instance
624+-- of 'ToForm' directly to a @application/x-www-form-urlencoded@
625+-- 'BSL.ByteString'.
626+--
627+-- This is effectively @'urlEncodeFormStable' . 'toForm'@.
628+--
629+-- >>> urlEncodeAsFormStable Person {name = "Dennis", age = 22}
630+-- "age=22&name=Dennis"
631+urlEncodeAsFormStable :: ToForm a => a -> BSL.ByteString
632+urlEncodeAsFormStable = urlEncodeFormStable . toForm
633+
585634-- | Find all values corresponding to a given key in a 'Form'.
586635--
587636-- >>> lookupAll "name" []
@@ -692,8 +741,8 @@ parseUnique key form = lookupUnique key form >>= parseQueryParam
692741-- 'fromForm' = 'genericFromForm' myOptions
693742-- @
694743--
695−-- >>> urlEncodeAsForm Project { projectName = "http-api-data", projectSize = 172 }
696−-- "size=172&name=http-api-data"
744+-- >>> urlEncodeAsFormStable Project { projectName = "http-api-data", projectSize = 172 }
745+-- "name=http-api-data&size=172"
697746-- >>> urlDecodeAsForm "name=http-api-data&size=172" :: Either Text Project
698747-- Right (Project {projectName = "http-api-data", projectSize = 172})
699748data FormOptions = FormOptions
@@ -712,3 +761,6 @@ defaultFormOptions :: FormOptions
712761defaultFormOptions = FormOptions
713762 { fieldLabelModifier = id
714763 }
764+
765+sortOn :: Ord b => (a -> b) -> [a] -> [a]
766+sortOn f = sortBy (comparing f)