on master help sign in
Merge pull request #443 from obsidiansystems/cg-tabulation

Add some new encoders to aid in correctly writing encoders that encode query parameters from record types.

commit6bb56ab0526b576d9d15b8511d47a21d197d066e authorAli Abrar <aliabrar@gmail.com> dateThu 13 Jun 2019, 17:26 (7 years ago) parenta326dd3aeb61bf
12 files changed+188−11
CONTRIBUTING.md+8 ChangeLog.md+2 default.nix+1 lib/route/obelisk-route.cabal+4 lib/route/src/Obelisk/Route.hs+83−9 lib/selftest/src/Obelisk/SelfTest.hs+2−1 lib/tabulation/CHANGELOG.md+5 lib/tabulation/LICENSE+30 lib/tabulation/Setup.hs+2 lib/tabulation/src/Data/Tabulation.hs+21 lib/tabulation/tabulation.cabal+26 release.nix+4−1
CONTRIBUTING.md+8
@@ -49,6 +49,14 @@ You can run the tests like this:
4949$(nix-build -A selftest --no-out-link)
5050```
5151
52+To test that your changes build across platforms, you can also try to build release.nix, like this:
53+```bash
54+nix-build release.nix
55+```
56+
57+Note, however, that to build release.nix you must accept the android license agreement and your machine must be configured to build both ios and android executables (usually via remote builders).
58+
59+
5260### Documentation
5361
5462#### In the code
ChangeLog.md+2
@@ -4,6 +4,8 @@ This project's release branch is `master`. This log is written from the perspect
44
55## Unreleased
66
7+* Add tabulation package. See Data.Tabulation for details.
8+* Add encoders for `DMap`, `HasFields` (cf. Data.Tabulation), and JSON.
79
810## v0.1.1.0 - 2019-05-17
911
default.nix+1
@@ -67,6 +67,7 @@ let
6767 obelisk-route = self.callCabal2nix "obelisk-route" (cleanSource ./lib/route) {};
6868 obelisk-selftest = self.callCabal2nix "obelisk-selftest" (cleanSource ./lib/selftest) {};
6969 obelisk-snap-extras = self.callCabal2nix "obelisk-snap-extras" (cleanSource ./lib/snap-extras) {};
70+ tabulation = self.callCabal2nix "tabulation" (cleanSource ./lib/tabulation) {};
7071 })
7172
7273 (self: super: let
lib/route/obelisk-route.cabal+4
@@ -6,8 +6,11 @@ build-type: Simple
66library
77 hs-source-dirs: src
88 build-depends: base,
9+ aeson,
10+ bytestring,
911 categories,
1012 constraints,
13+ constraints-extras,
1114 containers,
1215 dependent-map,
1316 dependent-sum,
@@ -27,6 +30,7 @@ library
2730 reflex,
2831 reflex-dom-core,
2932 template-haskell,
33+ tabulation,
3034 text,
3135 transformers,
3236 universe,
lib/route/src/Obelisk/Route.hs+83−9
@@ -13,6 +13,7 @@
1313{-# LANGUAGE RankNTypes #-}
1414{-# LANGUAGE ScopedTypeVariables #-}
1515{-# LANGUAGE TemplateHaskell #-}
16+{-# LANGUAGE TypeApplications #-}
1617{-# LANGUAGE TypeFamilies #-}
1718{-# LANGUAGE TypeOperators #-}
1819{-# LANGUAGE ViewPatterns #-}
@@ -85,6 +86,11 @@ module Obelisk.Route
8586 , readShowEncoder
8687 , integralEncoder
8788 , pathSegmentEncoder
89+ , queryOnlyEncoder
90+ , Decoder(..)
91+ , dmapEncoder
92+ , fieldMapEncoder
93+ , jsonEncoder
8894 ) where
8995
9096import Prelude hiding ((.), id)
@@ -97,6 +103,7 @@ import Control.Category.Associative
97103import Control.Category.Monoidal
98104import Control.Lens (Identity (..), Prism', makePrisms, itraverse, imap, prism, (^.), re, matching, (^?), _Just, _Nothing, Iso', from, view, Wrapped (..))
99105import Control.Monad.Except
106+import qualified Data.ByteString.Lazy as BSL
100107import Data.Dependent.Sum (DSum (..))
101108import Data.Dependent.Map (DMap)
102109import qualified Data.Dependent.Map as DMap
@@ -124,6 +131,9 @@ import Network.HTTP.Types.URI
124131import qualified Numeric.Lens
125132import Obelisk.Route.TH
126133import Text.Read (readMaybe)
134+import Data.Tabulation
135+import qualified Data.Aeson as Aeson
136+import Data.Aeson (FromJSON, ToJSON)
127137
128138-- Design goals:
129139-- No start-up time on the frontend (not yet met)
@@ -343,7 +353,7 @@ maybeEncoder f g = shadowEncoder f g . maybeToEitherEncoder
343353justEncoder :: (Applicative check, MonadError Text parse) => Encoder check parse a (Maybe a)
344354justEncoder = prismEncoder _Just
345355
346−-- |
356+-- | Encode () to 'Nothing'.
347357nothingEncoder :: (Applicative check, MonadError Text parse) => Encoder check parse () (Maybe a)
348358nothingEncoder = prismEncoder _Nothing
349359
@@ -547,13 +557,10 @@ pathOnlyEncoderIgnoringQuery = unsafeMkEncoder $ EncoderImpl
547557 }
548558
549559pathOnlyEncoder :: (Applicative check, MonadError Text parse) => Encoder check parse [Text] PageName
550−pathOnlyEncoder = unsafeMkEncoder $ EncoderImpl
551− { _encoderImpl_decode = \(path, query) ->
552− if query == mempty
553− then pure path
554− else throwError "pathOnlyEncoderImpl: query was provided"
555− , _encoderImpl_encode = \path -> (path, mempty)
556− }
560+pathOnlyEncoder = second (unitEncoder mempty) . coidr
561+
562+queryOnlyEncoder :: (Applicative check, MonadError Text parse) => Encoder check parse (Map Text (Maybe Text)) PageName
563+queryOnlyEncoder = first (unitEncoder []) . coidl
557564
558565singletonListEncoder :: (Applicative check, MonadError Text parse) => Encoder check parse a [a]
559566singletonListEncoder = unsafeMkEncoder $ EncoderImpl
@@ -866,7 +873,6 @@ renderObeliskRoute e r =
866873readShowEncoder :: (MonadError Text parse, Read a, Show a, Applicative check) => Encoder check parse a PageName
867874readShowEncoder = singlePathSegmentEncoder . unsafeTshowEncoder
868875
869−
870876integralEncoder :: (MonadError Text parse, Applicative check, Integral a) => Encoder check parse a Integer
871877integralEncoder = prismEncoder (Numeric.Lens.integral)
872878
@@ -879,4 +885,72 @@ pathSegmentEncoder = unsafeMkEncoder EncoderImpl
879885 (x:xs) -> pure (x, (xs, y))
880886 }
881887
888+newtype Decoder check parse b a = Decoder { toEncoder :: Encoder check parse a b }
889+
890+dmapEncoder :: forall check parse k k' v.
891+ ( Monad check
892+ , MonadError Text parse
893+ , Universe (Some k')
894+ , Ord k
895+ , GCompare k'
896+ , GShow k'
897+ )
898+ => Encoder check parse (Some k') k
899+ -> (forall v'. k' v' -> Encoder check parse v' v)
900+ -> Encoder check parse (DMap k' Identity) (Map k v)
901+dmapEncoder keyEncoder' valueEncoderFor = unsafeEncoder $ do
902+ keyEncoder :: Encoder Identity parse (Some k') k <- checkEncoder keyEncoder'
903+ valueDecoders :: DMap k' (Decoder Identity parse v) <- fmap DMap.fromList . forM universe $ \(Some.This (k' :: k' t)) -> do
904+ ve :: Encoder Identity parse t v <- checkEncoder (valueEncoderFor k')
905+ return $ (k' :: k' t) :=> (Decoder ve :: Decoder Identity parse v t)
906+ let keyError k = "dmapEncoder: key `" <> k <> "' was missing from the Universe instance for its type."
907+ return $ EncoderImpl
908+ { _encoderImpl_encode = \dm -> Map.fromList $ do
909+ ((k' :: k' t) :=> Identity v') <- DMap.toList dm
910+ return ( encode keyEncoder (Some.This k')
911+ , encode (toEncoder (DMap.findWithDefault (error . keyError $ gshow k') k' valueDecoders)) v'
912+ )
913+ , _encoderImpl_decode = \m -> fmap DMap.fromList . forM (Map.toList m) $ \(k,v) -> do
914+ Some.This (k' :: k' t) <- tryDecode keyEncoder k
915+ case DMap.lookup k' valueDecoders of
916+ Nothing -> throwError . T.pack . keyError $ gshow k'
917+ Just (Decoder e) -> do
918+ v' <- tryDecode e v
919+ return (k' :=> Identity v')
920+ }
921+
922+fieldMapEncoder :: forall check parse r.
923+ ( Applicative check
924+ , MonadError Text parse
925+ , HasFields r
926+ , Universe (Some (Field r))
927+ , GShow (Field r)
928+ , GCompare (Field r)
929+ )
930+ => Encoder check parse r (DMap (Field r) Identity)
931+fieldMapEncoder = unsafeEncoder $ do
932+ pure $ EncoderImpl
933+ { _encoderImpl_encode = \r -> DMap.fromList [ f :=> Identity (indexField r f) | Some.This f <- universe ]
934+ , _encoderImpl_decode = \dm -> tabulateFieldsA $ \f -> do
935+ case DMap.lookup f dm of
936+ Nothing -> throwError $ "fieldMapEncoder: Couldn't find key for `" <> T.pack (gshow f) <> "' in DMap."
937+ Just (Identity v) -> return v
938+ }
939+
940+-- | Use ToJSON/FromJSON to encode to Text. The correctness of this encoder is dependent on the encoding being injective and round-tripping correctly.
941+jsonEncoder :: forall check parse r.
942+ ( ToJSON r
943+ , FromJSON r
944+ , Applicative check
945+ , MonadError Text parse
946+ )
947+ => Encoder check parse r Text
948+jsonEncoder = unsafeEncoder $ do
949+ pure $ EncoderImpl
950+ { _encoderImpl_encode = \r -> decodeUtf8 . BSL.toStrict $ Aeson.encode r
951+ , _encoderImpl_decode = \t -> case Aeson.eitherDecodeStrict $ encodeUtf8 t of
952+ Left err -> throwError ("jsonEncoder: " <> T.pack err)
953+ Right x -> return x
954+ }
955+
882956--TODO: decodeURIComponent as appropriate
lib/selftest/src/Obelisk/SelfTest.hs+2−1
@@ -129,7 +129,8 @@ main = do
129129
130130 it "produces a valid route config" $ inTmpObInit $ \tmp -> liftIO $ do
131131 configs <- getConfigs
132− withCurrentDirectory (T.unpack $ toTextIgnore tmp) $ getConfigRoute configs `shouldNotReturn` Nothing
132+ withCurrentDirectory (T.unpack $ toTextIgnore tmp) $
133+ return (either (const Nothing) Just $ getConfigRoute configs) `shouldNotReturn` Nothing
133134
134135 -- These tests fail with "Could not find module 'Obelisk.Generated.Static'"
135136 -- when not run by 'nix-build --attr selftest'
lib/tabulation/CHANGELOG.mdnew file+5
@@ -0,0 +1,5 @@
1+# Revision history for tabulation
2+
3+## 0.1.0.0 -- 2019-05-24
4+
5+* First version. Basically carve out this library as a place for the HasFields class.
lib/tabulation/LICENSEnew file+30
@@ -0,0 +1,30 @@
1+Copyright (c) 2019, Cale Gibbard
2+
3+All rights reserved.
4+
5+Redistribution and use in source and binary forms, with or without
6+modification, are permitted provided that the following conditions are met:
7+
8+ * Redistributions of source code must retain the above copyright
9+ notice, this list of conditions and the following disclaimer.
10+
11+ * Redistributions in binary form must reproduce the above
12+ copyright notice, this list of conditions and the following
13+ disclaimer in the documentation and/or other materials provided
14+ with the distribution.
15+
16+ * Neither the name of Cale Gibbard nor the names of other
17+ contributors may be used to endorse or promote products derived
18+ from this software without specific prior written permission.
19+
20+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
lib/tabulation/Setup.hsnew file+2
@@ -0,0 +1,2 @@
1+import Distribution.Simple
2+main = defaultMain
lib/tabulation/src/Data/Tabulation.hsnew file+21
@@ -0,0 +1,21 @@
1+{-# LANGUAGE TypeFamilies #-}
2+{-# LANGUAGE RankNTypes #-}
3+
4+module Data.Tabulation where
5+
6+import Control.Lens
7+
8+-- | This is a class for record types whose fields can be enumerated by an associated GADT. It's closely related to the concept of a representable functor, except without the functor part, and the fields are not all the same type.
9+class HasFields a where
10+ type Field a :: * -> *
11+
12+ fieldLens :: Field a x -> Lens' a x
13+ tabulateFieldsA :: Applicative f => (forall x. Field a x -> f x) -> f a
14+
15+ tabulateFields :: (forall x. Field a x -> x) -> a
16+ tabulateFields f = runIdentity (tabulateFieldsA (Identity . f))
17+ traverseWithField :: Applicative m => (forall x. Field a x -> x -> m x) -> a -> m a
18+ traverseWithField t r = tabulateFieldsA (\f -> t f (indexField r f))
19+ indexField :: a -> Field a x -> x
20+ indexField a f = a ^. fieldLens f
21+ {-# MINIMAL fieldLens, tabulateFieldsA #-}
lib/tabulation/tabulation.cabalnew file+26
@@ -0,0 +1,26 @@
1+cabal-version: >=1.10
2+-- Initial package description 'tabulation.cabal' generated by 'cabal
3+-- init'. For further documentation, see
4+-- http://haskell.org/cabal/users-guide/
5+
6+name: tabulation
7+version: 0.1.0.0
8+synopsis: A library for types that can be completely reconstructed by fields as specified by a GADT.
9+-- description:
10+-- bug-reports:
11+license: BSD3
12+license-file: LICENSE
13+author: Cale Gibbard
14+maintainer: cale.gibbard@obsidian.systems
15+-- copyright:
16+category: Data
17+build-type: Simple
18+extra-source-files: CHANGELOG.md
19+
20+library
21+ exposed-modules: Data.Tabulation
22+ -- other-modules:
23+ -- other-extensions:
24+ build-depends: base, lens
25+ hs-source-dirs: src
26+ default-language: Haskell2010
release.nix+4−1
@@ -61,7 +61,10 @@ let
6161 ];
6262 command = local-self.command;
6363 serverExeSkeleton = (import ./skeleton { obelisk = local-self; }).exe;
64− androidSkeleton = (import ./skeleton { obelisk = local-self; }).android.frontend;
64+ androidObelisk = import ./. (self-args // {
65+ config.android_sdk.accept_license = true;
66+ });
67+ androidSkeleton = (import ./skeleton { obelisk = androidObelisk; }).android.frontend;
6568 iosObelisk = import ./. (self-args // {
6669 system = "x86_64-darwin";
6770 iosSdkVersion = "10.2";