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.