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