git.iko.soy/typeable/http-api-data
on master help sign in
Stable URL-encoding for Forms (#67)
b61b152Nickolay Kudasov
src/Web/FormUrlEncoded.hs+7
@@ -14,9 +14,11 @@ module Web.FormUrlEncoded (
14
14
15 -- * Encoding and decoding @'Form'@s
15 -- * Encoding and decoding @'Form'@s
16 urlEncodeAsForm,
16 urlEncodeAsForm,
17+ urlEncodeAsFormStable,
17 urlDecodeAsForm,
18 urlDecodeAsForm,
18
19
19 urlEncodeForm,
20 urlEncodeForm,
21+ urlEncodeFormStable,
20 urlDecodeForm,
22 urlDecodeForm,
21
23
22 -- * 'Generic's
24 -- * 'Generic's
@@ -28,7 +30,9 @@ module Web.FormUrlEncoded (
28 defaultFormOptions,
30 defaultFormOptions,
29
31
30 -- * Helpers
32 -- * Helpers
33+ toListStable,
31 toEntriesByKey,
34 toEntriesByKey,
35+ toEntriesByKeyStable,
32 fromEntriesByKey,
36 fromEntriesByKey,
33
37
34 lookupAll,
38 lookupAll,
@@ -38,6 +42,9 @@ module Web.FormUrlEncoded (
38 parseAll,
42 parseAll,
39 parseMaybe,
43 parseMaybe,
40 parseUnique,
44 parseUnique,
45+
46+ urlEncodeParams,
47+ urlDecodeParams,
41) where
48) where
42
49
43import Web.Internal.FormUrlEncoded
50import Web.Internal.FormUrlEncoded
src/Web/Internal/FormUrlEncoded.hs+72−20
@@ -35,10 +35,11 @@ import qualified Data.HashMap.Strict as HashMap
35import Data.Int
35import Data.Int
36import Data.IntMap (IntMap)
36import Data.IntMap (IntMap)
37import qualified Data.IntMap as IntMap
37import qualified Data.IntMap as IntMap
38−import Data.List (intersperse)
38+import Data.List (intersperse, sortBy)
39import Data.Map (Map)
39import Data.Map (Map)
40import qualified Data.Map as Map
40import qualified Data.Map as Map
41import Data.Monoid
41import Data.Monoid
42+import Data.Ord (comparing)
42
43
43import Data.Text (Text)
44import Data.Text (Text)
44import qualified Data.Text as Text
45import qualified Data.Text as Text
@@ -186,13 +187,19 @@ newtype Form = Form { unForm :: HashMap Text [Text] }
186
187
187instance Show Form where
188instance Show Form where
188 showsPrec d form = showParen (d > 10) $
189 showsPrec d form = showParen (d > 10) $
189− showString "fromList " . shows (toList form)
190+ showString "fromList " . shows (toListStable form)
190
191
192+-- | _NOTE:_ 'toList' is unstable and may result in different key order (but not values).
193+-- For a stable conversion use 'toListStable'.
191instance IsList Form where
194instance IsList Form where
192 type Item Form = (Text, Text)
195 type Item Form = (Text, Text)
193 fromList = Form . HashMap.fromListWith (flip (<>)) . fmap (\(k, v) -> (k, [v]))
196 fromList = Form . HashMap.fromListWith (flip (<>)) . fmap (\(k, v) -> (k, [v]))
194 toList = concatMap (\(k, vs) -> map ((,) k) vs) . HashMap.toList . unForm
197 toList = concatMap (\(k, vs) -> map ((,) k) vs) . HashMap.toList . unForm
195
198
199+-- | A stable version of 'toList'.
200+toListStable :: Form -> [(Text, Text)]
201+toListStable = sortOn fst . toList
202+
196-- | Convert a value into 'Form'.
203-- | Convert a value into 'Form'.
197--
204--
198-- An example type and instance:
205-- An example type and instance:
@@ -303,7 +310,7 @@ type family NotSupported (cls :: k1) (a :: k2) (reason :: Symbol) :: Constraint
303-- instance 'ToForm' Post
310-- instance 'ToForm' Post
304-- @
311-- @
305--
312--
306−-- >>> urlEncodeAsForm Post { title = "Test", subtitle = Nothing, comments = ["Nice post!", "+1"] }
313+-- >>> urlEncodeAsFormStable Post { title = "Test", subtitle = Nothing, comments = ["Nice post!", "+1"] }
307-- "comments=Nice%20post%21&comments=%2B1&title=Test"
314-- "comments=Nice%20post%21&comments=%2B1&title=Test"
308genericToForm :: forall a. (Generic a, GToForm a (Rep a)) => FormOptions -> a -> Form
315genericToForm :: forall a. (Generic a, GToForm a (Rep a)) => FormOptions -> a -> Form
309genericToForm opts = gToForm (Proxy :: Proxy a) opts . from
316genericToForm opts = gToForm (Proxy :: Proxy a) opts . from
@@ -388,6 +395,7 @@ class FromForm a where
388
395
389instance FromForm Form where fromForm = pure
396instance FromForm Form where fromForm = pure
390
397
398+-- | _NOTE:_ this conversion is unstable and may result in different key order (but not values).
391instance (FromFormKey k, FromHttpApiData v) => FromForm [(k, v)] where
399instance (FromFormKey k, FromHttpApiData v) => FromForm [(k, v)] where
392 fromForm = fmap (concatMap (\(k, vs) -> map ((,) k) vs)) . toEntriesByKey
400 fromForm = fmap (concatMap (\(k, vs) -> map ((,) k) vs)) . toEntriesByKey
393
401
@@ -402,13 +410,22 @@ instance FromHttpApiData v => FromForm (IntMap [v]) where
402
410
403-- | Parse a 'Form' into a list of entries groupped by key.
411-- | Parse a 'Form' into a list of entries groupped by key.
404--
412--
405−-- >>> toEntriesByKey [("name", "Nick"), ("color", "red"), ("color", "white")] :: Either Text [(Text, [Text])]
413+-- _NOTE:_ this conversion is unstable and may result in different key order
406−-- Right [("color",["red","white"]),("name",["Nick"])]
414+-- (but not values). For a stable encoding see 'toEntriesByKeyStable'.
407toEntriesByKey :: (FromFormKey k, FromHttpApiData v) => Form -> Either Text [(k, [v])]
415toEntriesByKey :: (FromFormKey k, FromHttpApiData v) => Form -> Either Text [(k, [v])]
408toEntriesByKey = traverse parseGroup . HashMap.toList . unForm
416toEntriesByKey = traverse parseGroup . HashMap.toList . unForm
409 where
417 where
410 parseGroup (k, vs) = (,) <$> parseFormKey k <*> traverse parseQueryParam vs
418 parseGroup (k, vs) = (,) <$> parseFormKey k <*> traverse parseQueryParam vs
411
419
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+
412-- | A 'Generic'-based implementation of 'fromForm'.
429-- | A 'Generic'-based implementation of 'fromForm'.
413-- This is used as a default implementation in 'FromForm'.
430-- This is used as a default implementation in 'FromForm'.
414--
431--
@@ -482,40 +499,54 @@ instance NotSupported FromForm t "is a sum type" => GFromForm t (f :+: g) where
482
499
483-- | Encode a 'Form' to an @application/x-www-form-urlencoded@ 'BSL.ByteString'.
500-- | Encode a 'Form' to an @application/x-www-form-urlencoded@ 'BSL.ByteString'.
484--
501--
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+--
485-- Key-value pairs get encoded to @key=value@ and separated by @&@:
511-- Key-value pairs get encoded to @key=value@ and separated by @&@:
486--
512--
487−-- >>> urlEncodeForm [("name", "Julian"), ("lastname", "Arni")]
513+-- >>> urlEncodeFormStable [("name", "Julian"), ("lastname", "Arni")]
488-- "lastname=Arni&name=Julian"
514-- "lastname=Arni&name=Julian"
489--
515--
490-- Keys with empty values get encoded to just @key@ (without the @=@ sign):
516-- Keys with empty values get encoded to just @key@ (without the @=@ sign):
491--
517--
492−-- >>> urlEncodeForm [("is_test", "")]
518+-- >>> urlEncodeFormStable [("is_test", "")]
493-- "is_test"
519-- "is_test"
494--
520--
495-- Empty keys are allowed too:
521-- Empty keys are allowed too:
496--
522--
497−-- >>> urlEncodeForm [("", "foobar")]
523+-- >>> urlEncodeFormStable [("", "foobar")]
498-- "=foobar"
524-- "=foobar"
499--
525--
500−-- However, if not key and value are empty, the key-value pair is ignored.
526+-- However, if both key and value are empty, the key-value pair is ignored.
501−-- (This prevents @'urlDecodeForm' . 'urlEncodeForm'@ from being a true isomorphism).
527+-- (This prevents @'urlDecodeForm' . 'urlEncodeFormStable'@ from being a true isomorphism).
502--
528--
503−-- >>> urlEncodeForm [("", "")]
529+-- >>> urlEncodeFormStable [("", "")]
504-- ""
530-- ""
505--
531--
506-- Everything is escaped with @'escapeURIString' 'isUnreserved'@:
532-- Everything is escaped with @'escapeURIString' 'isUnreserved'@:
507--
533--
508−-- >>> urlEncodeForm [("fullname", "Andres Löh")]
534+-- >>> urlEncodeFormStable [("fullname", "Andres Löh")]
509-- "fullname=Andres%20L%C3%B6h"
535-- "fullname=Andres%20L%C3%B6h"
510−urlEncodeForm :: Form -> BSL.ByteString
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
511−urlEncodeForm = toLazyByteString . mconcat . intersperse (shortByteString "&") . map encodePair . toList
543+urlEncodeParams = toLazyByteString . mconcat . intersperse (shortByteString "&") . map encodePair
512 where
544 where
513 escape = urlEncodeQuery . Text.encodeUtf8
545 escape = urlEncodeQuery . Text.encodeUtf8
514
546
515 encodePair (k, "") = escape k
547 encodePair (k, "") = escape k
516 encodePair (k, v) = escape k <> shortByteString "=" <> escape v
548 encodePair (k, v) = escape k <> shortByteString "=" <> escape v
517
549
518−
519-- | Decode an @application/x-www-form-urlencoded@ 'BSL.ByteString' to a 'Form'.
550-- | Decode an @application/x-www-form-urlencoded@ 'BSL.ByteString' to a 'Form'.
520--
551--
521-- Key-value pairs get decoded normally:
552-- Key-value pairs get decoded normally:
@@ -548,7 +579,13 @@ urlEncodeForm = toLazyByteString . mconcat . intersperse (shortByteString "&") .
548-- >>> urlDecodeForm "this=has=too=many=equals"
579-- >>> urlDecodeForm "this=has=too=many=equals"
549-- Left "not a valid pair: this=has=too=many=equals"
580-- Left "not a valid pair: this=has=too=many=equals"
550urlDecodeForm :: BSL.ByteString -> Either Text Form
581urlDecodeForm :: BSL.ByteString -> Either Text Form
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)]
551−urlDecodeForm bs = toForm <$> traverse parsePair pairs
588+urlDecodeParams bs = traverse parsePair pairs
552 where
589 where
553 pairs = map (BSL8.split '=') (BSL8.split '&' bs)
590 pairs = map (BSL8.split '=') (BSL8.split '&' bs)
554
591
@@ -560,6 +597,7 @@ urlDecodeForm bs = toForm <$> traverse parsePair pairs
560 [k] -> return (k, "")
597 [k] -> return (k, "")
561 xs -> Left $ "not a valid pair: " <> Text.intercalate "=" xs
598 xs -> Left $ "not a valid pair: " <> Text.intercalate "=" xs
562
599
600+
563-- | This is a convenience function for decoding a
601-- | This is a convenience function for decoding a
564-- @application/x-www-form-urlencoded@ 'BSL.ByteString' directly to a datatype
602-- @application/x-www-form-urlencoded@ 'BSL.ByteString' directly to a datatype
565-- that has an instance of 'FromForm'.
603-- that has an instance of 'FromForm'.
@@ -577,11 +615,22 @@ urlDecodeAsForm = fromForm <=< urlDecodeForm
577--
615--
578-- This is effectively @'urlEncodeForm' . 'toForm'@.
616-- This is effectively @'urlEncodeForm' . 'toForm'@.
579--
617--
580−-- >>> urlEncodeAsForm Person {name = "Dennis", age = 22}
618+-- _NOTE:_ this encoding is unstable and may result in different key order
581−-- "age=22&name=Dennis"
619+-- (but not values). For a stable encoding see 'urlEncodeAsFormStable'.
582urlEncodeAsForm :: ToForm a => a -> BSL.ByteString
620urlEncodeAsForm :: ToForm a => a -> BSL.ByteString
583urlEncodeAsForm = urlEncodeForm . toForm
621urlEncodeAsForm = urlEncodeForm . toForm
584
622
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+
585-- | Find all values corresponding to a given key in a 'Form'.
634-- | Find all values corresponding to a given key in a 'Form'.
586--
635--
587-- >>> lookupAll "name" []
636-- >>> lookupAll "name" []
@@ -692,8 +741,8 @@ parseUnique key form = lookupUnique key form >>= parseQueryParam
692-- 'fromForm' = 'genericFromForm' myOptions
741-- 'fromForm' = 'genericFromForm' myOptions
693-- @
742-- @
694--
743--
695−-- >>> urlEncodeAsForm Project { projectName = "http-api-data", projectSize = 172 }
744+-- >>> urlEncodeAsFormStable Project { projectName = "http-api-data", projectSize = 172 }
696−-- "size=172&name=http-api-data"
745+-- "name=http-api-data&size=172"
697-- >>> urlDecodeAsForm "name=http-api-data&size=172" :: Either Text Project
746-- >>> urlDecodeAsForm "name=http-api-data&size=172" :: Either Text Project
698-- Right (Project {projectName = "http-api-data", projectSize = 172})
747-- Right (Project {projectName = "http-api-data", projectSize = 172})
699data FormOptions = FormOptions
748data FormOptions = FormOptions
@@ -712,3 +761,6 @@ defaultFormOptions :: FormOptions
712defaultFormOptions = FormOptions
761defaultFormOptions = FormOptions
713 { fieldLabelModifier = id
762 { fieldLabelModifier = id
714 }
763 }
764+
765+sortOn :: Ord b => (a -> b) -> [a] -> [a]
766+sortOn f = sortBy (comparing f)