zohl / servant-auth-cookie

Authentication via encrypted cookies
BSD 3-Clause "New" or "Revised" License
23 stars 23 forks source link

How do I elegantly handle routes with multiple parameters? #34

Closed tmbull closed 7 years ago

tmbull commented 7 years ago

Hi, first of all, I apologize if this is a general Haskell question but I'm currently trying to use servant-auth-cookie to add authentication to an API with several endpoints. The issue is that most of my endpoints have several query parameters and/or captures. I struggled with this for a while, and eventually ended up rolling my own version for handlers of arbitrary arity:

type SimpleApi = "simple"
                   :> AuthProtect "cookie-auth"
                   :> Get '[JSON] (Cookied String)
                 :<|> "one-param"
                   :> QueryParam "q" String
                   :> AuthProtect "cookie-auth"
                   :> Get '[JSON] (Cookied String)
                 :<|> "two-param"
                   :> QueryParam "q1" String
                   :> QueryParam "q2" String
                   :> AuthProtect "cookie-auth"
                   :> Get '[JSON] (Cookied String)

simple ::
       Session
    -> String
simple session = "foobar"

simple1 ::
     Maybe String
  -> Session
  -> String
simple1 q session = show q

simple2 ::
     Maybe String
  -> Maybe String
  -> Session
  -> String
simple2 q1 q2 session = show q1 ++ show q2

server :: (ServerKeySet s)
  => AuthCookieSettings
  -> RandomSource
  -> s
  -> Server SimpleApi
server settings rs sks = simple' :<|> simple1' :<|> simple2'
  where
    simple' = cookied settings rs sks simple
    simple1' = cookied1 settings rs sks simple1
    simple2' = cookied2 settings rs sks simple2

It seems like ought to be an easier solution. Am I doing this wrong? Thanks!

zohl commented 7 years ago

Hello,

no need to apologize, there is indeed design flaw in cookied function. I didn't think of using it in tandem with query parameters and therefore it's hard to use it this way.

As a temporary workaround you can use cookies with lambdas like that:

handler  q1 q2 ... qN session = ...
handler' q1 q2 ... qN         = cookied (handler q1 q2)  -- here session variable can be omitted

-- or when session argument goes first
handler  session q1 q2 ... qN = ...
handler' session q1 q2 ... qN = cookied (\s -> handler s q1 q2 ... qN) session

Looks ugly, but should work. I'm thinking how to simplify it.

The problem is that cookies should transform function of type t1 -> t2 -> ... -> tN -> Session -> r to t1 -> t2 -> ... -> tN -> WithMetadata Session -> Handler (Cookied r), i.e. wrap the last argument and lift result to Handler monad. Dealing with "the last argument" when we don't know exact number of arguments is quite tricky. Even if we rewrite the code, so that the session is the first argument, we will have to drag Handler monad through applications of each argument, which results in more complicated and verbose code.

As a side note, there is one more problem: cookied takes pure function (in a sesnse that it's result is not in Handler monad). This means you cannot throw errors inside it. Unlike the first problem, it's easier to fix.

I have few ideas, I would like try before resorting to template haskell, so it might take the time :)

zohl commented 7 years ago

Done, it is possible without TH. Here is an example of new cookied function:

