packages feed

bloodhound-1.0.0.0: src/Database/Bloodhound/Internal/Client/BHRequest.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -Wno-deprecations #-}

-- |
-- Module : Database.Bloodhound.Client
-- Copyright : (C) 2014, 2018 Chris Allen
-- License : BSD-style (see the file LICENSE)
-- Maintainer : Chris Allen <cma@bitemyapp.com>
-- Stability : provisional
-- Portability : GHC
--
-- Client side abstractions to interact with Elasticsearch servers.
module Database.Bloodhound.Internal.Client.BHRequest
  ( -- * Request
    BHRequest (..),
    StatusIndependant,
    StatusDependant,
    mkFullRequest,
    mkSimpleRequest,
    ParsedEsResponse,
    ParseBHResponse (..),
    Server (..),
    Endpoint (..),
    mkEndpoint,
    withQueries,
    getEndpoint,
    withBHResponse,
    withBHResponse_,
    withBHResponseParsedEsResponse,
    keepBHResponse,
    joinBHResponse,

    -- * Response
    BHResponse (..),

    -- * Response interpretation
    decodeResponse,
    eitherDecodeResponse,
    parseEsResponse,
    parseEsResponseWith,
    isVersionConflict,
    isSuccess,
    isCreated,
    statusCodeIs,

    -- * Response handling
    EsProtocolException (..),
    EsResult (..),
    EsResultFound (..),
    esResultFoundSeqNoLens,
    esResultFoundPrimaryTermLens,
    esResultFoundRoutingLens,
    EsError (..),

    -- * Common results
    Acknowledged (..),
    Accepted (..),
    IgnoredBody (..),
  )
where

