2
votes

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.

1
It's been a while since I last worked with Servant, but if you want to Capture data in a URL, I think you might need to make LotsOfQuestions a FromHttpApiData instance.Mark Seemann
Thanks for the suggestion! I looked into it and saw that I only had to implement parseQueryParam or parseUrlPiece which was fairly straightforward. Now I can connect to the server via browser but the url it responds to now takes & instead of ? at the start of the query i.e /questions?q1=what&q2=where to /questions&q1=what&q2=where (in both cases; using Capture and QueryParam) which to me seems like an undefined behavior.atis
Capture is for request path, not query parameters, as you found out :) So you need to stick with QueryParam. That being said, have you read this part of the doc? docs.servant.dev/en/stable/tutorial/… It talks about the FromHttpApiData typeclass which is used to translate QueryParam types, maybe it would help (I am unsure).Sir4ur0n
From looking at the docs for FromHttpApiData, as far as I understand, it only requires one function like this parseQuestions :: Text -> Either Text LotsOfQuestions which should use parseQueryParam and the one I implemented works well. The problem is that it only works with this type of URLs /questions& and not /question? even though parseQuestions works fine on both of those. Perhaps it has something to do with how QueryParam works and which part of the URL is fed to it but I'm not sure.atis

1 Answers

0
votes

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