haskelloption-typemonoids

Writing a Haskell Options Datatype for Safe Query Strings for Fixed API


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:

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.

Desired Usage

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)

Attempt 1: 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:

The Dream

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)

Question

How would you recommend approaching this problem in Haskell? Thanks!

Edit: Solution based on Daniel Wagner's answer

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

Solution

  • 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.