Polysemy is fun! - Part 2
31 Jul 2019If you have not already gone through the previous post, please do so for context. All the code discussed in this post is available at https://gitlab.com/rkaippully/polysemy-password-manager/tree/part2.
Running Effects
So far we have seen how to define an effect as a data type and how to embed such effect values in the Sem monad. But
those effects were not “doing” anything. It’s all nice to have a good looking program, but what is the point if it does
not do something? How do we run the code so that we have a real password manager?
It is not hard to run effects. Remember me saying that the r in Sem r a is a list of effects? We pick the first
effect from that list and find a function that can handle that effect and eliminate it from the list. For example, if we
have a program of type Sem [e1, e2] a, we must find a function that will interpret the e1 effect. Applying that
function will consume the e1 effect and give us a value of type Sem [e2] a. Repeat this with an interpreter for e2
and you get a Sem [] a. This is a value with no effects. You can use the
run function to get the value out of
it.
Let us see how this works in practice.
Interpreting CryptoHash
The first step is to define an interpreter for the CryptoHash effect that we defined. We’ll use the
BCrypt algorithm to manipulate password hashes as defined in the cryptonite
library. The hashPassword and
validatePassword functions in cryptonite seem to correspond to the MakeHash and ValidateHash data constructors of
CryptoHash.
There is a function named
interpret in polysemy that lets
you handle an effect such as CryptoHash.
In the first round, we’ll implement ValidateHash.
runCryptoHashAsState :: Sem (CryptoHash : r) a -> Sem r a
runCryptoHashAsState = interpret $ \case
ValidateHash password hash -> return (validatePassword password hash)
As you can see, interpret takes a function as its only parameter. This function has to transform a CryptoHash m x
value into a Sem r x value.
Also, pay close attention to the type signature Sem (CryptoHash : r) a -> Sem r a. You might recall that the first
type parameter to Sem is a type-level list of effects. Here (CryptoHash : r) is a list of effects with CryptoHash
at its head and r as the tail - the remaining effects. After running the interpret function we eliminate the
CryptoHash effect from the type (because it has been interpreted) and return a Sem r a.
Interpreting ValidateHash is straightforward. We just delegate the call to validatePassword and lift the result into
Sem r monad.
Effect Dependencies
Interpreting MakeHash via hashPassword is trickier. The type signature of hashPassword is MonadRandom m => Int ->
Password -> m PasswordHash. We need to run the hashing in a monad that allows random number generation. One choice is
to run it in IO monad. But that will cause our Sem r to be polluted with IO. This will allow some other piece of
code in this monad to run arbitrary IO actions. Let us avoid it if we can.
Another choice is to use a pseudo-random number
generator with a
deterministic random number generator (DRG) initialized by a seed value. If we have access to such a DRG, we can use
withDRG function to run
hashPassword in a MonadRandom context. But every time we use a DRG, its internal state gets updated and we get a new
DRG value. So we need a mechanism to store this updated DRG and pass it to the next MakeHash usage.
This is where State effect
comes in. We can use it to retrieve the current value of DRG. Then we invoke withDRG to generate a password hash. This
invocation will also return an updated DRG which we’ll save back into the State effect.
The code looks like this:
MakeHash password -> do
drg <- get
let (h, drg') = withDRG drg (hashPassword 10 password)
put drg'
return h
We managed to interpret the CryptoHash effect but that requires a State effect. We express this in the type
signature of runCryptoHashAsState function:
runCryptoHashAsState :: (DRG gen, Member (State gen) r)
=> Sem (CryptoHash : r) a
-> Sem r a
runCryptoHashAsState = interpret $ \case
ValidateHash password hash -> return (validatePassword password hash)
MakeHash password -> do
drg <- get
let (hash, drg') = withDRG drg (hashPassword 10 password)
put drg'
return hash
The constraint Member (State gen) r indicates that the State gen effect must be present in the list of effects
r. We need to find an interpreter for State gen, but let us leave that for later.
Let us go to the next effect we have - KVStore.
Interpreting KVStore
Let us use an SQLite database to store the password hashes. We will assume we have a table
named passwords with two columns username and hash to store the data. Interpreting a KVStore with this table is
easy:
runKVStoreAsSQLite :: Member (Embed IO) r
=> Sem (KVStore Username PasswordHash : r) a
-> Sem (Input Connection : r) a
runKVStoreAsSQLite = reinterpret $ \case
LookupKV username -> do
conn <- input
hashes <- embed (queryNamed conn
"SELECT hash FROM passwords WHERE username = :username"
[":username" := username])
return (fromOnly <$> listToMaybe hashes)
UpdateKV username maybeHash -> do
let (query, params) =
case maybeHash of
Just hash -> ( "INSERT INTO passwords (username, hash) " <>
"VALUES (:username, :hash) " <>
"ON CONFLICT (username) DO UPDATE SET hash = excluded.hash"
, [":username" := username, ":hash" := hash] )
Nothing -> ( "DELETE FROM passwords WHERE username = :username"
, [":username" := username] )
conn <- input
embed (executeNamed conn query params)
The structure of this handler is similar to the previous one for CryptoHash. But there are some important differences.
First, we make use of the
sqlite-simple package for
the DB operations. Actions provided by this library run in the IO monad. We have to somehow incorporate that into our
interpretation. Polysemy allows embedding arbitrary monadic actions into the Sem monad via the Embed constraint. The
Member (Embed IO) r constraint indicates that we have embedded IO monadic actions in the Sem monad. In the code,
we can use the embed function to “lift” an IO operation into the Sem monad. This is analogous to liftIO in monad
transformers.
Second, we need a database connection to execute our operations. In the case of CryptoHash, a State effect was used
because of the need to get and update the DRG. In this case, the DB connection is just an input. Hence, we make use of
Input effect instead. We use
the input function to get a
connection and use it for DB operations.
Third, we are using the function reinterpret here instead of interpret. There is a subtle but important difference -
interpret handles and eliminates an effect, while reinterpret merely translates one effect to another. The
runCryptoHashAsState handler converted a Sem (CryptoHash : r) a value into a Sem r a value; the CryptoHash
effect was eliminated. But runKVStoreAsSQLite handler converts a Sem (KVStore Username PasswordHash : r) value into
a Sem (Input Connection : r) a value. The KVStore effect just got reencoded as an Input effect.
But runCryptoHashAsState had a dependency on State effect and that was expressed as a type constraint. How is that
different from reinterpret reencoding the KVStore effect?
There are two differences. The Member (State gen) r constraint merely says State gen is one of the effects in
r. It can be located anywhere in the list r which means that this effect can be handled in an arbitrary order. The
handlers for CryptoHash and State gen are only loosely related to each other. But when reinterpret reencodes an
effect into another one, the new effect is added at the head of the effect list and has to be handled next. Typically,
the handlers for KVStore Username PasswordHash and Input Connection will get invoked in that order. This shows that
they are logically related; both these effects together represent the storage system for the data.
It is also crucial to note that there is a one-to-one correspondence between the two effects in reinterpret. Every
time a KVStore effect is handled by runKVStoreAsSQLite handler, a new Input Connection effect is added to the
list of effects. If our program had two separate key-value stores, it will need two separate connection inputs as
well. This makes sense because we don’t want the data in those two stores mixed up. But there is no such one-to-one
correspondence in the Member constraint introduced by runCryptoHashAsState. If we had two CryptoHash handlers, one
using bcrypt and another using scrypt, the same DRG can be shared for both. So only one handler for State gen is
necessary for any number of CryptoHash handlers.
Final Steps
With all this, we are ready for a complete implementation of our operations. Here is how to implement addUser:
runAddUser :: Username -> Password -> IO ()
runAddUser username password = do
drg <- getSystemDRG
withConnection dbFile $ \conn ->
{-
Handle effects one by one. The comments on each line indicates
the list of effects yet to be handled at that point.
-}
addUser username password -- [CryptoHash, KVStore Username Password]
& runCryptoHashAsState -- [KVStore Username Password, State gen]
& runKVStoreAsSQLite -- [Input Connection, State gen, Embed IO]
& runInputConst conn -- [State gen, Embed IO]
& evalState drg -- [Embed IO]
& runM
We get a DRG and a DB connection through the IO monad. Then we start handling the effects in addUser username
password. Notice how some effect handlers (such as runCryptoHashAsState) adds new effects to the “pending”
list. Eventually, we are left with a Sem [Embed IO] a value. The runM function can convert that to an IO a value.
Some of the handlers are defined in polysemy library itself - you can find the definitions of runInputConst and
evalState here and
here respectively.
The validatePassword implementation is along similar lines and is left as an exercise for you.
Testing Effects
Polysemy also helps us in writing tests for our effects. We can provide alternate effect handlers in the test code and test our effects for correctness and coverage.
below is such an implementation. Note that this code is pure and does not require the IO monad.
runAddUser :: DRG gen
=> gen
-> Map Username PasswordHash
-> Username
-> Password
-> Map Username PasswordHash
runAddUser drg m username password =
addUser username password -- [CryptoHash, KVStore Username Password]
& runCryptoHashAsState -- [KVStore Username Password, State gen]
& runKVStorePurely m -- [State gen]
& evalState drg -- []
& run
& fst
This function takes a map of user names and hashes, performs the add operation, and returns the resultant map. A test can validate that the output map contains expected entries.
For a more interesting example, see https://gitlab.com/rkaippully/polysemy-password-manager/blob/part2/test/Spec.hs
Summary
That was a whirlwind tour of implementing effect handlers. Obviously, there is a lot more functionality in polysemy. The library documentation is quite detailed and informative. Go check it out!