Kenny Cason
Kenny Cason

Reputation: 12328

Haskell data, custom string values

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!

Upvotes: 1

Views: 336

Answers (1)

bheklilr
bheklilr

Reputation: 54058

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.

Upvotes: 1

Related Questions