1

I am writing a Haskell SDK, I have everything working however I'm wanting to introduce stronger types to my search filters (url parameters).

A sample call looks like:

-- list first 3 positive comments mentioned by females
comments "tide-pods" [("limit", "3"),("sentiment", "positive"),("gender", "female")] config

While this isn't too horrible for me, I would really like to be able to pass in something like:

comments "tide-pods" [("limit", "3"),(Sentiment, Positive),(Gender, Male)] config

Or something similar.

In DataRank.hs you can see my url parameter type type QueryParameter = (String, String), as well as the code to convert the arguments for http-conduit convertParameters :: [QueryParameter] -> [(ByteString, Maybe ByteString)]

I have been experimenting with data/types, for example:

data Gender = Male | Female | Any 
-- desired values of above data types
-- Male = "male"
-- Female = "female"
-- Any = "male,female"

The api also needs to remain flexible enough for any arbitrary String key, String values because I would like the SDK to keep the ability to supply new filters without depending on a SDK update. For the curious, A list of the search filters to-date are in a recently built Java SDK

I was having problems finding a good way to provide the search interface in Haskell. Thanks in advance!

Kenny Cason
  • 12,109
  • 11
  • 47
  • 72
  • `data FilterKey = Sentiment SentimentValue | Gender GenderValue | Arbitrary String String`, then you could do `comments "tide-pods" [Arbitrary "limit" "3", Sentiment Positive, Gender Male] config` – bheklilr May 01 '15 at 16:17
  • You could have a `comments :: String -> [FilterKey] -> Config -> Result` and `commentsRaw :: String -> [(String, String)] -> Config -> Result`, where `comments` calls `commentsRaw`, and just write a function `filterKeyToPair :: FilterKey -> (String, String)`. That would be pretty straightforward. – bheklilr May 01 '15 at 16:20
  • Thanks! Now, when I'm iterating over my list of parameters to pass to http-conduit, how do I get the desired values (notably the ANY = "male,female"? Do I just do a guard and return the correct String? – Kenny Cason May 01 '15 at 16:22
  • 1
    You could do something like [this](https://gist.github.com/bheklilr/f3ea4a4b19fb2bf005ea) if you wanted to keep it simple for now. – bheklilr May 01 '15 at 16:40
  • 1
    I solved this problem by creating a data type for each endpoint's options. Then I used lenses to cleanly set particular options. Check out my library for examples: https://github.com/tfausak/strive#update-current-athlete. – Taylor Fausak May 01 '15 at 16:41
  • Thanks for the links guys! Will check them out and see what I can come up with. – Kenny Cason May 01 '15 at 16:54
  • @KennyCason Check out [this](https://gist.github.com/bheklilr/6ab4074cadc86ddf188b) for a more complicated API that is arguably nicer. It allows users of your API to extend it pretty easily with their own custom types, so you could even split this into a base API then packages that implement the filters themselves, or at least split it into different modules. – bheklilr May 01 '15 at 17:10
  • The examples have helped tremendously! @bheklilr You should take the samples from those links and create an answer :) Particularly the first link. – Kenny Cason May 01 '15 at 18:19

1 Answers1

1

The simplest way to keep it simple but unsafe is to just use a basic ADT with an Arbitrary field that takes a String key and value:

data FilterKey
    = Arbitrary String String
    | Sentiment Sentiment
    | Gender Gender
    deriving (Eq, Show)

data Sentiment
    = Positive
    | Negative
    | Neutral
    deriving (Eq, Show, Bounded, Enum)

data Gender
    = Male
    | Female
    | Any
    deriving (Eq, Show, Bounded, Enum)

Then you need a function to convert a FilterKey to your API's base (String, String) filter type

filterKeyToPair :: FilterKey -> (String, String)
filterKeyToPair (Arbitrary key val) = (key, val)
filterKeyToPair (Sentiment sentiment) = ("sentiment", showSentiment sentiment)
filterKeyToPair (Gender gender) = ("gender", showGender gender)

showSentiment :: Sentiment -> String
showSentiment s = case s of
    Positive -> "positive"
    Negative -> "negative"
    Neutral  -> "neutral"

showGender :: Gender -> String
showGender g = case g of
    Male   -> "male"
    Female -> "female"
    Any    -> "male,female"

And finally you can just wrap your base API's comments function so that the filters parameter is more typesafe, and it's converted to the (String, String) form internally to send the request

comments :: String -> [FilterKey] -> Config -> Result
comments name filters conf = do
    let filterPairs = map filterKeyToPair filters
    commentsRaw name filterPairs conf

This will work quite well and is fairly easy to use:

comments "tide-pods" [Arbitrary "limits" "3", Sentiment Positive, Gender Female] config

But it isn't very extensible. If a user of your library wants to extend it to add a Limit Int field, they would have to write it as

data Limit = Limit Int

limitToFilterKey :: Limit -> FilterKey
limitToFilterKey (Limit l) = Arbitrary "limit" (show l)

And it would instead look like

[limitToFilterKey (Limit 3), Sentiment Positive, Gender Female]

which isn't particularly nice, especially if they're trying to add a lot of different fields and types. A complex but extensible solution would be to have a single Filter type, and actually for simplicity have it capable of representing a single filter or a list of filters (try implementing it where Filter = Filter [(String, String)], it's a bit harder to do cleanly):

import Data.Monoid hiding (Any)

-- Set up the filter part of the API

data Filter
    = Filter (String, String)
    | Filters [(String, String)]
    deriving (Eq, Show)

instance Monoid Filter where
    mempty = Filters []
    (Filter   f) `mappend` (Filter  g)  = Filters [f, g]
    (Filter   f) `mappend` (Filters gs) = Filters (f : gs)
    (Filters fs) `mappend` (Filter  g)  = Filters (fs ++ [g])
    (Filters fs) `mappend` (Filters gs) = Filters (fs ++ gs)

Then have a class to represent the conversion to a Filter (much like Data.Aeson.ToJSON):

class FilterKey kv where
    keyToString :: kv -> String
    valToString :: kv -> String
    toFilter :: kv -> Filter
    toFilter kv = Filter (keyToString kv, valToString kv)

The instance for Filter is quite simple

instance FilterKey Filter where
    -- Unsafe because it doesn't match the Fitlers contructor
    -- but I never said this was a fully fleshed out API
    keyToString (Filter (k, _)) = k
    valToString (Filter (_, v)) = v
    toFilter = id

A quick trick you can do here to easily combine values of this type is

-- Same fixity as <>
infixr 6 &
(&) :: (FilterKey kv1, FilterKey kv2) => kv1 -> kv2 -> Filter
kv1 & kv2 = toFilter kv1 <> toFilter kv2

Then you can write instances of the FilterKey class that work with:

data Arbitrary = Arbitrary String String deriving (Eq, Show)

infixr 7 .=
(.=) :: String -> String -> Arbitrary
(.=) = Arbitrary

instance FilterKey Arbitrary where
    keyToString (Arbitrary k _) = k
    valToString (Arbitrary _ v) = v

data Sentiment
    = Positive
    | Negative
    | Neutral
    deriving (Eq, Show, Bounded, Enum)

instance FilterKey Sentiment where
    keyToString _        = "sentiment"
    valToString Positive = "positive"
    valToString Negative = "negative"
    valToString Neutral  = "neutral"

data Gender
    = Male
    | Female
    | Any
    deriving (Eq, Show, Bounded, Enum)

instance FilterKey Gender where
    keyToString _      = "gender"
    valToString Male   = "male"
    valToString Female = "female"
    valToString Any    = "male,female"

Add a bit of sugar:

data Is = Is

is :: Is
is = Is

sentiment :: Is -> Sentiment -> Sentiment
sentiment _ = id

gender :: Is -> Gender -> Gender
gender _ = id

And you can write queries like

example
    = comments "tide-pods" config
    $ "limit" .= "3"
    & sentiment is Positive
    & gender is Any

This API can still be safe if you don't export the constructors to Filter and if you don't export toFilter. I left that as a method on the typeclass simply so that Filter can override it with id for efficiency. Then a user of your library simply does

data Limit
    = Limit Int
    deriving (Eq, Show)

instance FilterKey Limit where
    keyToString _ = "limit"
    valToString (Limit l) = show l

If they wanted to keep the is style they could use

limit :: Is -> Int -> Limit
limit _ = Limit

And write something like

example
    = comments "foo" config
    $ limit is 3
    & sentiment is Positive
    & gender is Female

But that is shown here simply as an example of one way you can make EDSLs in Haskell look very readable.

bheklilr
  • 53,530
  • 6
  • 107
  • 163