1{-# LANGUAGE OverloadedStrings #-}
2{-# LANGUAGE RecordWildCards #-}
3{-# LANGUAGE TupleSections #-}
5-------------------------------------------------------------------------------
7-- Module : Database.Bloodhound.Client
8-- Copyright : (C) 2014 Chris Allen
9-- License : BSD-style (see the file LICENSE)
10-- Maintainer : Chris Allen <cma@bitemyapp.com
11-- Stability : provisional
12-- Portability : OverloadedStrings
14-- Client side functions for talking to Elasticsearch servers.
16-------------------------------------------------------------------------------
18module Database.Bloodhound.Client
19 ( -- * Bloodhound client functions
20 -- | The examples in this module assume the following code has been run.
21 -- The :{ and :} will only work in GHCi. You'll only need the data types
22 -- and typeclass instances for the functions that make use of them.
40 -- *** Index Templates
69 -- ** Snapshot/Restore
79 -- *** Restoring Snapshots
84 -- ** Request Utilities
85 , encodeBulkOperations
89 -- * Reply-handling tools
97import qualified Blaze.ByteString.Builder as BB
98import Control.Applicative as A
100import Control.Monad.Catch
101import Control.Monad.IO.Class
103import Data.ByteString.Lazy.Builder
104import qualified Data.ByteString.Lazy.Char8 as L
105import Data.Foldable (toList)
106import qualified Data.HashMap.Strict as HM
108import qualified Data.List as LS (filter, foldl')
109import Data.List.NonEmpty (NonEmpty (..))
110import Data.Maybe (catMaybes, fromMaybe, isJust)
112import Data.Text (Text)
113import qualified Data.Text as T
114import qualified Data.Text.Encoding as T
115import Data.Time.Clock
116import qualified Data.Vector as V
117import Network.HTTP.Client
118import qualified Network.HTTP.Types.Method as NHTM
119import qualified Network.HTTP.Types.Status as NHTS
120import qualified Network.HTTP.Types.URI as NHTU
121import qualified Network.URI as URI
122import Prelude hiding (filter, head)
124import Database.Bloodhound.Types
127-- >>> :set -XOverloadedStrings
128-- >>> :set -XDeriveGeneric
129-- >>> import Database.Bloodhound
130-- >>> let testServer = (Server "http://localhost:9200")
131-- >>> let runBH' = withBH defaultManagerSettings testServer
132-- >>> let testIndex = IndexName "twitter"
133-- >>> let testMapping = MappingName "tweet"
134-- >>> let defaultIndexSettings = IndexSettings (ShardCount 1) (ReplicaCount 0)
135-- >>> data TweetMapping = TweetMapping deriving (Eq, Show)
136-- >>> _ <- runBH' $ deleteIndex testIndex >> deleteMapping testIndex testMapping
137-- >>> import GHC.Generics
138-- >>> import Data.Time.Calendar (Day (..))
139-- >>> import Data.Time.Clock (UTCTime (..), secondsToDiffTime)
141--instance ToJSON TweetMapping where
142-- toJSON TweetMapping =
143-- object ["properties" .=
144-- object ["location" .=
145-- object ["type" .= ("geo_point" :: Text)]]]
146--data Location = Location { lat :: Double
147-- , lon :: Double } deriving (Eq, Generic, Show)
148--data Tweet = Tweet { user :: Text
149-- , postDate :: UTCTime
152-- , location :: Location } deriving (Eq, Generic, Show)
153--exampleTweet = Tweet { user = "bitemyapp"
154-- , postDate = UTCTime
155-- (ModifiedJulianDay 55000)
156-- (secondsToDiffTime 10)
157-- , message = "Use haskell!"
159-- , location = Location 40.12 (-71.34) }
160--instance ToJSON Tweet where
161-- toJSON = genericToJSON defaultOptions
162--instance FromJSON Tweet where
163-- parseJSON = genericParseJSON defaultOptions
164--instance ToJSON Location where
165-- toJSON = genericToJSON defaultOptions
166--instance FromJSON Location where
167-- parseJSON = genericParseJSON defaultOptions
168--data BulkTest = BulkTest { name :: Text } deriving (Eq, Generic, Show)
169--instance FromJSON BulkTest where
170-- parseJSON = genericParseJSON defaultOptions
171--instance ToJSON BulkTest where
172-- toJSON = genericToJSON defaultOptions
175-- | 'mkShardCount' is a straight-forward smart constructor for 'ShardCount'
176-- which rejects 'Int' values below 1 and above 1000.
178-- >>> mkShardCount 10
179-- Just (ShardCount 10)
180mkShardCount :: Int -> Maybe ShardCount
184 | otherwise = Just (ShardCount n)
186-- | 'mkReplicaCount' is a straight-forward smart constructor for 'ReplicaCount'
187-- which rejects 'Int' values below 0 and above 1000.
189-- >>> mkReplicaCount 10
190-- Just (ReplicaCount 10)
191mkReplicaCount :: Int -> Maybe ReplicaCount
194 | n > 1000 = Nothing -- ...
195 | otherwise = Just (ReplicaCount n)
197emptyBody :: L.ByteString
203 -> Maybe L.ByteString
205dispatch dMethod url body = do
206 initReq <- liftIO $ parseUrl' url
207 reqHook <- bhRequestHook A.<$> getBHEnv
208 let reqBody = RequestBodyLBS $ fromMaybe emptyBody body
209 req <- liftIO $ reqHook $ setRequestIgnoreStatus $ initReq { method = dMethod
210 , requestBody = reqBody }
211 mgr <- bhManager <$> getBHEnv
212 liftIO $ httpLbs req mgr
214joinPath' :: [Text] -> Text
215joinPath' = T.intercalate "/"
217joinPath :: MonadBH m => [Text] -> m Text
219 Server s <- bhServer <$> getBHEnv
220 return $ joinPath' (s:ps)
222appendSearchTypeParam :: Text -> SearchType -> Text
223appendSearchTypeParam originalUrl st = addQuery params originalUrl
224 where stText = "search_type"
226 | st == SearchTypeDfsQueryThenFetch = [(stText, Just "dfs_query_then_fetch")]
227 | st == SearchTypeCount = [(stText, Just "count")]
228 | st == SearchTypeScan = [(stText, Just "scan"), ("scroll", Just "1m")]
229 | st == SearchTypeQueryAndFetch = [(stText, Just "query_and_fetch")]
230 | st == SearchTypeDfsQueryAndFetch = [(stText, Just "dfs_query_and_fetch")]
231 -- used to catch 'SearchTypeQueryThenFetch', which is also the default
232 | otherwise = [(stText, Just "query_then_fetch")]
234-- | Severely dumbed down query renderer. Assumes your data doesn't
236addQuery :: [(Text, Maybe Text)] -> Text -> Text
237addQuery q u = u <> rendered
240 T.decodeUtf8 $ BB.toByteString $ NHTU.renderQueryText prependQuestionMark q
241 prependQuestionMark = True
243bindM2 :: (Applicative m, Monad m) => (a -> b -> m c) -> m a -> m b -> m c
244bindM2 f ma mb = join (f <$> ma <*> mb)
246-- | Convenience function that sets up a manager and BHEnv and runs
247-- the given set of bloodhound operations. Connections will be
248-- pipelined automatically in accordance with the given manager
249-- settings in IO. If you've got your own monad transformer stack, you
250-- should use 'runBH' directly.
251withBH :: ManagerSettings -> Server -> BH IO a -> IO a
254 let env = mkBHEnv s mgr
257-- Shortcut functions for HTTP methods
258delete :: MonadBH m => Text -> m Reply
259delete = flip (dispatch NHTM.methodDelete) Nothing
260get :: MonadBH m => Text -> m Reply
261get = flip (dispatch NHTM.methodGet) Nothing
262head :: MonadBH m => Text -> m Reply
263head = flip (dispatch NHTM.methodHead) Nothing
264put :: MonadBH m => Text -> Maybe L.ByteString -> m Reply
265put = dispatch NHTM.methodPut
266post :: MonadBH m => Text -> Maybe L.ByteString -> m Reply
267post = dispatch NHTM.methodPost
269-- indexDocument s ix name doc = put (root </> s </> ix </> name </> doc) (Just encode doc)
270-- http://hackage.haskell.org/package/http-client-lens-0.1.0/docs/Network-HTTP-Client-Lens.html
271-- https://github.com/supki/libjenkins/blob/master/src/Jenkins/Rest/Internal.hs
273-- | 'getStatus' fetches the 'Status' of a 'Server'
275-- >>> serverStatus <- runBH' getStatus
276-- >>> fmap status (serverStatus)
278getStatus :: MonadBH m => m (Maybe Status)
280 response <- get =<< url
281 return $ decode (responseBody response)
282 where url = joinPath []
284-- | 'getSnapshotRepos' gets the definitions of a subset of the
285-- defined snapshot repos.
290 => SnapshotRepoSelection
291 -> m (Either EsError [GenericSnapshotRepo])
292getSnapshotRepos sel = fmap (fmap unGSRs) . parseEsResponse =<< get =<< url
294 url = joinPath ["_snapshot", selectorSeg]
295 selectorSeg = case sel of
296 AllSnapshotRepos -> "_all"
297 SnapshotRepoList (p :| ps) -> T.intercalate "," (renderPat <$> (p:ps))
298 renderPat (RepoPattern t) = t
299 renderPat (ExactRepo (SnapshotRepoName t)) = t
302-- | Wrapper to extract the list of 'GenericSnapshotRepo' in the
303-- format they're returned in
304newtype GSRs = GSRs { unGSRs :: [GenericSnapshotRepo] }
307instance FromJSON GSRs where
308 parseJSON = withObject "Collection of GenericSnapshotRepo" parse
310 parse = fmap GSRs . mapM (uncurry go) . HM.toList
311 go rawName = withObject "GenericSnapshotRepo" $ \o -> do
312 GenericSnapshotRepo (SnapshotRepoName rawName) <$> o .: "type"
316-- | Create or update a snapshot repo
321 => SnapshotRepoUpdateSettings
322 -- ^ Use 'defaultSnapshotRepoUpdateSettings' if unsure
325updateSnapshotRepo SnapshotRepoUpdateSettings {..} repo =
326 bindM2 put url (return (Just body))
328 url = addQuery params <$> joinPath ["_snapshot", snapshotRepoName gSnapshotRepoName]
330 | repoUpdateVerify = []
331 | otherwise = [("verify", Just "false")]
332 body = encode $ object [ "type" .= gSnapshotRepoType
333 , "settings" .= gSnapshotRepoSettings
335 GenericSnapshotRepo {..} = toGSnapshotRepo repo
339-- | Verify if a snapshot repo is working. __NOTE:__ this API did not
340-- make it into ElasticSearch until 1.4. If you use an older version,
341-- you will get an error here.
347 -> m (Either EsError SnapshotVerification)
348verifySnapshotRepo (SnapshotRepoName n) =
349 parseEsResponse =<< bindM2 post url (return Nothing)
351 url = joinPath ["_snapshot", n, "_verify"]
354deleteSnapshotRepo :: MonadBH m => SnapshotRepoName -> m Reply
355deleteSnapshotRepo (SnapshotRepoName n) = delete =<< url
357 url = joinPath ["_snapshot", n]
360-- | Create and start a snapshot
365 -> SnapshotCreateSettings
367createSnapshot (SnapshotRepoName repoName)
368 (SnapshotName snapName)
369 SnapshotCreateSettings {..} =
370 bindM2 put url (return (Just body))
372 url = addQuery params <$> joinPath ["_snapshot", repoName, snapName]
373 params = [("wait_for_completion", Just (boolQP snapWaitForCompletion))]
374 body = encode $ object prs
375 prs = catMaybes [ ("indices" .=) . indexSelectionName <$> snapIndices
376 , Just ("ignore_unavailable" .= snapIgnoreUnavailable)
377 , Just ("ignore_global_state" .= snapIncludeGlobalState)
378 , Just ("partial" .= snapPartial)
382indexSelectionName :: IndexSelection -> Text
383indexSelectionName AllIndexes = "_all"
384indexSelectionName (IndexList (i :| is)) = T.intercalate "," (renderIndex <$> (i:is))
386 renderIndex (IndexName n) = n
389-- | Get info about known snapshots given a pattern and repo name.
396 -> m (Either EsError [SnapshotInfo])
397getSnapshots (SnapshotRepoName repoName) sel =
398 fmap (fmap unSIs) . parseEsResponse =<< get =<< url
400 url = joinPath ["_snapshot", repoName, snapPath]
401 snapPath = case sel of
402 AllSnapshots -> "_all"
403 SnapshotList (s :| ss) -> T.intercalate "," (renderPath <$> (s:ss))
404 renderPath (SnapPattern t) = t
405 renderPath (ExactSnap (SnapshotName t)) = t
408newtype SIs = SIs { unSIs :: [SnapshotInfo] }
411instance FromJSON SIs where
412 parseJSON = withObject "Collection of SnapshotInfo" parse
414 parse o = SIs <$> o .: "snapshots"
417-- | Delete a snapshot. Cancels if it is running.
418deleteSnapshot :: MonadBH m => SnapshotRepoName -> SnapshotName -> m Reply
419deleteSnapshot (SnapshotRepoName repoName) (SnapshotName snapName) =
422 url = joinPath ["_snapshot", repoName, snapName]
425-- | Restore a snapshot to the cluster See
426-- <https://www.elastic.co/guide/en/elasticsearch/reference/1.7/modules-snapshots.html#_restore>
432 -> SnapshotRestoreSettings
433 -- ^ Start with 'defaultSnapshotRestoreSettings' and customize
434 -- from there for reasonable defaults.
436restoreSnapshot (SnapshotRepoName repoName)
437 (SnapshotName snapName)
438 SnapshotRestoreSettings {..} = bindM2 post url (return (Just body))
440 url = addQuery params <$> joinPath ["_snapshot", repoName, snapName, "_restore"]
441 params = [("wait_for_completion", Just (boolQP snapRestoreWaitForCompletion))]
442 body = encode (object prs)
445 prs = catMaybes [ ("indices" .=) . indexSelectionName <$> snapRestoreIndices
446 , Just ("ignore_unavailable" .= snapRestoreIgnoreUnavailable)
447 , Just ("include_global_state" .= snapRestoreIncludeGlobalState)
448 , ("rename_pattern" .=) <$> snapRestoreRenamePattern
449 , ("rename_replacement" .=) . renderTokens <$> snapRestoreRenameReplacement
450 , Just ("include_aliases" .= snapRestoreIncludeAliases)
451 , ("index_settings" .= ) <$> snapRestoreIndexSettingsOverrides
452 , ("ignore_index_settings" .= ) <$> snapRestoreIgnoreIndexSettings
454 renderTokens (t :| ts) = mconcat (renderToken <$> (t:ts))
455 renderToken (RRTLit t) = t
456 renderToken RRSubWholeMatch = "$0"
457 renderToken (RRSubGroup g) = T.pack (show (rrGroupRefNum g))
465 -> m (Either EsError NodesInfo)
466getNodesInfo sel = parseEsResponse =<< get =<< url
468 url = joinPath ["_nodes", selectionSeg]
469 selectionSeg = case sel of
470 LocalNode -> "_local"
471 NodeList (l :| ls) -> T.intercalate "," (selToSeg <$> (l:ls))
473 selToSeg (NodeByName (NodeName n)) = n
474 selToSeg (NodeByFullNodeId (FullNodeId i)) = i
475 selToSeg (NodeByHost (Server s)) = s
476 selToSeg (NodeByAttribute (NodeAttrName a) v) = a <> ":" <> v
483 -> m (Either EsError NodesStats)
484getNodesStats sel = parseEsResponse =<< get =<< url
486 url = joinPath ["_nodes", selectionSeg, "stats"]
487 selectionSeg = case sel of
488 LocalNode -> "_local"
489 NodeList (l :| ls) -> T.intercalate "," (selToSeg <$> (l:ls))
491 selToSeg (NodeByName (NodeName n)) = n
492 selToSeg (NodeByFullNodeId (FullNodeId i)) = i
493 selToSeg (NodeByHost (Server s)) = s
494 selToSeg (NodeByAttribute (NodeAttrName a) v) = a <> ":" <> v
496-- | 'createIndex' will create an index given a 'Server', 'IndexSettings', and an 'IndexName'.
498-- >>> response <- runBH' $ createIndex defaultIndexSettings (IndexName "didimakeanindex")
499-- >>> respIsTwoHunna response
501-- >>> runBH' $ indexExists (IndexName "didimakeanindex")
503createIndex :: MonadBH m => IndexSettings -> IndexName -> m Reply
504createIndex indexSettings (IndexName indexName) =
505 bindM2 put url (return body)
506 where url = joinPath [indexName]
507 body = Just $ encode indexSettings
510-- | 'deleteIndex' will delete an index given a 'Server', and an 'IndexName'.
512-- >>> _ <- runBH' $ createIndex defaultIndexSettings (IndexName "didimakeanindex")
513-- >>> response <- runBH' $ deleteIndex (IndexName "didimakeanindex")
514-- >>> respIsTwoHunna response
516-- >>> runBH' $ indexExists testIndex
518deleteIndex :: MonadBH m => IndexName -> m Reply
519deleteIndex (IndexName indexName) =
520 delete =<< joinPath [indexName]
522-- | 'updateIndexSettings' will apply a non-empty list of setting updates to an index
524-- >>> _ <- runBH' $ createIndex defaultIndexSettings (IndexName "unconfiguredindex")
525-- >>> response <- runBH' $ updateIndexSettings (BlocksWrite False :| []) (IndexName "unconfiguredindex")
526-- >>> respIsTwoHunna response
528updateIndexSettings :: MonadBH m => NonEmpty UpdatableIndexSetting -> IndexName -> m Reply
529updateIndexSettings updates (IndexName indexName) =
530 bindM2 put url (return body)
531 where url = joinPath [indexName, "_settings"]
532 body = Just (encode jsonBody)
533 jsonBody = Object (deepMerge [u | Object u <- toJSON <$> toList updates])
536getIndexSettings :: (MonadBH m, MonadThrow m) => IndexName
537 -> m (Either EsError IndexSettingsSummary)
538getIndexSettings (IndexName indexName) = do
539 parseEsResponse =<< get =<< url
540 where url = joinPath [indexName, "_settings"]
543-- | 'optimizeIndex' will optimize a single index, list of indexes or
544-- all indexes. Note that this call will block until finishing but
545-- will continue even if the request times out. Concurrent requests to
546-- optimize an index while another is performing will block until the
547-- previous one finishes. For more information see
548-- <https://www.elastic.co/guide/en/elasticsearch/reference/1.7/indices-optimize.html>. Nothing
549-- worthwhile comes back in the reply body, so matching on the status
552-- 'optimizeIndex' with a maxNumSegments of 1 and onlyExpungeDeletes
553-- to True is the main way to release disk space back to the OS being
554-- held by deleted documents.
556-- Note that this API was deprecated in ElasticSearch 2.1 for the
557-- almost completely identical forcemerge API. Adding support to that
558-- API would be trivial but due to the significant breaking changes,
559-- this library cannot currently be used with >= 2.0, so that feature was omitted.
561-- >>> let ixn = IndexName "unoptimizedindex"
562-- >>> _ <- runBH' $ deleteIndex ixn >> createIndex defaultIndexSettings ixn
563-- >>> response <- runBH' $ optimizeIndex (IndexList (ixn :| [])) (defaultIndexOptimizationSettings { maxNumSegments = Just 1, onlyExpungeDeletes = True })
564-- >>> respIsTwoHunna response
566optimizeIndex :: MonadBH m => IndexSelection -> IndexOptimizationSettings -> m Reply
567optimizeIndex ixs IndexOptimizationSettings {..} =
568 bindM2 post url (return body)
569 where url = addQuery params <$> joinPath [indexName, "_optimize"]
570 params = catMaybes [ ("max_num_segments",) . Just . showText <$> maxNumSegments
571 , Just ("only_expunge_deletes", Just (boolQP onlyExpungeDeletes))
572 , Just ("flush", Just (boolQP flushAfterOptimize))
574 indexName = indexSelectionName ixs
578deepMerge :: [Object] -> Object
579deepMerge = LS.foldl' go mempty
580 where go acc = LS.foldl' go' acc . HM.toList
581 go' acc (k, v) = HM.insertWith merge k v acc
582 merge (Object a) (Object b) = Object (deepMerge [a, b])
586statusCodeIs :: (Int, Int) -> Reply -> Bool
587statusCodeIs r resp = inRange r $ NHTS.statusCode (responseStatus resp)
589respIsTwoHunna :: Reply -> Bool
590respIsTwoHunna = statusCodeIs (200, 299)
592existentialQuery :: MonadBH m => Text -> m (Reply, Bool)
593existentialQuery url = do
595 return (reply, respIsTwoHunna reply)
598-- | Tries to parse a response body as the expected type @a@ and
599-- failing that tries to parse it as an EsError. All well-formed, JSON
600-- responses from elasticsearch should fall into these two
601-- categories. If they don't, a 'EsProtocolException' will be
602-- thrown. If you encounter this, please report the full body it
603-- reports along with your ElasticSearch verison.
604parseEsResponse :: (MonadThrow m, FromJSON a) => Reply
605 -> m (Either EsError a)
607 | respIsTwoHunna reply = case eitherDecode body of
608 Right a -> return (Right a)
609 Left _ -> tryParseError
610 | otherwise = tryParseError
611 where body = responseBody reply
612 tryParseError = case eitherDecode body of
613 Right e -> return (Left e)
614 -- this case should not be possible
616 explode = throwM (EsProtocolException body)
618-- | 'indexExists' enables you to check if an index exists. Returns 'Bool'
621-- >>> exists <- runBH' $ indexExists testIndex
622indexExists :: MonadBH m => IndexName -> m Bool
623indexExists (IndexName indexName) = do
624 (_, exists) <- existentialQuery =<< joinPath [indexName]
627-- | 'refreshIndex' will force a refresh on an index. You must
628-- do this if you want to read what you wrote.
630-- >>> _ <- runBH' $ createIndex defaultIndexSettings testIndex
631-- >>> _ <- runBH' $ refreshIndex testIndex
632refreshIndex :: MonadBH m => IndexName -> m Reply
633refreshIndex (IndexName indexName) =
634 bindM2 post url (return Nothing)
635 where url = joinPath [indexName, "_refresh"]
637-- | Block until the index becomes available for indexing
638-- documents. This is useful for integration tests in which
639-- indices are rapidly created and deleted.
640waitForYellowIndex :: MonadBH m => IndexName -> m Reply
641waitForYellowIndex (IndexName indexName) = get =<< url
642 where url = addQuery q <$> joinPath ["_cluster","health",indexName]
643 q = [("wait_for_status",Just "yellow"),("timeout",Just "10s")]
645stringifyOCIndex :: OpenCloseIndex -> Text
646stringifyOCIndex oci = case oci of
648 CloseIndex -> "_close"
650openOrCloseIndexes :: MonadBH m => OpenCloseIndex -> IndexName -> m Reply
651openOrCloseIndexes oci (IndexName indexName) =
652 bindM2 post url (return Nothing)
653 where ociString = stringifyOCIndex oci
654 url = joinPath [indexName, ociString]
656-- | 'openIndex' opens an index given a 'Server' and an 'IndexName'. Explained in further detail at
657-- <http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/indices-open-close.html>
659-- >>> reply <- runBH' $ openIndex testIndex
660openIndex :: MonadBH m => IndexName -> m Reply
661openIndex = openOrCloseIndexes OpenIndex
663-- | 'closeIndex' closes an index given a 'Server' and an 'IndexName'. Explained in further detail at
664-- <http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/indices-open-close.html>
666-- >>> reply <- runBH' $ closeIndex testIndex
667closeIndex :: MonadBH m => IndexName -> m Reply
668closeIndex = openOrCloseIndexes CloseIndex
670-- | 'listIndices' returns a list of all index names on a given 'Server'
671listIndices :: (MonadThrow m, MonadBH m) => m [IndexName]
673 parse . responseBody =<< get =<< url
675 url = joinPath ["_cat/indices?v"]
676 -- parses the tabular format the indices api provides
677 parse body = case T.lines (T.decodeUtf8 (L.toStrict body)) of
678 (hdr:rows) -> let ks = T.words hdr
679 keyedRows = [ HM.fromList (zip ks (T.words row)) | row <- rows ]
680 names = catMaybes (HM.lookup "index" <$> keyedRows)
681 in return (IndexName <$> names)
682 [] -> throwM (EsProtocolException body)
684-- | 'updateIndexAliases' updates the server's index alias
685-- table. Operations are atomic. Explained in further detail at
686-- <https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-aliases.html>
688-- >>> let src = IndexName "a-real-index"
689-- >>> let aliasName = IndexName "an-alias"
690-- >>> let iAlias = IndexAlias src (IndexAliasName aliasName)
691-- >>> let aliasCreate = IndexAliasCreate Nothing Nothing
692-- >>> _ <- runBH' $ deleteIndex src
693-- >>> respIsTwoHunna <$> runBH' (createIndex defaultIndexSettings src)
695-- >>> runBH' $ indexExists src
697-- >>> respIsTwoHunna <$> runBH' (updateIndexAliases (AddAlias iAlias aliasCreate :| []))
699-- >>> runBH' $ indexExists aliasName
701updateIndexAliases :: MonadBH m => NonEmpty IndexAliasAction -> m Reply
702updateIndexAliases actions = bindM2 post url (return body)
703 where url = joinPath ["_aliases"]
704 body = Just (encode bodyJSON)
705 bodyJSON = object [ "actions" .= toList actions]
707-- | Get all aliases configured on the server.
708getIndexAliases :: (MonadBH m, MonadThrow m)
709 => m (Either EsError IndexAliasesSummary)
710getIndexAliases = parseEsResponse =<< get =<< url
711 where url = joinPath ["_aliases"]
713-- | 'putTemplate' creates a template given an 'IndexTemplate' and a 'TemplateName'.
714-- Explained in further detail at
715-- <https://www.elastic.co/guide/en/elasticsearch/reference/1.7/indices-templates.html>
717-- >>> let idxTpl = IndexTemplate (TemplatePattern "tweet-*") (Just (IndexSettings (ShardCount 1) (ReplicaCount 1))) [toJSON TweetMapping]
718-- >>> resp <- runBH' $ putTemplate idxTpl (TemplateName "tweet-tpl")
719putTemplate :: MonadBH m => IndexTemplate -> TemplateName -> m Reply
720putTemplate indexTemplate (TemplateName templateName) =
721 bindM2 put url (return body)
722 where url = joinPath ["_template", templateName]
723 body = Just $ encode indexTemplate
725-- | 'templateExists' checks to see if a template exists.
727-- >>> exists <- runBH' $ templateExists (TemplateName "tweet-tpl")
728templateExists :: MonadBH m => TemplateName -> m Bool
729templateExists (TemplateName templateName) = do
730 (_, exists) <- existentialQuery =<< joinPath ["_template", templateName]
733-- | 'deleteTemplate' is an HTTP DELETE and deletes a template.
735-- >>> let idxTpl = IndexTemplate (TemplatePattern "tweet-*") (Just (IndexSettings (ShardCount 1) (ReplicaCount 1))) [toJSON TweetMapping]
736-- >>> _ <- runBH' $ putTemplate idxTpl (TemplateName "tweet-tpl")
737-- >>> resp <- runBH' $ deleteTemplate (TemplateName "tweet-tpl")
738deleteTemplate :: MonadBH m => TemplateName -> m Reply
739deleteTemplate (TemplateName templateName) =
740 delete =<< joinPath ["_template", templateName]
742-- | 'putMapping' is an HTTP PUT and has upsert semantics. Mappings are schemas
743-- for documents in indexes.
745-- >>> _ <- runBH' $ createIndex defaultIndexSettings testIndex
746-- >>> resp <- runBH' $ putMapping testIndex testMapping TweetMapping
748-- Response {responseStatus = Status {statusCode = 200, statusMessage = "OK"}, responseVersion = HTTP/1.1, responseHeaders = [("Content-Type","application/json; charset=UTF-8"),("Content-Length","21")], responseBody = "{\"acknowledged\":true}", responseCookieJar = CJ {expose = []}, responseClose' = ResponseClose}
749putMapping :: (MonadBH m, ToJSON a) => IndexName
750 -> MappingName -> a -> m Reply
751putMapping (IndexName indexName) (MappingName mappingName) mapping =
752 bindM2 put url (return body)
753 where url = joinPath [indexName, "_mapping", mappingName]
754 -- "_mapping" and mappingName above were originally transposed
755 -- erroneously. The correct API call is: "/INDEX/_mapping/MAPPING_NAME"
756 body = Just $ encode mapping
758-- | 'deleteMapping' is an HTTP DELETE and deletes a mapping for a given index.
759-- Mappings are schemas for documents in indexes.
761-- >>> _ <- runBH' $ createIndex defaultIndexSettings testIndex
762-- >>> _ <- runBH' $ putMapping testIndex testMapping TweetMapping
763-- >>> resp <- runBH' $ deleteMapping testIndex testMapping
765-- Response {responseStatus = Status {statusCode = 200, statusMessage = "OK"}, responseVersion = HTTP/1.1, responseHeaders = [("Content-Type","application/json; charset=UTF-8"),("Content-Length","21")], responseBody = "{\"acknowledged\":true}", responseCookieJar = CJ {expose = []}, responseClose' = ResponseClose}
766deleteMapping :: MonadBH m => IndexName -> MappingName -> m Reply
767deleteMapping (IndexName indexName)
768 (MappingName mappingName) =
769 -- "_mapping" and mappingName below were originally transposed
770 -- erroneously. The correct API call is: "/INDEX/_mapping/MAPPING_NAME"
771 delete =<< joinPath [indexName, "_mapping", mappingName]
773versionCtlParams :: IndexDocumentSettings -> [(Text, Maybe Text)]
774versionCtlParams cfg =
775 case idsVersionControl cfg of
776 NoVersionControl -> []
777 InternalVersion v -> versionParams v "internal"
778 ExternalGT (ExternalDocVersion v) -> versionParams v "external_gt"
779 ExternalGTE (ExternalDocVersion v) -> versionParams v "external_gte"
780 ForceVersion (ExternalDocVersion v) -> versionParams v "force"
782 vt = showText . docVersionNumber
783 versionParams v t = [ ("version", Just $ vt v)
784 , ("version_type", Just t)
787-- | 'indexDocument' is the primary way to save a single document in
788-- Elasticsearch. The document itself is simply something we can
789-- convert into a JSON 'Value'. The 'DocId' will function as the
790-- primary key for the document.
792-- >>> resp <- runBH' $ indexDocument testIndex testMapping defaultIndexDocumentSettings exampleTweet (DocId "1")
794-- Response {responseStatus = Status {statusCode = 201, statusMessage = "Created"}, responseVersion = HTTP/1.1, responseHeaders = [("Content-Type","application/json; charset=UTF-8"),("Content-Length","74")], responseBody = "{\"_index\":\"twitter\",\"_type\":\"tweet\",\"_id\":\"1\",\"_version\":1,\"created\":true}", responseCookieJar = CJ {expose = []}, responseClose' = ResponseClose}
795indexDocument :: (ToJSON doc, MonadBH m) => IndexName -> MappingName
796 -> IndexDocumentSettings -> doc -> DocId -> m Reply
797indexDocument (IndexName indexName)
798 (MappingName mappingName) cfg document (DocId docId) =
799 bindM2 put url (return body)
800 where url = addQuery params <$> joinPath [indexName, mappingName, docId]
801 parentParams = case idsParent cfg of
803 Just (DocumentParent (DocId p)) -> [ ("parent", Just p) ]
804 params = versionCtlParams cfg ++ parentParams
805 body = Just (encode document)
807-- | 'updateDocument' provides a way to perform an partial update of a
808-- an already indexed document.
809updateDocument :: (ToJSON patch, MonadBH m) => IndexName -> MappingName
810 -> IndexDocumentSettings -> patch -> DocId -> m Reply
811updateDocument (IndexName indexName)
812 (MappingName mappingName) cfg patch (DocId docId) =
813 bindM2 post url (return body)
814 where url = addQuery (versionCtlParams cfg) <$>
815 joinPath [indexName, mappingName, docId, "_update"]
816 body = Just (encode $ object ["doc" .= toJSON patch])
818-- | 'deleteDocument' is the primary way to delete a single document.
820-- >>> _ <- runBH' $ deleteDocument testIndex testMapping (DocId "1")
821deleteDocument :: MonadBH m => IndexName -> MappingName
823deleteDocument (IndexName indexName)
824 (MappingName mappingName) (DocId docId) =
825 delete =<< joinPath [indexName, mappingName, docId]
828-- <http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/docs-bulk.html Elasticsearch's bulk API>
829-- to perform bulk operations. The 'BulkOperation' data type encodes the
830-- index\/update\/delete\/create operations. You pass a 'V.Vector' of 'BulkOperation's
831-- and a 'Server' to 'bulk' in order to send those operations up to your Elasticsearch
832-- server to be performed. I changed from [BulkOperation] to a Vector due to memory overhead.
834-- >>> let stream = V.fromList [BulkIndex testIndex testMapping (DocId "2") (toJSON (BulkTest "blah"))]
835-- >>> _ <- runBH' $ bulk stream
836-- >>> _ <- runBH' $ refreshIndex testIndex
837bulk :: MonadBH m => V.Vector BulkOperation -> m Reply
838bulk bulkOps = bindM2 post url (return body)
839 where url = joinPath ["_bulk"]
840 body = Just $ encodeBulkOperations bulkOps
842-- | 'encodeBulkOperations' is a convenience function for dumping a vector of 'BulkOperation'
843-- into an 'L.ByteString'
845-- >>> let bulkOps = V.fromList [BulkIndex testIndex testMapping (DocId "2") (toJSON (BulkTest "blah"))]
846-- >>> encodeBulkOperations bulkOps
847-- "\n{\"index\":{\"_type\":\"tweet\",\"_id\":\"2\",\"_index\":\"twitter\"}}\n{\"name\":\"blah\"}\n"
848encodeBulkOperations :: V.Vector BulkOperation -> L.ByteString
849encodeBulkOperations stream = collapsed where
850 blobs = fmap encodeBulkOperation stream
851 mashedTaters = mash (mempty :: Builder) blobs
852 collapsed = toLazyByteString $ mappend mashedTaters (byteString "\n")
854mash :: Builder -> V.Vector L.ByteString -> Builder
855mash = V.foldl' (\b x -> b `mappend` (byteString "\n") `mappend` (lazyByteString x))
857mkBulkStreamValue :: Text -> Text -> Text -> Text -> Value
858mkBulkStreamValue operation indexName mappingName docId =
860 object [ "_index" .= indexName
861 , "_type" .= mappingName
864-- | 'encodeBulkOperation' is a convenience function for dumping a single 'BulkOperation'
865-- into an 'L.ByteString'
867-- >>> let bulkOp = BulkIndex testIndex testMapping (DocId "2") (toJSON (BulkTest "blah"))
868-- >>> encodeBulkOperation bulkOp
869-- "{\"index\":{\"_type\":\"tweet\",\"_id\":\"2\",\"_index\":\"twitter\"}}\n{\"name\":\"blah\"}"
870encodeBulkOperation :: BulkOperation -> L.ByteString
871encodeBulkOperation (BulkIndex (IndexName indexName)
872 (MappingName mappingName)
873 (DocId docId) value) = blob
874 where metadata = mkBulkStreamValue "index" indexName mappingName docId
875 blob = encode metadata `mappend` "\n" `mappend` encode value
877encodeBulkOperation (BulkCreate (IndexName indexName)
878 (MappingName mappingName)
879 (DocId docId) value) = blob
880 where metadata = mkBulkStreamValue "create" indexName mappingName docId
881 blob = encode metadata `mappend` "\n" `mappend` encode value
883encodeBulkOperation (BulkDelete (IndexName indexName)
884 (MappingName mappingName)
885 (DocId docId)) = blob
886 where metadata = mkBulkStreamValue "delete" indexName mappingName docId
887 blob = encode metadata
889encodeBulkOperation (BulkUpdate (IndexName indexName)
890 (MappingName mappingName)
891 (DocId docId) value) = blob
892 where metadata = mkBulkStreamValue "update" indexName mappingName docId
893 doc = object ["doc" .= value]
894 blob = encode metadata `mappend` "\n" `mappend` encode doc
896-- | 'getDocument' is a straight-forward way to fetch a single document from
897-- Elasticsearch using a 'Server', 'IndexName', 'MappingName', and a 'DocId'.
898-- The 'DocId' is the primary key for your Elasticsearch document.
900-- >>> yourDoc <- runBH' $ getDocument testIndex testMapping (DocId "1")
901getDocument :: MonadBH m => IndexName -> MappingName
903getDocument (IndexName indexName)
904 (MappingName mappingName) (DocId docId) =
905 get =<< joinPath [indexName, mappingName, docId]
907-- | 'documentExists' enables you to check if a document exists. Returns 'Bool'
910-- >>> exists <- runBH' $ documentExists testIndex testMapping Nothing (DocId "1")
911documentExists :: MonadBH m => IndexName -> MappingName
912 -> Maybe DocumentParent -> DocId -> m Bool
913documentExists (IndexName indexName) (MappingName mappingName)
914 parent (DocId docId) = do
915 (_, exists) <- existentialQuery =<< url
917 where url = addQuery params <$> joinPath [indexName, mappingName, docId]
918 parentParam = fmap (\(DocumentParent (DocId p)) -> p) parent
919 params = LS.filter (\(_, v) -> isJust v) [("parent", parentParam)]
921dispatchSearch :: MonadBH m => Text -> Search -> m Reply
922dispatchSearch url search = post url' (Just (encode search))
923 where url' = appendSearchTypeParam url (searchType search)
925-- | 'searchAll', given a 'Search', will perform that search against all indexes
926-- on an Elasticsearch server. Try to avoid doing this if it can be helped.
928-- >>> let query = TermQuery (Term "user" "bitemyapp") Nothing
929-- >>> let search = mkSearch (Just query) Nothing
930-- >>> reply <- runBH' $ searchAll search
931searchAll :: MonadBH m => Search -> m Reply
932searchAll = bindM2 dispatchSearch url . return
933 where url = joinPath ["_search"]
935-- | 'searchByIndex', given a 'Search' and an 'IndexName', will perform that search
936-- against all mappings within an index on an Elasticsearch server.
938-- >>> let query = TermQuery (Term "user" "bitemyapp") Nothing
939-- >>> let search = mkSearch (Just query) Nothing
940-- >>> reply <- runBH' $ searchByIndex testIndex search
941searchByIndex :: MonadBH m => IndexName -> Search -> m Reply
942searchByIndex (IndexName indexName) = bindM2 dispatchSearch url . return
943 where url = joinPath [indexName, "_search"]
945-- | 'searchByType', given a 'Search', 'IndexName', and 'MappingName', will perform that
946-- search against a specific mapping within an index on an Elasticsearch server.
948-- >>> let query = TermQuery (Term "user" "bitemyapp") Nothing
949-- >>> let search = mkSearch (Just query) Nothing
950-- >>> reply <- runBH' $ searchByType testIndex testMapping search
951searchByType :: MonadBH m => IndexName -> MappingName -> Search
953searchByType (IndexName indexName)
954 (MappingName mappingName) = bindM2 dispatchSearch url . return
955 where url = joinPath [indexName, mappingName, "_search"]
957-- | For a given scearch, request a scroll for efficient streaming of
958-- search results. Note that the search is put into 'SearchTypeScan'
959-- mode and thus results will not be sorted. Combine this with
960-- 'advanceScroll' to efficiently stream through the full result set
961getInitialScroll :: MonadBH m => IndexName -> MappingName -> Search -> m (Maybe ScrollId)
962getInitialScroll (IndexName indexName) (MappingName mappingName) search = do
963 let url = joinPath [indexName, mappingName, "_search"]
964 search' = search { searchType = SearchTypeScan }
965 resp' <- bindM2 dispatchSearch url (return search')
966 let msr = decode' $ responseBody resp' :: Maybe (SearchResult ())
967 msid = maybe Nothing scrollId msr
970scroll' :: (FromJSON a, MonadBH m, MonadThrow m) => Maybe ScrollId -> m ([Hit a], Maybe ScrollId)
971scroll' Nothing = return ([], Nothing)
972scroll' (Just sid) = do
973 res <- advanceScroll sid 60
975 Right SearchResult {..} -> return (hits searchHits, scrollId)
976 Left _ -> return ([], Nothing)
978-- | Use the given scroll to fetch the next page of documents. If there are no
979-- further pages, 'SearchResult.searchHits.hits' will be '[]'.
987 -- ^ How long should the snapshot of data be kept around? This timeout is updated every time 'advanceScroll' is used, so don't feel the need to set it to the entire duration of your search processing. Note that durations < 1s will be rounded up. Also note that 'NominalDiffTime' is an instance of Num so literals like 60 will be interpreted as seconds. 60s is a reasonable default.
988 -> m (Either EsError (SearchResult a))
989advanceScroll (ScrollId sid) scroll = do
990 url <- joinPath ["_search/scroll?scroll=" <> scrollTime]
991 parseEsResponse =<< post url (Just . L.fromStrict $ T.encodeUtf8 sid)
992 where scrollTime = showText secs <> "s"
996simpleAccumulator :: (FromJSON a, MonadBH m, MonadThrow m) => [Hit a] -> ([Hit a], Maybe ScrollId) -> m ([Hit a], Maybe ScrollId)
997simpleAccumulator oldHits (newHits, Nothing) = return (oldHits ++ newHits, Nothing)
998simpleAccumulator oldHits ([], _) = return (oldHits, Nothing)
999simpleAccumulator oldHits (newHits, msid) = do
1000 (newHits', msid') <- scroll' msid
1001 simpleAccumulator (oldHits ++ newHits) (newHits', msid')
1003-- | 'scanSearch' uses the 'scan&scroll' API of elastic,
1004-- for a given 'IndexName' and 'MappingName'. Note that this will
1005-- consume the entire search result set and will be doing O(n) list
1006-- appends so this may not be suitable for large result sets. In that
1007-- case, 'getInitialScroll' and 'advanceScroll' are good low level
1008-- tools. You should be able to hook them up trivially to conduit,
1009-- pipes, or your favorite streaming IO abstraction of choice. Note
1010-- that ordering on the search would destroy performance and thus is
1012scanSearch :: (FromJSON a, MonadBH m, MonadThrow m) => IndexName -> MappingName -> Search -> m [Hit a]
1013scanSearch indexName mappingName search = do
1014 msid <- getInitialScroll indexName mappingName search
1015 (hits, msid') <- scroll' msid
1016 (totalHits, _) <- simpleAccumulator [] (hits, msid')
1019-- | 'mkSearch' is a helper function for defaulting additional fields of a 'Search'
1020-- to Nothing in case you only care about your 'Query' and 'Filter'. Use record update
1021-- syntax if you want to add things like aggregations or highlights while still using
1022-- this helper function.
1024-- >>> let query = TermQuery (Term "user" "bitemyapp") Nothing
1025-- >>> mkSearch (Just query) Nothing
1026-- Search {queryBody = Just (TermQuery (Term {termField = "user", termValue = "bitemyapp"}) Nothing), filterBody = Nothing, sortBody = Nothing, aggBody = Nothing, highlight = Nothing, trackSortScores = False, from = From 0, size = Size 10, searchType = SearchTypeQueryThenFetch, fields = Nothing, source = Nothing}
1027mkSearch :: Maybe Query -> Maybe Filter -> Search
1028mkSearch query filter = Search query filter Nothing Nothing Nothing False (From 0) (Size 10) SearchTypeQueryThenFetch Nothing Nothing
1030-- | 'mkAggregateSearch' is a helper function that defaults everything in a 'Search' except for
1031-- the 'Query' and the 'Aggregation'.
1033-- >>> let terms = TermsAgg $ (mkTermsAggregation "user") { termCollectMode = Just BreadthFirst }
1035-- TermsAgg (TermsAggregation {term = Left "user", termInclude = Nothing, termExclude = Nothing, termOrder = Nothing, termMinDocCount = Nothing, termSize = Nothing, termShardSize = Nothing, termCollectMode = Just BreadthFirst, termExecutionHint = Nothing, termAggs = Nothing})
1036-- >>> let myAggregation = mkAggregateSearch Nothing $ mkAggregations "users" terms
1037mkAggregateSearch :: Maybe Query -> Aggregations -> Search
1038mkAggregateSearch query mkSearchAggs = Search query Nothing Nothing (Just mkSearchAggs) Nothing False (From 0) (Size 0) SearchTypeQueryThenFetch Nothing Nothing
1040-- | 'mkHighlightSearch' is a helper function that defaults everything in a 'Search' except for
1041-- the 'Query' and the 'Aggregation'.
1043-- >>> let query = QueryMatchQuery $ mkMatchQuery (FieldName "_all") (QueryString "haskell")
1044-- >>> let testHighlight = Highlights Nothing [FieldHighlight (FieldName "message") Nothing]
1045-- >>> let search = mkHighlightSearch (Just query) testHighlight
1046mkHighlightSearch :: Maybe Query -> Highlights -> Search
1047mkHighlightSearch query searchHighlights = Search query Nothing Nothing Nothing (Just searchHighlights) False (From 0) (Size 10) SearchTypeQueryThenFetch Nothing Nothing
1049-- | 'pageSearch' is a helper function that takes a search and assigns the from
1050-- and size fields for the search. The from parameter defines the offset
1051-- from the first result you want to fetch. The size parameter allows you to
1052-- configure the maximum amount of hits to be returned.
1054-- >>> let query = QueryMatchQuery $ mkMatchQuery (FieldName "_all") (QueryString "haskell")
1055-- >>> let search = mkSearch (Just query) Nothing
1057-- Search {queryBody = Just (QueryMatchQuery (MatchQuery {matchQueryField = FieldName "_all", matchQueryQueryString = QueryString "haskell", matchQueryOperator = Or, matchQueryZeroTerms = ZeroTermsNone, matchQueryCutoffFrequency = Nothing, matchQueryMatchType = Nothing, matchQueryAnalyzer = Nothing, matchQueryMaxExpansions = Nothing, matchQueryLenient = Nothing, matchQueryBoost = Nothing})), filterBody = Nothing, sortBody = Nothing, aggBody = Nothing, highlight = Nothing, trackSortScores = False, from = From 0, size = Size 10, searchType = SearchTypeQueryThenFetch, fields = Nothing, source = Nothing}
1058-- >>> pageSearch (From 10) (Size 100) search
1059-- Search {queryBody = Just (QueryMatchQuery (MatchQuery {matchQueryField = FieldName "_all", matchQueryQueryString = QueryString "haskell", matchQueryOperator = Or, matchQueryZeroTerms = ZeroTermsNone, matchQueryCutoffFrequency = Nothing, matchQueryMatchType = Nothing, matchQueryAnalyzer = Nothing, matchQueryMaxExpansions = Nothing, matchQueryLenient = Nothing, matchQueryBoost = Nothing})), filterBody = Nothing, sortBody = Nothing, aggBody = Nothing, highlight = Nothing, trackSortScores = False, from = From 10, size = Size 100, searchType = SearchTypeQueryThenFetch, fields = Nothing, source = Nothing}
1060pageSearch :: From -- ^ The result offset
1061 -> Size -- ^ The number of results to return
1062 -> Search -- ^ The current seach
1063 -> Search -- ^ The paged search
1064pageSearch resultOffset pageSize search = search { from = resultOffset, size = pageSize }
1066parseUrl' :: MonadThrow m => Text -> m Request
1067parseUrl' t = parseRequest (URI.escapeURIString URI.isAllowedInURI (T.unpack t))
1069-- | Was there an optimistic concurrency control conflict when
1070-- indexing a document?
1071isVersionConflict :: Reply -> Bool
1072isVersionConflict = statusCheck (== 409)
1074isSuccess :: Reply -> Bool
1075isSuccess = statusCheck (inRange (200, 299))
1077isCreated :: Reply -> Bool
1078isCreated = statusCheck (== 201)
1080statusCheck :: (Int -> Bool) -> Reply -> Bool
1081statusCheck prd = prd . NHTS.statusCode . responseStatus
1083-- | This is a hook that can be set via the 'bhRequestHook' function
1084-- that will authenticate all requests using an HTTP Basic
1085-- Authentication header. Note that it is *strongly* recommended that
1086-- this option only be used over an SSL connection.
1088-- >> (mkBHEnv myServer myManager) { bhRequestHook = basicAuthHook (EsUsername "myuser") (EsPassword "mypass") }
1089basicAuthHook :: Monad m => EsUsername -> EsPassword -> Request -> m Request
1090basicAuthHook (EsUsername u) (EsPassword p) = return . applyBasicAuth u' p'
1091 where u' = T.encodeUtf8 u
1095boolQP :: Bool -> Text
1097boolQP False = "false"
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list