I'm practicing "real-world" Haskell by writing an application that makes web requests to a music catalog. I can call an endpoint like https://example.com/search
with any combination of optional parameters like title
, artist
, year
. For example, any of the following combinations are valid:
https://example.com/search?title="Ecoute moi Camarade"
https://example.com/search?title="Ecoute moi Camarade"&artist="Mazouni"
https://example.com/search?year=1974&artist="Mazouni"
I can use req
to build lists of query parameters in a friendly way,
import qualified Network.HTTP.Req as Req
import qualified Data.Aeson as AE
makeSearch :: IO ()
makeSearch = Req.runReq Req.defaultHttpConfig $ do
let url = https "example.com" /: "search"
let params =
"artist" =: ("Ecoute moi Camarade" :: Text) <>
"track" =: ("Mazouni" :: Text)
r <- (req GET url NoReqBody jsonResponse params) :: (Req.Req (Req.JsonResponse AE.Value))
liftIO $ print (Req.responseBody r :: AE.Value)
I want the makeSearch
function to accept arbitrary combinations of the optional parameters. The two easiest options are:
Define a separate function for every combination of optional parameters. This is too much duplication, and too much work when there are many options.
Have the caller pass in a manually-constructed params
value like I defined above, but this isn't very type-safe.
Instead, I'd like to define some Haskell data types to model what I know about the API I am consuming. Note that I do NOT have control over the web API itself.
I think the following simple criteria are reasonable:
For example, something like the following would be nice for the caller:
makeSearch (searchArtist "Mazouni" <> searchTitle "Ecoute moi Camarade")
makeSearch (searchYear 1974)
Monoid
and Last
I tried to implement a pattern I've seen before using Monoid
,
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
import GHC.Generics ( Generic )
import Data.Monoid.Generic
data SearchOpts = SearchOpts {
searchArtist :: Last Text,
searchTitle :: Last Text,
searchYear :: Last Integer
} deriving (Generic, Show, Eq)
deriving Semigroup via GenericSemigroup SearchOpts
deriving Monoid via GenericMonoid SearchOpts
However, if we want to search only by title, we still need to provide Nothing
for the remaining options. I can define some helper functions like below, but it would be better if they were somehow generated automatically.
matchArtist :: Text -> SearchOpts
matchArtist name = mempty { searchArtist = Last (Just name) }
matchTitle :: Text -> SearchOpts
matchTitle title = mempty { searchTitle = Last (Just title) }
matchYear :: Text -> SearchOpts
matchYear t = mempty { searchYear = Last (Just t) }
Further, I haven't found a clean way to implement makeSearch
with this approach. The complications are:
sqArtist
and the query parameter keys like "artist"
.req
library combines parameters with <>
on values of type Options 'Https
. I'm not sure how to convert my list of optional values into something that can be used by req
as a query string.Last
, since I have to manually unwrap each field when the value is used.This sort of manipulation is very common TypeScript. Here's a simple example. Using UrlSearchParams
would simplify even further, but that's not quite a fair comparison.
interface SearchOpts {
artist ?: string,
title ?: string,
year ?: number
}
function makeSearch(opts: SearchOpts): string {
var params:string[] = [];
if(opts.artist) { params.push("artist=" + encodeURIComponent(opts.artist)); }
if(opts.title) { params.push("title=" + encodeURIComponent(opts.title)); }
if(opts.year) { params.push("year=" + encodeURIComponent(opts.year)); }
return params.join("&");
}
makeSearch({ title: "T"}) // OK
makeSearch({ title: "T", artist: "A"}) // OK
makeSearch({ year: 1974, artist: "A"}) // OK
makeSearch({ title: "T"}) // OK
makeSearch({ title: "T", extra: "Extra"}) // Error! (as desired)
How would you recommend approaching this problem in Haskell? Thanks!
The following SearchOpts
and makeSearch
implementation isn't too bad. I'll look into lenses and template Haskell as well!
data SearchOpts = SearchOpts {
searchArtist :: Maybe Text,
searchTitle :: Maybe Text,
searchYear :: Maybe Text
} deriving (Eq, Ord, Read, Show)
instance Default SearchOpts where
def = SearchOpts Nothing Nothing Nothing
matchArtist :: Text -> SearchOpts
matchArtist a = def { searchArtist = Just a }
matchTitle :: Text -> SearchOpts
matchTitle t = def { searchTitle = Just t }
matchYear :: Text -> SearchOpts
matchYear y = def { searchYear = Just y }
-- App is a MonadHttp instance
makeSearch :: SearchOpts -> App SearchResults
makeSearch query = do
let url = https "example.com" /: "search"
let args = [
("artist" , searchArtist query),
("title" , searchTitle query),
("type" , searchYear query)
]
let justArgs = [ (key,v) | arg@(key, Just v) <- args ]
let params = (map (uncurry (=:)) justArgs)
let option = (foldl (<>) mempty params)
-- defined elsewhere
makeReq url option
The standard trick is to just use Maybe
(not Last
) and define a Default
instance:
data SearchOpts = SearchOpts
{ searchArtist :: Maybe Text
, searchTitle :: Maybe Text
, searchYear :: Maybe Integer
} deriving (Eq, Ord, Read, Show)
instance Default SearchOpts where
def = SearchOpts Nothing Nothing Nothing
Now it's easy to supply only the fields you want by writing things like this:
def { searchArtist = Just "Mazouni" }
-- or
def
{ searchArtist = Just "Mazouni"
, searchTitle = Just "Ecoute moi Camarade"
}
If you're married to the Monoid
instance (perhaps because it lets callers skip the Just
) you can still give one.
instance Semigroup SearchOpts where
SearchOpts a t y <> SearchArtist a' t' y'
= SearchOpts (a <|> a') (t <|> t') (y <|> y')
instance Monoid SearchOpts where mempty = def
To automatically generate single-field "constructors", you could look into some Template Haskell; it's also possible that makeLenses
or its variants may get you where you need to go.