Closed endgame closed 3 years ago
One option that already exists (but isn't made obvious anywhere) is to use Alternative
to create a sum type of all of your events, then map to the appropriate handler.
Here's a snippet from a project where I did this (I've left the comment, because it's somewhat relevant to the convo!)
data HandlerEvent
= HttpEvent (ApiGatewayProxyRequest UserId)
| CronEvent CloudWatchEvent
instance FromJSON HandlerEvent where
parseJSON v =
(HttpEvent <$> parseJSON v) <|> (CronEvent <$> parseJSON v)
data HandlerResponse
= HttpResponse ApiGatewayProxyResponse
| CronResponse ()
instance ToJSON HandlerResponse where
toJSON (HttpResponse x) = toJSON x
toJSON (CronResponse x) = toJSON x
--TODO: This whole strategy might be more trouble than it's worth
--and they should just be two entirely distinct lambdas.
--They should _at least_ be separated into separate modules.
handler :: MonadAWS m => Text -> HandlerEvent -> m HandlerResponse
handler tableName event =
case event of
HttpEvent e -> HttpResponse <$> httpHandler tableName e
CronEvent e -> CronResponse <$> cronHandler tableName e
It's a bit boilerplate-y and there's no examples or recommendations towards a pattern like this, but I do think it's a pretty reasonable approach. Not sure how to improve upon this or make it "first class" rather than some random trick.
And while the _HANDLER
is very natural to configure (it's always sort of odd adding in "NOT_USED" and such), one can always inject a custom environment variable that switches implementations in this way. Hell, there's no way to prevent access to _HANDLER
so there's nothing stopping a user from accessing it.
It's an interesting idea though. I'm still not seeing the use case jump out at me, but I'd certainly be interested to pull the thread a bit more.
I think it's probably going to remain a niche tool in the Haskell Lambda toolbox, but I think it could be handy in niche cases. Maybe for smaller projects where you just starting to split things out, or when you're refactoring code and don't want to commit to standing up new executable
targets.
How about this? I feel like it's simple enough match hal
's design philosophy, and means that hal
can cover the _HANDLER
Lambda feature without warping everything else around it:
import Control.Monad.IO.Class
import Data.Map (Map)
import qualified Data.Map as M
import Data.Text (Text)
import System.Environment (getEnv)
import qualified Data.Text as T
dispatchHandler :: MonadIO m => Map Text (m ()) -> m ()
dispatchHandler handlers = do
handler <- fmap T.pack . liftIO $ getEnv "_HANDLER"
case M.lookup handler handlers of
Nothing -> error $ "No handler defined for " ++ show handler
Just h -> h
If you really wanted to force the usual FileName.functionName
convention, you could write some TH like:
handlers :: [Name] -> ExpQ
handlers = _
main = $(handlers ['Foo.bar, 'baz, 'quux])
But this feels too magical to fit in with the rest of hal
. I like the dispatchHandler
function better.
This does look like something that is pretty small and probably reasonably supportable in the long term. I like that the "_HANDLER"
is hidden from the user. And making it m ()
means that we don't have to worry about which runtime is used for which env var value--mixing and matching isn't an issue. I also agree that the less "magic" approach is better here. I think a magic package that uses hal might make sense to just quick start folks, but definitely, the first is closer to hal's design philosophy.
My outstanding thoughts are:
dispatchHandler
as a name doesn't seem to capture what the function does. Just in general, I think a challenge is getting people to connect the dots between the Lambda configuration and this--my hope is that a clearer name might do it.dependent-map
, as you mention). I wonder if there's some approach based on typeclasses/derive Generic/etc that could eliminate boilerplate safely.Not to say we need to work out all things above, just stuff I figure is worth discussing a bit more :)
And making it
m ()
means that we don't have to worry about which runtime is used for which env var value--mixing and matching isn't an issue.
This isn't quite what I meant, and I think it's clearer if I show more of an example sketch:
main = dispatchHandler $ Map.fromList
[ ("Foo", runReaderTLambdaContext (evalStateT (mRuntimeWithContext myHandler) 0)) -- for argument's sake
, ("Bar", liftIO $ pureRuntime someOtherHandler)
]
There's not really anywhere to stick a forall
into the type signature for dispatchHandler
because we need to return something that unifies with IO ()
(so it can be main
).
Yeah, sorry, that example is what I expected, I just said it poorly :) Before the previous comment, I was thinking about having the runners on the result of dispatchHandler
, which would be problematic.
Given that we'd expect the user to resolve constarints before passing to the dispatchHandler
, is there a benefit to use the MonadIO
constraint vs expecting IO
itself?
I suppose a constraint does allow you to resolve constraints that are common to all handlers at the end.
is there a benefit to use the
MonadIO
constraint vs expectingIO
itself?
Yes. If you use mRuntimeWithContext
, you aren't forced down into IO
if you want to dispatch on _HANDLER
. If we provide dispatchHandler :: Map Text (IO ()) -> IO ()
, there's an IO ()
in negative position (in the argument) and you can't recover the more general form without something like unliftio
.
I think the best thing here is for programmers to inspect _HANDLER
themselves if they want to serve multiple handlers from one deployment package. As I've said elsewhere, the strength of hal
is its simplicity.
Continuation of #66 . The AWS docs for custom runtimes say that the
_HANDLER
environment variable lets the same piece of code be deployed multiple times to handle different requests.In larger applications, I think that this can be useful to limit the proliferation of
executable
targets in cabal files for larger lambda applications. One way to do this could be to provide aHandler
GADT that can wrap up functions matching the types of those inAWS.Lambda.Runtime
, and a functionrunHandlers :: [(Text, Handler)] -> IO ()
or something.@IamfromSpace :
If you can distinguish the events without needing to branch on
_HANDLER
then you're right that you'll share your execution environments and cut down on cold starts. Example: once #52 lands, it should be possible to build a little router package on top of it for API Gateway use.But suppose you have a larger application made out of a bunch of Lambda functions responding to different events: some listening to API Gateway events, some listening to SNS topics, whatever. With the current setup, it's easy to fall into a setup where you feel like you "need" to have a separate executable for each lambda. This then slows you down because you're going to be building and uploading a bunch of different executables.
I think there's a way you can do this within the existing combinators, including branching off
_HANDLER
. It might needdependent-map
to be completely safe. Supporting_HANDLER
might be a good fit here, but going to the Nth degree of type-safety might be above the complexity appetite for this package. I'll probably need to experiment and think some more before I have something I really want to push ahead with.