haskellsdkhttp-conduit

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!


Solution

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