Bloodhound redesign progress

  • 23rd Jul 2023
  • 2 min read
  • Tags: 
  • haskell

In my previous company we used to work with ElasticSearch, after some times I became the maintainer of Bloodhound.

While I planned to increase type-safety and so on and so forth, I did not work on it.

I only added a phantom type and a proper BHResponse as a standard return type (instead of an "untyped" http-client Response).

Which gives functions like that:

flushIndex :: MonadBH m => IndexName -> m (BHResponse ShardResult)
deleteIndex :: MonadBH m => IndexName -> m (BHResponse Acknowledged)
getDocument :: (FromJSON a, MonadBH m) => IndexName -> DocId -> m (BHResponse (EsResult a))

Few days ago we had a bug report stating that one of getDocument wasn't working when a document isn't found (aka, it throws a 404).

The thing is, EsResult deals with not-found from the payload only:

data EsResult a = EsResult
  { _index :: Text,
    _type :: Text,
    _id :: Text,
    foundResult :: Maybe (EsResultFound a)
  }
  deriving (Eq, Show)

instance (FromJSON a) => FromJSON (EsResult a) where
  parseJSON jsonVal@(Object v) = do
    found <- v .:? "found" .!= False
    fr <-
      if found
        then parseJSON jsonVal
        else return Nothing
    EsResult
      <$> v .: "_index"
      <*> v .: "_type"
      <*> v .: "_id"
      <*> pure fr
  parseJSON _ = empty

The issue comes from the body parsing, there are two ways to parse a BHResponse body: one which takes care of the status code, and the other which does not.

parseEsResponse ::
  ( MonadThrow m,
    FromJSON body
  ) =>
  BHResponse body ->
  m (ParsedEsResponse body)
parseEsResponse response
  | isSuccess response = case eitherDecode body of
      Right a -> return (Right a)
      Left err ->
        tryParseError err
  | otherwise = tryParseError "Non-200 status code"
  where
    body = responseBody $ getResponse response
    tryParseError originalError =
      case eitherDecode body of
        Right e -> return (Left e)
        -- Failed to parse the error message.
        Left err -> explode ("Original error was: " <> originalError <> " Error parse failure was: " <> err)
    explode errorMsg = throwM $ EsProtocolException (T.pack errorMsg) body

eitherDecodeResponse ::
  FromJSON a =>
  BHResponse a ->
  Either String a
eitherDecodeResponse = eitherDecode . responseBody . getResponse

To structurally solve this issue, a first step is to add proper types:

-- | 'Request' upon Elasticsearch's server.
--
-- @contextualized@ is a phantom type for the expected status-dependancy
-- @responseBody@ is a phantom type for the expected result
data BHRequest contextualized responseBody = BHRequest
  { bhRequestMethod :: NHTM.Method,
    bhRequestEndpoint :: Endpoint,
    bhRequestBody :: Maybe BL.ByteString
  }
  deriving stock (Eq, Show)

The second step is to merge Request sending and body parsing, simplifying a lot the API:

flushIndex :: MonadBH m => IndexName -> m ShardResult
deleteIndex :: MonadBH m => IndexName -> m Acknowledged
getDocument :: (FromJSON a, MonadBH m) => IndexName -> DocId -> m (EsResult a)

The thing with this design is:

  • It changes a lot the consumer code
  • It hides a BHResponse, which can be useful

Currently, I'm heavily working of having a stable version of this concept, but my next step would be to expose BHRequest:

flushIndex :: IndexName -> BHRequest ContextDependent ShardResult
deleteIndex :: IndexName -> BHRequest ContextDependent Acknowledged
getDocument :: FromJSON a => IndexName -> DocId -> BHRequest ContextIndependent (EsResult a)

Which will change consumer as follows:

perform request
perform $ withResponse request