type TestApi
  =    "test-0-0"
    :> AuthProtect "cookie-auth"
    :> Get '[JSON] (Cookied String)
  :<|> "test-1-0"
    :> AuthProtect "cookie-auth"
    :> QueryParam "q1" String
    :> Get '[JSON] (Cookied String)
  :<|> "test-1-1"
    :> QueryParam "q1" String
    :> AuthProtect "cookie-auth"
    :> Get '[JSON] (Cookied String)
  :<|> "test-2-0"
    :> AuthProtect "cookie-auth"
    :> QueryParam "q1" String
    :> QueryParam "q2" Int
    :> Get '[JSON] (Cookied String)
  :<|> "test-2-1"
    :> QueryParam "q1" String
    :> AuthProtect "cookie-auth"
    :> QueryParam "q2" Int
    :> Get '[JSON] (Cookied String)
  :<|> "test-2-2"
    :> QueryParam "q1" String
    :> QueryParam "q2" Int
    :> AuthProtect "cookie-auth"
    :> Get '[JSON] (Cookied String)
  :<|> "test-3-0"
    :> AuthProtect "cookie-auth"
    :> QueryParam "q1" String
    :> QueryParam "q2" Int
    :> QueryParam "q3" Bool
    :> Get '[JSON] (Cookied String)
  :<|> "test-3-1"
    :> QueryParam "q1" String
    :> AuthProtect "cookie-auth"
    :> QueryParam "q2" Int
    :> QueryParam "q3" Bool
    :> Get '[JSON] (Cookied String)
  :<|> "test-3-2"
    :> QueryParam "q1" String
    :> QueryParam "q2" Int
    :> AuthProtect "cookie-auth"
    :> QueryParam "q3" Bool
    :> Get '[JSON] (Cookied String)
  :<|> "test-3-3"
    :> QueryParam "q1" String
    :> QueryParam "q2" Int
    :> QueryParam "q3" Bool
    :> AuthProtect "cookie-auth"
    :> Get '[JSON] (Cookied String)

test0 :: Session -> Handler String
test0 session = return . concat $ (["test-0-0::", show session] :: [String])

test10 :: Session -> Maybe String -> Handler String
test10 session q1 = return . concat $ (["test-1-0::", show q1, "::", show session] :: [String])

test11 :: Maybe String -> Session -> Handler String
test11 q1 session = return . concat $ (["test-1-1::", show q1, "::", show session] :: [String])

test20 :: Session -> Maybe String -> Maybe Int -> Handler String
test20 session q1 q2 = return . concat $ (["test-2-0::", show q1, "::", show q2, "::", show session] :: [String])

test21 :: Maybe String -> Session -> Maybe Int -> Handler String
test21 q1 session q2 = return . concat $ (["test-2-1::", show q1, "::", show q2, "::", show session] :: [String])

test22 :: Maybe String -> Maybe Int -> Session -> Handler String
test22 q1 q2 session = return . concat $ (["test-2-2::", show q1, "::", show q2, "::", show session] :: [String])

test30 :: Session -> Maybe String -> Maybe Int -> Maybe Bool -> Handler String
test30 session q1 q2 q3 = return . concat $ (["test-3-0::", show q1, "::", show q2, "::", show q3, "::", show session] :: [String])

test31 :: Maybe String -> Session -> Maybe Int -> Maybe Bool -> Handler String
test31 q1 session q2 q3 = return . concat $ (["test-3-1::", show q1, "::", show q2, "::", show q3, "::", show session] :: [String])

test32 :: Maybe String -> Maybe Int -> Session -> Maybe Bool -> Handler String
test32 q1 q2 session q3 = return . concat $ (["test-3-2::", show q1, "::", show q2, "::", show q3, "::", show session] :: [String])

test33 :: Maybe String -> Maybe Int -> Maybe Bool -> Session -> Handler String
test33 q1 q2 q3 session = return . concat $ (["test-3-3::", show q1, "::", show q2, "::", show q3, "::", show session] :: [String])

serveTest
  =    cookied' test0
  :<|> cookied' test10
  :<|> cookied' test11
  :<|> cookied' test20
  :<|> cookied' test21
  :<|> cookied' test22
  :<|> cookied' test30
  :<|> cookied' test31
  :<|> cookied' test32
  :<|> cookied' test33
  where
    cookied' :: CookiedWrapper Session
    cookied' = cookied settings rs sks (Proxy :: Proxy Session)

So, to use it you should do the following: 1) check that your handlers return value in Handler monad (in the example it's simply return). 2) enable FlexibleContexts extension

The price of such wrapper is obscure compiler error messages when something goes wrong. In case of ambiguity, make sure you provided signature for every function you pass to cookied.

tmbull commented 7 years ago

Awesome! Thanks for the quick update. I'll give it a try and let you know how it goes.

tmbull commented 7 years ago

@zohl wanted to let you know that I tried the new cookied in our project and it works great, so I'll go ahead and close this. Thanks again for the quick response.

zohl commented 7 years ago

Alright, thank you for letting me know!