Search code examples
haskellservant

How to properly reduce servant API path (:>) combinator trees for GET request via browser?


Haskell Servant docs provide several examples for writing an API to serve some content with a type level DSL like this

type API = "position" :> Capture "x" Int :> Capture "y" Int :> Get '[JSON] Position

What I want to do is write a similar API that can take several inputs as a GET request that also works from a browser. (Following code is abridged and simplified).

type QuestionAPI = "question"
                 :> QueryParam "question1" Question
                 :> QueryParam "question2" Question
                 :> QueryParam "question3" Question
                 ...
                 ...
                 :> QueryParam "questionn" Question
                 :> Get '[JSON] [Answer]

This works just fine but the function that consumes this endpoint takes in n number of arguments

processQuestionAPI :: Maybe Question -> Maybe Question -> ... -> Handler [Answer]        
processQuestionAPI param1 param2 param3 ... paramN = ...

which makes everything more difficult to read and reason with.

The first fix I could think of was to use a record!

data LotsOfQuestions = LotsOfQuestions
                { question1 :: Maybe Question
                , question2 :: Maybe Question
                , question3 :: Maybe Question
                ...
                ...
                , questionn :: Maybe Question
                }

and rewrite the endpoint like this

type QuestionAPI = "question"
                 :> ReqBody '[FromUrlEncoded] LotsOfQuestions
                 :> Get '[JSON] [Answer]

While compiling this code GHC threw this error

• No instance for (Web.Internal.FormUrlEncoded.FromForm
                     LotsOfQuestion)
    arising from a use of ‘serve’

So I did just that wrote a custom FromForm instance for LotsOfQuestions.

instance FromForm LotsOfQuestion where
    fromForm aForm = LotsOfQuestions 
                   <$> parseMaybe "q1" aForm
                   <*> parseMaybe "q2" aForm 
                   ...
                   <*> parseMaybe "qN" aForm 

Everything compiled and the server was up and running but I couldn't connect to it using my browser.

The URL I used was this

localhost:8081/questions?q1=what&q2=where&q3=when

The odd thing was that cURL actually worked!

This

curl -d "q1=what&q2=where&q3=when" -X GET "localhost:8081/questions"

produces exactly what I want.

Looking around I found this this issue, which led me to believe that sending Request Body with a GET request isn't the recommended way of doing things.

So I have to replace ReqBody with something equivalent for GET request, but I'm not sure what that could be.


Solution

  • This is more of a progress report than a final answer.

    This main problem was endpoints like these

    type QuestionAPI = "question"
                     :> QueryParam "question1" Question
                     :> QueryParam "question2" Question
                     :> QueryParam "question3" Question
                     ...
                     ...
                     :> QueryParam "questionn" Question
                     :> Get '[JSON] [Answer]
    

    do work but the functions that consumes them often aren't as easy to work with, for example

    processQuestionAPI :: Maybe Question -> Maybe Question -> ... -> Handler [Answer]        
    processQuestionAPI param1 param2 param3 ... paramN = ...
    

    My solution was to use record syntax

    data LotsOfQuestions = LotsOfQuestions
                    { question1 :: Maybe Question
                    , question2 :: Maybe Question
                    , question3 :: Maybe Question
                    ...
                    ...
                    , questionn :: Maybe Question
                    }
    

    but I didn't know how to map that record to servant DSL.

    Mark's comment gave me some insight.

    What I needed to do was implement FromHttpApiData class, specifically parseQueryParam.

    Because some of those questions were optional, the implementation was somewhat roundabout.

    instance FromHttpApiData LotsOfQuestions where
      parseQueryParam = parseQuestions
    
    tailMaybe :: [a] -> Maybe [a]
    tailMaybe []  = Nothing
    tailMaybe str = Just $ tail str
    
    splitOnEqual :: String -> Maybe (String, Maybe String)
    splitOnEqual xs = second tailMaybe . flip splitAt xs <$> elemIndex '=' xs
    
    parseQuestions :: Text -> Either Text LotsOfQuestions
    parseQuestions txt =
      LotsOfQuestions
        <$> sequence (fmap fromOrder =<< lookup "q1" txtMap)
        <*> sequence (fmap fromOrder =<< lookup "q2" txtMap)
        <*> sequence (fmap fromOrder =<< lookup "q3" txtMap)
        ...
        ...
        <*> sequence (fmap fromOrder =<< lookup "qN" txtMap)
    
      where txtMap = mapMaybe splitOnEqual (splitOn "&" $ unpack txt)
    

    here fromOrder is an internal function with type Text -> Either Text Question and splitOn comes from Data.List.Split.

    These are the changes I made to QuestionAPI

    type QuestionAPI = "questions"
               :> QueryParam "are" LotsOfQuestions
               :> Get '[ JSON] [Answer]
    

    and the way to interact with that API is via a link like this

    http://localhost:8081/questions?are=q1=what&q2=where&q3=when