import Blaze.ByteString.Builder qualified as BB
import Control.Applicative as A
import Control.Monad
import Control.Monad.Catch
import Data.Aeson
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Ix
import Data.Monoid
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import Database.Bloodhound.Internal.Client.Doc
import Database.Bloodhound.Internal.Utils.Imports (Lens', lens)
import GHC.Exts
import Network.HTTP.Client
import Network.HTTP.Types.Method qualified as NHTM
import Network.HTTP.Types.Status qualified as NHTS
import Network.HTTP.Types.URI qualified as NHTU
import Prelude hiding (filter, head)

-- | 'Server' is used with the client functions to point at the ES instance
newtype Server = Server Text
  deriving stock (Eq, Show)
  deriving newtype (FromJSON)

-- | 'Endpoint' represents an url before being built
data Endpoint = Endpoint
  { getRawEndpoint :: [Text],
    getRawEndpointQueries :: [(Text, Maybe Text)]
  }
  deriving stock (Eq, Show)

instance IsList Endpoint where
  type Item Endpoint = Text
  toList = getRawEndpoint
  fromList = mkEndpoint

-- | Create an 'Endpoint' from a list of url parts
mkEndpoint :: [Text] -> Endpoint
mkEndpoint urlParts = Endpoint urlParts mempty

-- | Generate the raw URL
getEndpoint :: Server -> Endpoint -> Text
getEndpoint (Server serverRoot) endpoint =
  T.intercalate "/" (serverRoot : getRawEndpoint endpoint) <> queries
  where
    queries = T.decodeUtf8 $ BB.toByteString $ NHTU.renderQueryText prependQuestionMark $ getRawEndpointQueries endpoint
    prependQuestionMark = True

-- | Severely dumbed down query renderer. Assumes your data doesn't
-- need any encoding
withQueries :: Endpoint -> [(Text, Maybe Text)] -> Endpoint
withQueries endpoint queries = endpoint {getRawEndpointQueries = getRawEndpointQueries endpoint <> queries}

-- | 'Request' upon Elasticsearch's server.
--
-- @parsingContext@ is a phantom type for the expected status-dependancy
-- @responseBody@ is a phantom type for the expected result
data BHRequest parsingContext responseBody = BHRequest
  { bhRequestMethod :: NHTM.Method,
    bhRequestEndpoint :: Endpoint,
    bhRequestBody :: Maybe BL.ByteString,
    bhRequestQueryStrings :: [(BS.ByteString, Maybe BS.ByteString)],
    bhRequestParser :: BHResponse parsingContext responseBody -> Either EsProtocolException (ParsedEsResponse responseBody)
  }

instance Functor (BHRequest parsingContext) where
  fmap f req =
    req
      { bhRequestParser =
          \BHResponse {..} -> fmap (fmap f) $ bhRequestParser req $ BHResponse {..}
      }

-- | 'BHResponse' body-parsing does not depend on 'statusCode'
data StatusIndependant

-- | 'BHResponse' body-parsing may depend on 'statusCode'
data StatusDependant

-- | 'BHRequest' with a body
mkFullRequest :: (ParseBHResponse parsingContext, FromJSON responseBody) => NHTM.Method -> Endpoint -> BL.ByteString -> BHRequest parsingContext responseBody
mkFullRequest method' endpoint body =
  BHRequest
    { bhRequestMethod = method',
      bhRequestEndpoint = endpoint,
      bhRequestBody = Just body,
      bhRequestQueryStrings = [],
      bhRequestParser = parseBHResponse
    }

-- | 'BHRequest' without a body
mkSimpleRequest :: (ParseBHResponse parsingContext, FromJSON responseBody) => NHTM.Method -> Endpoint -> BHRequest parsingContext responseBody
mkSimpleRequest method' endpoint =
  BHRequest
    { bhRequestMethod = method',
      bhRequestEndpoint = endpoint,
      bhRequestBody = Nothing,
      bhRequestQueryStrings = [],
      bhRequestParser = parseBHResponse
    }

class ParseBHResponse parsingContext where
  parseBHResponse ::
    (FromJSON a) =>
    BHResponse parsingContext a ->
    Either EsProtocolException (ParsedEsResponse a)

instance ParseBHResponse StatusDependant where
  parseBHResponse = parseEsResponse

instance ParseBHResponse StatusIndependant where
  parseBHResponse r =
    return $
      case eitherDecodeResponse r of
        Right d -> Right d
        Left e ->
          Left $
            EsError
              { errorStatus = Just $ NHTS.statusCode (responseStatus $ getResponse r),
                errorMessage = "Unable to parse body: " <> T.pack e
              }

-- | Work with the full 'BHResponse'
withBHResponse ::
  forall a parsingContext b.
  (Either EsProtocolException (ParsedEsResponse a) -> BHResponse StatusDependant a -> b) ->
  BHRequest parsingContext a ->
  BHRequest StatusDependant b
withBHResponse f req =
  req
    { bhRequestParser = \resp ->
        liftResponse $ f (bhRequestParser req $ castResponse @_ @_ @parsingContext @a resp) $ castResponse @_ @_ @StatusDependant @a resp
    }
  where
    liftResponse :: b -> Either EsProtocolException (ParsedEsResponse b)
    liftResponse = return . return

-- | Internal only
castResponse :: BHResponse parsingContext0 responseBody0 -> BHResponse parsingContext1 responseBody1
castResponse BHResponse {..} = BHResponse {..}

-- | Work with the full 'BHResponse'
withBHResponse_ ::
  forall a parsingContext b.
  (BHResponse StatusDependant a -> b) ->
  BHRequest parsingContext a ->
  BHRequest StatusDependant b
withBHResponse_ f = withBHResponse $ const f

-- | Enable working with 'ParsedEsResponse'
withBHResponseParsedEsResponse ::
  forall a parsingContext.
  BHRequest parsingContext a ->
  BHRequest StatusDependant (ParsedEsResponse a)
withBHResponseParsedEsResponse req =
  req
    { bhRequestParser = \BHResponse {..} -> return <$> bhRequestParser req BHResponse {..}
    }

-- | Keep with the full 'BHResponse'
keepBHResponse ::
  forall a parsingContext.
  BHRequest parsingContext a ->
  BHRequest StatusDependant (BHResponse StatusDependant a, a)
keepBHResponse = joinBHResponse . withBHResponse (\parsed resp -> fmap (fmap ((,) resp)) parsed)

joinBHResponse ::
  forall a parsingContext.
  BHRequest parsingContext (Either EsProtocolException (ParsedEsResponse a)) ->
  BHRequest parsingContext a
joinBHResponse req =
  req
    { bhRequestParser = \resp ->
        case bhRequestParser req $ castResponse resp of
          Left e -> Left e
          Right (Right a) -> a
          Right (Left e) -> Right (Left e)
    }

-- | Result of a 'BHRequest'
newtype BHResponse parsingContext body = BHResponse
  { getResponse :: Network.HTTP.Client.Response BL.ByteString
  }
  deriving stock (Show)

-- | Result of a 'parseEsResponse'
type ParsedEsResponse a = Either EsError a

-- | Tries to parse a response body as the expected type @body@ and
-- failing that tries to parse it as an EsError. All well-formed, JSON
-- responses from elasticsearch should fall into these two
-- categories. If they don't, a 'EsProtocolException' will be
-- thrown. If you encounter this, please report the full body it
-- reports along with your Elasticsearch version.
--
-- /Empty-body 2xx handling/: some cluster endpoints (verified:
-- @/_cluster/voting_config_exclusions@ POST and DELETE on ES 7.17)
-- return HTTP 200 with a genuinely empty body (Content-Length: 0)
-- rather than the @{\"acknowledged\":true}@ envelope their declared
-- 'Acknowledged' return type implies. To tolerate that wire shape,
-- an empty body on a 2xx response is normalised to the JSON value
-- @null@ before the decoder is invoked. Only types whose 'FromJSON'
-- instance accepts @null@ are affected — in practice just
-- 'Acknowledged', whose instance maps @null@ to 'Acknowledged'
-- @True@ (a 200 OK with no body IS the acknowledgement per the
-- server's documented semantics). All other types keep the
-- historical behaviour: their strict 'FromJSON' instances reject
-- @null@, so the empty body surfaces as an 'EsProtocolException' as
-- before.
--
-- Note: the OpenSearch k-NN\/Neural warmup and clear-cache endpoints
-- were /suspected/ to need this handling but turn out to have
-- separate response-shape bugs (wrong method, wrong response type)
-- tracked by the pending live tests in @Test.KnnWarmupSpec@,
-- @Test.KnnClearCacheSpec@, @Test.NeuralWarmupSpec@, and
-- @Test.NeuralClearCacheSpec@. They do not return empty-body 200 and
-- are not covered by this normalisation.
parseEsResponse ::
  (FromJSON body) =>
  BHResponse parsingContext body ->
  Either EsProtocolException (ParsedEsResponse body)
parseEsResponse response
  | isSuccess response = case eitherDecode normalisedBody of
      Right a -> return (Right a)
      Left err ->
        tryParseError err
  | otherwise = tryParseError "Non-200 status code"
  where
    body = responseBody $ getResponse response
    -- Normalise a genuinely empty body to the literal JSON @null@
    -- before decoding. Only affects types whose 'FromJSON' accepts
    -- @null@ (e.g. 'Acknowledged'); strict types still reject it and
    -- surface the original parse failure below. Error envelopes always
    -- carry content, so 'tryParseError' decodes the raw 'body' rather
    -- than 'normalisedBody' — a hypothetical empty 4xx body would just
    -- fail to decode as 'EsError' and fall through to the original
    -- error in 'explode'.
    normalisedBody = if BL.null body then "null" else body
    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 = Left $ EsProtocolException (T.pack errorMsg) body

-- | Parse 'BHResponse' with an arbitrary parser
parseEsResponseWith ::
  ( MonadThrow m,
    FromJSON body
  ) =>
  (body -> Either String parsed) ->
  BHResponse parsingContext body ->
  m parsed
parseEsResponseWith parser response =
  case eitherDecode body of
    Left e -> explode e
    Right parsed ->
      case parser parsed of
        Right a -> return a
        Left e -> explode e
  where
    body = responseBody $ getResponse response
    explode errorMsg = throwM $ EsProtocolException (T.pack errorMsg) body

-- | Helper around 'aeson' 'decode'
decodeResponse ::
  (FromJSON a) =>
  BHResponse StatusIndependant a ->
  Maybe a
decodeResponse = decode . responseBody . getResponse

-- | Helper around 'aeson' 'eitherDecode'
eitherDecodeResponse ::
  (FromJSON a) =>
  BHResponse StatusIndependant a ->
  Either String a
eitherDecodeResponse = eitherDecode . responseBody . getResponse

-- | Was there an optimistic concurrency control conflict when
-- indexing a document? (Check '409' status code.)
isVersionConflict :: BHResponse parsingContext a -> Bool
isVersionConflict = statusCheck (== 409)

-- | Check '2xx' status codes
isSuccess :: BHResponse parsingContext a -> Bool
isSuccess = statusCodeIs (200, 299)

-- | Check '201' status code
isCreated :: BHResponse parsingContext a -> Bool
isCreated = statusCheck (== 201)

-- | Check status code
statusCheck :: (Int -> Bool) -> BHResponse parsingContext a -> Bool
statusCheck prd = prd . NHTS.statusCode . responseStatus . getResponse

-- | Check status code in range
statusCodeIs :: (Int, Int) -> BHResponse parsingContext body -> Bool
statusCodeIs r resp = inRange r $ NHTS.statusCode (responseStatus $ getResponse resp)

-- | 'EsResult' describes the standard wrapper JSON document that you see in
--   successful Elasticsearch lookups or lookups that couldn't find the document.
data EsResult a = EsResult
  { _index :: Text,
    _type :: Maybe Text,
    _id :: Text,
    foundResult :: Maybe (EsResultFound a)
  }
  deriving stock (Eq, Show)

{-# DEPRECATED _type "deprecated since ElasticSearch 6.0" #-}

-- | 'EsResultFound' contains the document and its metadata inside of an
--   'EsResult' when the document was successfully found.
--
--   @_seq_no@, @_primary_term@, and @_routing@ are 'Maybe' because they
--   are not always present in every response shape Bloodhound parses as
--   an 'EsResultFound' (e.g. test fixtures, the @_source=false@ case,
--   older protocol variants). Real ES7+\/OpenSearch @GET _doc@ responses
--   always include @_seq_no@ and @_primary_term@ on found documents.
data EsResultFound a = EsResultFound
  { _version :: DocVersion,
    _source :: a,
    _seq_no :: Maybe Int,
    _primary_term :: Maybe Int,
    _routing :: Maybe Text
  }
  deriving stock (Eq, Show)

instance (FromJSON a) => FromJSON (EsResult a) where
  parseJSON jsonVal@(Object v) = do
    found <- v .:? "found" .!= False
    fr <-
      if found
        -- 'optional' swallows ANY parse failure of 'EsResultFound',
        -- not just the missing @_source@ case. The primary trigger is
        -- the caller sending @_source=false@ (or the equivalent
        -- per-document filter in mget), which causes the @_source@ key
        -- to be absent from the response. In that case the document
        -- /was/ found but no source is available, so 'foundResult' is
        -- 'Nothing'. If the caller's 'FromJSON' instance is malformed
        -- or a future ES version drops @_version@, the parse failure
        -- is also swallowed here — a deliberate tradeoff for keeping
        -- @'_source' :: a@ non-'Maybe' in 'EsResultFound'.
        then A.optional (parseJSON jsonVal)
        else return Nothing
    EsResult
      <$> v
        .: "_index"
      <*> v
        .:? "_type"
      <*> v
        .: "_id"
      <*> pure fr
  parseJSON _ = empty

instance (FromJSON a) => FromJSON (EsResultFound a) where
  parseJSON (Object v) =
    EsResultFound
      <$> v
        .: "_version"
      <*> v
        .: "_source"
      <*> v
        .:? "_seq_no"
      <*> v
        .:? "_primary_term"
      <*> v
        .:? "_routing"
  parseJSON _ = empty

esResultFoundSeqNoLens :: Lens' (EsResultFound a) (Maybe Int)
esResultFoundSeqNoLens = lens _seq_no (\x y -> x {_seq_no = y})

esResultFoundPrimaryTermLens :: Lens' (EsResultFound a) (Maybe Int)
esResultFoundPrimaryTermLens = lens _primary_term (\x y -> x {_primary_term = y})

esResultFoundRoutingLens :: Lens' (EsResultFound a) (Maybe Text)
esResultFoundRoutingLens = lens _routing (\x y -> x {_routing = y})

-- | 'EsError' is the generic type that will be returned when there was a
--   problem. If you can't parse the expected response, its a good idea to
--   try parsing this.
data EsError = EsError
  { errorStatus :: Maybe Int,
    errorMessage :: Text
  }
  deriving stock (Eq, Show)

{-# DEPRECATED errorStatus "deprecated since ElasticSearch 6.0" #-}

instance Exception EsError

instance Semigroup EsError where
  _ <> x = x

instance Monoid EsError where
  mempty = EsError Nothing "Monoid value, shouldn't happen"

instance FromJSON EsError where
  parseJSON (Object v) = p1 <|> p2 <|> p3 <|> p4
    where
      -- Shape: {"status": <int>, "error": "<string>"}
      p1 =
        EsError
          <$> v .:? "status"
          <*> v .: "error"
      -- Shape: {"status": <int>, "error": {"reason": ..., "type": ...}}
      -- The reason field is fetched loosely because some servers (e.g.
      -- OpenSearch 1.3 on @GET /_script_language@) emit a 500 with
      -- @reason: null@ for an internal null_pointer_exception; we fall
      -- back to @type@, then a placeholder, so the decode yields a
      -- proper 'EsError' rather than an 'EsProtocolException'.
      p2 = do
        status <- v .:? "status"
        err <- v .: "error"
        reason <- err .:? "reason"
        typ <- err .:? "type"
        return $ EsError status $ case (reason, typ) of
          (Just r, _) -> r
          (Nothing, Just t) -> t
          _ -> "unknown error"
      -- Shape: {"failures": [{"status": <int>, "cause": {...}}]}
      -- Same null-tolerant treatment of @cause.reason@, falling back
      -- to @cause.type@.
      p3 = do
        failures <- v .: "failures"
        -- This is a bit imprecise: We're only using the first error, ignoring
        -- all others.
        case failures of
          (failure : _) -> do
            status <- failure .:? "status"
            cause <- failure .: "cause"
            reason <- cause .:? "reason"
            typ <- cause .:? "type"
            return $ EsError status $ case (reason, typ) of
              (Just r, _) -> r
              (Nothing, Just t) -> t
              _ -> "unknown failure"
          [] -> fail "could not find field `failure`"
      -- Catch-all so that future/unknown error shapes still surface as
      -- a proper 'EsError' rather than bubbling up as an
      -- 'EsProtocolException' from 'parseEsResponse'.
      p4 = EsError <$> v .:? "status" <*> pure "unrecognised error body"
  parseJSON _ = empty

-- | 'EsProtocolException' will be thrown if Bloodhound cannot parse a response
-- returned by the Elasticsearch server. If you encounter this error, please
-- verify that your domain data types and FromJSON instances are working properly
-- (for example, the 'a' of '[Hit a]' in 'SearchResult.searchHits.hits'). If you're
-- sure that your mappings are correct, then this error may be an indication of an
-- incompatibility between Bloodhound and Elasticsearch. Please open a bug report
-- and be sure to include the exception body.
data EsProtocolException = EsProtocolException
  { esProtoExMessage :: !Text,
    esProtoExResponse :: !BL.ByteString
  }
  deriving stock (Eq, Show)

instance Exception EsProtocolException

newtype Acknowledged = Acknowledged {isAcknowledged :: Bool}
  deriving stock (Eq, Show)

-- | Decodes @{"acknowledged": <bool>}@ as usual. Also accepts the
-- JSON value @null@, mapping it to 'Acknowledged' @True@: the ES
-- @/_cluster/voting_config_exclusions@ POST and DELETE endpoints
-- (at least on ES 7.17; see bead @bloodhound-vvj@) return HTTP 200
-- with an empty body for their 'Acknowledged'-typed responses, and
-- 'parseEsResponse' normalises such empty bodies to @null@ before
-- invoking this instance. A 200 OK with no body IS the
-- acknowledgement per the server's documented semantics; treating
-- @null@ as @True@ matches that wire shape without affecting the
-- well-formed @{"acknowledged": <bool>}@ path.
instance FromJSON Acknowledged where
  parseJSON Null = return (Acknowledged True)
  parseJSON v = withObject "Acknowledged" (fmap Acknowledged . (.: "acknowledged")) v

newtype Accepted = Accepted {isAccepted :: Bool}
  deriving stock (Eq, Show)

instance FromJSON Accepted where
  parseJSON =
    withObject "Accepted" $
      fmap Accepted . (.: "accepted")

data IgnoredBody = IgnoredBody
  deriving stock (Eq, Show)

instance FromJSON IgnoredBody where
  parseJSON _ = return IgnoredBody