├── .devcontainer ├── devcontainer.json └── postCreateCommand.sh ├── .github ├── dependabot.yaml └── workflows │ ├── main.yml │ └── rubocop.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── docker ├── compose.yaml ├── elasticsearch-follow.yml ├── elasticsearch.yml └── elasticsearch8plus.yml ├── docs ├── README.md ├── bulk_indexing.md ├── client.md ├── cluster.md ├── docs.md ├── index.md ├── multi_search.md ├── notifications.md ├── scan_scroll.md ├── snapshots.md └── templates.md ├── elastomer-client.gemspec ├── lib └── elastomer_client │ ├── client.rb │ ├── client │ ├── bulk.rb │ ├── ccr.rb │ ├── cluster.rb │ ├── delete_by_query.rb │ ├── docs.rb │ ├── errors.rb │ ├── index.rb │ ├── multi_percolate.rb │ ├── multi_search.rb │ ├── native_delete_by_query.rb │ ├── nodes.rb │ ├── percolator.rb │ ├── reindex.rb │ ├── repository.rb │ ├── rest_api_spec.rb │ ├── rest_api_spec │ │ ├── api_spec.rb │ │ ├── api_spec_v5_6.rb │ │ ├── api_spec_v8_13.rb │ │ ├── api_spec_v8_17.rb │ │ ├── api_spec_v8_18.rb │ │ ├── api_spec_v8_7.rb │ │ └── rest_api.rb │ ├── scroller.rb │ ├── snapshot.rb │ ├── tasks.rb │ ├── template.rb │ └── update_by_query.rb │ ├── core_ext │ └── time.rb │ ├── middleware │ ├── compress.rb │ ├── encode_json.rb │ ├── limit_size.rb │ ├── opaque_id.rb │ └── parse_json.rb │ ├── notifications.rb │ ├── version.rb │ └── version_support.rb ├── script ├── bootstrap ├── console ├── generate-rest-api-spec ├── poll-for-es └── setup-ccr └── test ├── assertions.rb ├── client ├── bulk_test.rb ├── ccr_test.rb ├── cluster_test.rb ├── docs_test.rb ├── errors_test.rb ├── index_test.rb ├── multi_percolate_test.rb ├── multi_search_test.rb ├── native_delete_by_query_test.rb ├── nodes_test.rb ├── percolator_test.rb ├── reindex_test.rb ├── repository_test.rb ├── rest_api_spec │ ├── api_spec_test.rb │ └── rest_api_test.rb ├── scroller_test.rb ├── snapshot_test.rb ├── stubbed_client_test.rb ├── tasks_test.rb ├── template_test.rb └── update_by_query_test.rb ├── client_test.rb ├── core_ext └── time_test.rb ├── middleware ├── encode_json_test.rb ├── opaque_id_test.rb └── parse_json_test.rb ├── mock_response.rb ├── notifications_test.rb ├── test_helper.rb └── version_support_test.rb /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/universal 3 | { 4 | "name": "github/elastomer-client", 5 | "image": "mcr.microsoft.com/devcontainers/ruby:0-3-bullseye", 6 | 7 | // Features to add to the dev container. More info: https://containers.dev/features. 8 | "features": { 9 | "ghcr.io/devcontainers/features/docker-in-docker:2": {}, 10 | "ghcr.io/meaningful-ooo/devcontainer-features/homebrew:2": {}, 11 | "ghcr.io/guiyomh/features/vim:0": {}, 12 | "ghcr.io/jungaretti/features/ripgrep:1": {} 13 | }, 14 | 15 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 16 | // "forwardPorts": [], 17 | 18 | // Use 'postCreateCommand' to run commands after the container is created. 19 | "postCreateCommand": ".devcontainer/postCreateCommand.sh", 20 | 21 | // Configure tool-specific properties. 22 | "customizations": { 23 | "vscode": { 24 | "extensions": [ 25 | "github.copilot", 26 | "misogi.ruby-rubocop", 27 | "mutantdino.resourcemonitor", 28 | "rebornix.ruby", 29 | "wingrunr21.vscode-ruby", 30 | "eamodio.gitlens", 31 | "miguel-savignano.ruby-symbols", 32 | "KoichiSasada.vscode-rdbg" 33 | ], 34 | "settings": { 35 | "files.watcherExclude": { 36 | "**/vendor": true, 37 | "**/.git": true, 38 | "**/tmp": true 39 | } 40 | } 41 | } 42 | } 43 | 44 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 45 | // "remoteUser": "root" 46 | } 47 | -------------------------------------------------------------------------------- /.devcontainer/postCreateCommand.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | script/bootstrap 5 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: 'bundler' 5 | directory: '/' 6 | schedule: 7 | interval: 'weekly' 8 | - package-ecosystem: 'github-actions' 9 | directory: '/' 10 | schedule: 11 | interval: 'weekly' 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby-version: ['3.2'] 15 | ES_VERSION: ['8.18.0'] 16 | include: 17 | - ES_VERSION: '8.18.0' 18 | ES_DOWNLOAD_URL: >- 19 | https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-8.18.0-linux-x86_64.tar.gz 20 | steps: 21 | - uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby-version }} 24 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 25 | - uses: actions/checkout@v4 26 | - name: Cache Elasticsearch 27 | id: cache-elasticsearch 28 | uses: actions/cache@v4 29 | env: 30 | cache-name: cache-elasticsearch 31 | with: 32 | path: ./elasticsearch-${{ matrix.ES_VERSION }} 33 | key: ${{ env.cache-name }}-${{ matrix.ES_VERSION }} 34 | - if: ${{ steps.cache-elasticsearch.outputs.cache-hit != 'true' }} 35 | name: Download Elasticsearch 36 | run: | 37 | wget ${{ matrix.ES_DOWNLOAD_URL }} 38 | tar -xzf elasticsearch-*.tar.gz 39 | - if: ${{ matrix.ES_VERSION != '5.6.15' }} 40 | name: Run Elasticsearch 41 | run: './elasticsearch-${{ matrix.ES_VERSION }}/bin/elasticsearch -d -Expack.security.enabled=false' 42 | - run: gem install bundler 43 | - run: bundle install 44 | - run: script/poll-for-es 45 | - run: bundle exec rake test 46 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Rubocop 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: '3.2' 14 | bundler-cache: true 15 | - run: bundle exec rubocop 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /vendor/elasticsearch 3 | /vendor/gems 4 | /.bundle 5 | /.rbenv-version 6 | /vendor/cache/*.gem 7 | /coverage 8 | Gemfile.lock 9 | *.gem 10 | tags 11 | .byebug_history 12 | *.swp 13 | /tmp 14 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Ruby linting configuration. 2 | # We only worry about two kinds of issues: 'error' and anything less than that. 3 | # Error is not about severity, but about taste. Simple style choices that 4 | # never have a great excuse to be broken, such as 1.9 JSON-like hash syntax, 5 | # are errors. Choices that tend to have good exceptions in practice, such as 6 | # line length, are warnings. 7 | 8 | # If you'd like to make changes, a full list of available issues is at 9 | # https://github.com/bbatsov/rubocop/blob/master/config/enabled.yml 10 | # A list of configurable issues is at: 11 | # https://github.com/bbatsov/rubocop/blob/master/config/default.yml 12 | # 13 | # If you disable a check, document why. 14 | 15 | inherit_gem: 16 | rubocop-github: 17 | - config/default.yml # generic Ruby rules and cops 18 | 19 | require: 20 | - rubocop-minitest 21 | - rubocop-performance 22 | - rubocop-rake 23 | 24 | AllCops: 25 | NewCops: enable 26 | Exclude: 27 | - 'lib/elastomer_client/client/rest_api_spec/api_spec_*.rb' # Exclude generated ApiSpec files 28 | - 'vendor/**/*' 29 | 30 | Metrics/MethodLength: 31 | Max: 25 -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 6.2.3 (2025-06-06) 2 | - Added support for ES 8.17.2 3 | - Added support for ES 8.18.0 4 | 5 | ## 6.2.2 (2025-02-19) 6 | - Add support for manually and automatically following indices for cross-cluster replication 7 | 8 | ## 6.2.1 (2024-12-10) 9 | - Add support for rethrottling reindex tasks 10 | 11 | ## 6.2.0 (2024-11-06) 12 | - Add support for reindex API 13 | - Remove CI checks for ES 5.6.15 14 | 15 | ## 6.1.1 (2024-06-05) 16 | - Unlock faraday_middleware version to allow 1.x 17 | 18 | ## 6.1.0 (2024-04-09) 19 | - Replace `Faraday::Error::*` with `Faraday::*` error classes 20 | - Handle all `Faraday::Error` instead of `Faraday::Error::ClientError` 21 | - Unlock faraday version to allow 1.0.0 22 | - Bump the minimum version of faraday and faraday middleware 23 | 24 | ## 6.0.3 (2024-04-05) 25 | - Add ES 8.13 REST API spec 26 | - Update CI and development versions from 8.7.0 to 8.13.0 27 | 28 | ## 6.0.2 (2024-01-24) 29 | - Change Opaque ID error handling to not throw OpaqueIDError for 5xx responses 30 | 31 | ## 6.0.1 (2024-01-09) 32 | - Move Opaque ID middleware to the end of the Faraday middleware chain 33 | 34 | ## 6.0.0 (2023-12-08) 35 | - Remove default retry logic, requiring consumers to implement their own 36 | - Remove support for ES 7 37 | - Move to support only the latest Ruby version 38 | 39 | ## 5.2.0 (2023-11-07) 40 | - Allow passing a Faraday connection configuration block to the client. 41 | 42 | ## 5.1.0 (2023-09-29) 43 | - Remove logic extracting parameters from document in bulk requests. Parameters now must be sent separately from the document to be parsed correctly. 44 | 45 | ## 5.0.5 (2023-08-08) 46 | - Replace usage of "found" field by "result" in tests for the delete API (#275) 47 | - Reduce the noise of the client during an inspect call by hiding connection info (#276) 48 | - Rename MiniTest to Minitest (#277) 49 | 50 | ## 5.0.4 (2023-06-20) 51 | - Remove support for `timestamp` and `ttl` index parameters 52 | 53 | ## 5.0.3 (2023-06-14) 54 | - Allow deprecated underscored parameters to work for Bulk API for versions ES 7+. 55 | - Allow non-underscored parameters to work for Bulk API for version ES 5. 56 | 57 | ## 5.0.2 (2023-05-31) 58 | - Add ES 8.7 REST API spec 59 | - Remove deprecated `type` parameter from `search_shards` API 60 | 61 | ## 5.0.1 (2023-04-26) 62 | - Fix bug in bulk API preventing string `_id` from being removed if empty 63 | - Remove `_type` from document body during bulk requests for versions ES 7+ 64 | 65 | ## 5.0.0 (2023-04-17) 66 | - Rename Elastomer to ElastomerClient (and elastomer to elastomer_client) 67 | 68 | ## 4.0.3 (2023-04-07) 69 | - Fix query values specified in path get removed when query values are specified with params (#261) 70 | - Add support for `update_by_query` (#263) 71 | 72 | ## 4.0.2 (2023-03-03) 73 | - Fix ES 7+ handling of params like `routing` that were prefixed with an underscore in earlier versions 74 | 75 | ## 4.0.1 (2023-02-10) 76 | - Fix a bug in the bulk API interface that prevents a version check from working correctly 77 | 78 | ## 4.0.0 (2023-02-10) 79 | - Add ES 7 and ES 8 compatibility for existing functionality 80 | - Remove ES 2 support 81 | - Add support for newer Ruby versions (3.0, 3.1, 3.2) 82 | - Remove support for older Ruby versions (< 3.0) 83 | 84 | ## 3.2.3 (2020-02-26) 85 | - Fix warnings in Ruby 2.7 whan passing a Hash to a method that is expecting keyword arguments 86 | 87 | ## 3.2.2 (2020-02-25) 88 | - Update Webmock to ~> 3.5 to support Ruby 2.6+ (#222) 89 | 90 | ## 3.2.1 (2019-08-27) 91 | - Ignore basic_auth unless username and password are present 92 | 93 | ## 3.2.0 (2019-08-22) 94 | - Add config based basic and token auth to `Elastomer::Client#connection` 95 | - Filter `Elastomer::Client#inspect` output to hide basic and token auth info, 96 | and reduce noisiness when debugging 97 | 98 | ## 3.1.5 (2019-06-26) 99 | - Add new more granular exception type 100 | 101 | ## 3.1.1 (2018-02-24) 102 | - Output opaque ID information when a conflict is detected 103 | - Updating the `semantic` gem 104 | 105 | ## 3.1.0 (2018-01-19) 106 | - Added the `strict_params` flag for enforcing params passed to the REST API 107 | - Added the `RestApiSpec` module and classes for enforcing strict params 108 | 109 | ## 3.0.1 (2017-12-20) 110 | - Fixed argument passing to `app_delete_by_query` 111 | - Explicitly close scroll search contexts when scroll is complete 112 | 113 | ## 3.0.0 (2017-12-15) 114 | - Fixed swapped args in {Client,Index}#multi\_percolate count calls using block API 115 | - Support for Elasticsearch 5.x 116 | - Uses Elasticsearch's built-in `_delete_by_query` when supported 117 | - GET and HEAD requests are retried when possible 118 | - Add support for `_tasks` API 119 | - Replace `scan` queries with `scroll` sorted by `doc_id` 120 | 121 | ## 2.3.0 (2017-11-29) 122 | - Remove Elasticsearch 1.x and earlier code paths 123 | - Fix CI and configure an Elasticsearch 5.6 build 124 | 125 | ## 2.2.0 (2017-04-29) 126 | - Added a `clear_scroll` API 127 | - JSON timestamps include milliseconds by default 128 | - Removing Fixnum usage 129 | 130 | ## 2.1.1 (2016-09-02) 131 | - Bulk index only rejects documents larger than the maximum request size 132 | 133 | ## 2.1.0 (2016-01-02) 134 | - Added enforcement of maximum request size 135 | - Added some exception wrapping 136 | 137 | ## 2.0.1 (2016-09-01) 138 | - Fix bug in delete by query when routing is required 139 | 140 | ## 2.0.0 (2016-08-25) 141 | - Support Elasticsearch 2 142 | 143 | ## 0.9.0 (2016-02-26) 144 | - Adding support for the `/_suggest` API endpoint 145 | - Documentation cleanup - thank you Matt Wagner @wags 146 | 147 | ## 0.8.1 (2015-11-04) 148 | - Replace yanked 0.8.0 149 | - Fix code style based on Rubocop recommendations 150 | 151 | ## 0.8.0 (2015-09-23) yanked due to invalid build 152 | - BREAKING: Remove `Client#warmer` method 153 | - Add the Percolate API 154 | 155 | ## 0.7.0 (2015-09-18) 156 | - Add streaming bulk functionality via `bulk_stream_items` 157 | - Make Delete by Query compatible with Elasticsearch 2.0 158 | 159 | ## 0.6.0 (2015-09-11) 160 | - Support all URL parameters when using `Client.#scroll` 161 | - BREAKING: Moved some `Scroller` reader methods into `Scroller.opts` 162 | 163 | ## 0.5.1 (2015-04-03) 164 | - Add response body to notification payload 165 | 166 | ## 0.5.0 (2015-01-21) 167 | - BREAKING: rename action.available notification to action.ping 168 | - Index Component 169 | - client.index no longer requires a name 170 | - Documents Component 171 | - client.docs no longer requires an index name 172 | - added an `exists?` method 173 | - added `termvector` and `multi_termvectors` methods 174 | - added a `search_shards` method 175 | - added an `mget` alias for `multi_get` 176 | - Adding more documentation 177 | - Rename client.available? to client.ping (aliased as available?) 178 | - Updating tests to pass with ES 1.4.X 179 | - Enabling regular scroll queries vi `Client#scroll` 180 | 181 | ## 0.4.1 (2014-10-14) 182 | - Support for index `_recovery` endpoint 183 | - Fix Faraday 0.8 support 184 | - Wrap all Faraday exceptions 185 | - Correctly wrap single-command reroute with a command array 186 | 187 | ## 0.4.0 (2014-10-08) 188 | - BREAKING: docs.add alias for docs.index removed 189 | - BREAKING: Faraday exceptions are now raised as Elastomer exceptions 190 | - Repository and snapshot support 191 | - Support cluster state filtering on 1.x 192 | - Support node stats filtering on 1.x 193 | - New apis: cluster stats, cluster pending\_tasks 194 | - Support single-index alias get, add, delete 195 | 196 | ## 0.3.3 (2014-08-18) 197 | - Allow symbols as parameter values #67 198 | 199 | ## 0.3.2 (2014-07-02) 200 | - Make underscore optional in bulk params #66 201 | 202 | ## 0.3.1 (2014-06-24) 203 | - First rubygems release 204 | - Make `update_aliases` more flexible 205 | - Add `Client#semantic_version` for ES version comparisons 206 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | group :development do 8 | gem "activesupport", ">= 7.0" 9 | gem "bundler", "~> 2.0" 10 | gem "debug", "~> 1.7" 11 | gem "minitest", "~> 5.17" 12 | gem "minitest-focus", "~> 1.3" 13 | gem "rake" 14 | gem "rubocop", "~> 1.63.0" 15 | gem "rubocop-github", "~> 0.20.0" 16 | gem "rubocop-minitest", "~> 0.35.0" 17 | gem "rubocop-performance", "~> 1.21.0" 18 | gem "rubocop-rake", "~> 0.6.0" 19 | gem "simplecov", require: false 20 | gem "spy", "~> 1.0" 21 | gem "webmock", "~> 3.5" 22 | end 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 GitHub Inc. 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ElastomerClient [![CI build Workflow](https://github.com/github/elastomer-client/actions/workflows/main.yml/badge.svg)](https://github.com/github/elastomer-client/actions/workflows/main.yml) 2 | 3 | Making a stupid simple Elasticsearch client so your project can be smarter! 4 | 5 | ## Client 6 | 7 | The client provides a one-to-one mapping to the Elasticsearch [API 8 | endpoints](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html). 9 | The API is decomposed into logical sections and accessed according to what you 10 | are trying to accomplish. Each logical section is represented as a [client 11 | class](lib/elastomer_client/client) and a top-level accessor is provided for each. 12 | 13 | #### Cluster 14 | 15 | API endpoints dealing with cluster level information and settings are found in 16 | the [Cluster](lib/elastomer_client/client/cluster.rb) class. 17 | 18 | ```ruby 19 | require 'elastomer_client/client' 20 | client = ElastomerClient::Client.new 21 | 22 | # the current health summary 23 | client.cluster.health 24 | 25 | # detailed cluster state information 26 | client.cluster.state 27 | 28 | # the list of all index templates 29 | client.cluster.templates 30 | ``` 31 | 32 | #### Index 33 | 34 | The methods in the [Index](lib/elastomer_client/client/index.rb) class deal with the 35 | management of indexes in the cluster. This includes setting up type mappings 36 | and adjusting settings. The actual indexing and search of documents are 37 | handled by the Docs class (discussed next). 38 | 39 | ```ruby 40 | require 'elastomer_client/client' 41 | client = ElastomerClient::Client.new 42 | 43 | index = client.index('books') 44 | index.create( 45 | :settings => { 'index.number_of_shards' => 3 }, 46 | :mappings => { 47 | :_source => { :enabled => true }, 48 | :properties => { 49 | :author => { :type => 'keyword' }, 50 | :title => { :type => 'text' } 51 | } 52 | } 53 | ) 54 | 55 | index.exists? 56 | 57 | index.delete 58 | ``` 59 | 60 | #### Docs 61 | 62 | The [Docs](lib/elastomer_client/client/docs.rb) class handles the indexing and 63 | searching of documents. Each instance is scoped to an index and optionally a 64 | document type. 65 | 66 | ```ruby 67 | require 'elastomer_client/client' 68 | client = ElastomerClient::Client.new 69 | 70 | docs = client.docs('books') 71 | 72 | docs.index({ 73 | :_id => 1, 74 | :author => 'Mark Twain', 75 | :title => 'The Adventures of Huckleberry Finn' 76 | }) 77 | 78 | docs.search({:query => {:match_all => {}}}) 79 | ``` 80 | 81 | #### Performance 82 | 83 | By default ElastomerClient uses Net::HTTP (via Faraday) to communicate with 84 | Elasticsearch. You may find that Excon performs better for your use. To enable 85 | Excon, add it to your bundle and then change your ElastomerClient initialization 86 | thusly: 87 | 88 | ```ruby 89 | ElastomerClient::Client.new(url: YOUR_ES_URL, adapter: :excon) 90 | ``` 91 | 92 | #### Retries 93 | 94 | You can add retry logic to your Elastomer client connection using Faraday's Retry middleware. The `ElastomerClient::Client.new` method can accept a block, which you can use to customize the Faraday connection. Here's an example: 95 | 96 | ```ruby 97 | retry_options = { 98 | max: 2, 99 | interval: 0.05, 100 | methods: [:get] 101 | } 102 | 103 | ElastomerClient::Client.new do |connection| 104 | connection.request :retry, retry_options 105 | end 106 | ``` 107 | 108 | ## Compatibility 109 | 110 | This client is tested against: 111 | 112 | - Ruby version 3.2 113 | - Elasticsearch versions: 114 | - 5.6 115 | - 8.13 116 | - 8.18 117 | 118 | ## Development 119 | 120 | Get started by cloning and running a few scripts: 121 | 122 | - [ElastomerClient ](#elastomerclient-) 123 | - [Client](#client) 124 | - [Cluster](#cluster) 125 | - [Index](#index) 126 | - [Docs](#docs) 127 | - [Performance](#performance) 128 | - [Compatibility](#compatibility) 129 | - [Development](#development) 130 | - [Bootstrap the project](#bootstrap-the-project) 131 | - [Start an Elasticsearch server in Docker](#start-an-elasticsearch-server-in-docker) 132 | - [Run tests against a version of Elasticsearch](#run-tests-against-a-version-of-elasticsearch) 133 | - [Releasing](#releasing) 134 | 135 | ### Bootstrap the project 136 | 137 | ``` 138 | script/bootstrap 139 | ``` 140 | 141 | ### Start an Elasticsearch server in Docker 142 | 143 | To run ES 5 and ES 8: 144 | ``` 145 | docker compose --project-directory docker --profile all up 146 | ``` 147 | 148 | To run in ES8 cross cluster replication mode: 149 | ``` 150 | script/setup-ccr up "{non-production license}" 151 | ``` 152 | 153 | To run only ES 8: 154 | ``` 155 | docker compose --project-directory docker --profile es8 up 156 | ``` 157 | 158 | To run only ES 5: 159 | ``` 160 | docker compose --project-directory docker --profile es5 up 161 | ``` 162 | 163 | ### Run tests against a version of Elasticsearch 164 | 165 | ES 8 166 | ``` 167 | ES_PORT=9208 rake test 168 | ``` 169 | 170 | CCR tests: 171 | ``` 172 | ES_PORT=9208 ES_REPLICA_PORT=9209 rake test 173 | ``` 174 | 175 | ES 5 176 | ``` 177 | ES_PORT=9205 rake test 178 | ``` 179 | 180 | ## Releasing 181 | 182 | 1. Create a new branch from `main` 183 | 1. Bump the version number in `lib/elastomer/version.rb` 184 | 1. Update `CHANGELOG.md` with info about the new version 185 | 1. Commit your changes and tag the commit with a version number starting with the prefix "v" e.g. `v4.0.2` 186 | 1. Execute `rake build`. This will place a new gem file in the `pkg/` folder. 187 | 1. Run `gem install pkg/elastomer-client-{VERSION}.gem` to install the new gem locally 188 | 1. Start an `irb` session, `require "elastomer/client"` and make sure things work as you expect 189 | 1. Once everything is working as you expect, push both your commit and your tag, and open a pull request 190 | 1. Request review from a maintainer and wait for the pull request to be approved. Once it is approved, you can merge it to `main` yourself. After that, pull down a fresh copy of `main` and then... 191 | 1. [Optional] If you intend to release a new version to Rubygems, run `rake release` 192 | 1. [Optional] If necessary, manually push the new version to rubygems.org 193 | 1. 🕺 💃 🎉 194 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | require "rake/testtask" 6 | 7 | Rake::TestTask.new do |t| 8 | t.test_files = FileList["test/**/*_test.rb"] 9 | end 10 | 11 | task default: :test 12 | 13 | namespace :actions do 14 | desc "list valid actions" 15 | task :list do 16 | # there are two distinct :action declarations we need to find 17 | # the regular expressions below capture both 18 | # 19 | # [:action] = 'some.value' 20 | # :action => 'some.value' 21 | # 22 | list = %x(grep '\\[\\?:action\\]\\?\\s\\+=' `find lib -name '*.rb'`).split("\n") 23 | list.map! do |line| 24 | m = /\A.*?\[?:action\]?\s+=>?\s+'(.*?)'.*\Z/.match line 25 | m.nil? ? nil : m[1] 26 | end 27 | 28 | list.compact.sort.uniq.each do |action| 29 | STDOUT.puts "- #{action}" 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /docker/compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | elasticsearch8.18: 5 | image: docker.elastic.co/elasticsearch/elasticsearch:8.18.0 6 | container_name: es8.18 7 | profiles: ["es8", "ccr", "all"] 8 | environment: 9 | - cluster.name=elastomer8.18 10 | - bootstrap.memory_lock=true 11 | - discovery.type=single-node 12 | - xpack.security.enabled=false 13 | - xpack.watcher.enabled=false 14 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 15 | - node.roles=[master,data,remote_cluster_client] 16 | ulimits: 17 | memlock: 18 | soft: -1 19 | hard: -1 20 | nofile: 21 | soft: 65536 22 | hard: 65536 23 | mem_limit: 2g 24 | cap_add: 25 | - IPC_LOCK 26 | volumes: 27 | - esrepos8:/usr/share/elasticsearch/repos 28 | - ./elasticsearch8plus.yml:/usr/share/elasticsearch/config/elasticsearch.yml 29 | ports: 30 | - 127.0.0.1:${ES_8_PORT:-9208}:9200 31 | 32 | elasticsearchFollower: 33 | image: docker.elastic.co/elasticsearch/elasticsearch:8.18.0 34 | container_name: es-follow 35 | profiles: ["ccr"] 36 | environment: 37 | - cluster.name=es-follow 38 | - bootstrap.memory_lock=true 39 | - discovery.type=single-node 40 | - xpack.security.enabled=false 41 | - xpack.watcher.enabled=false 42 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 43 | - node.roles=[master,data,remote_cluster_client] 44 | ulimits: 45 | memlock: 46 | soft: -1 47 | hard: -1 48 | nofile: 49 | soft: 65536 50 | hard: 65536 51 | mem_limit: 2g 52 | cap_add: 53 | - IPC_LOCK 54 | volumes: 55 | - ./elasticsearch-follow.yml:/usr/share/elasticsearch/config/elasticsearch.yml 56 | ports: 57 | - 127.0.0.1:${ES_8_PORT:-9209}:9201 58 | 59 | elasticsearch5.6: 60 | image: docker.elastic.co/elasticsearch/elasticsearch:5.6.4 61 | container_name: es5.6 62 | profiles: ["es5", "all"] 63 | environment: 64 | - cluster.name=elastomer5.6 65 | - bootstrap.memory_lock=true 66 | - discovery.type=single-node 67 | - xpack.monitoring.enabled=false 68 | - xpack.security.enabled=false 69 | - xpack.watcher.enabled=false 70 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 71 | ulimits: 72 | memlock: 73 | soft: -1 74 | hard: -1 75 | nofile: 76 | soft: 65536 77 | hard: 65536 78 | mem_limit: 1g 79 | cap_add: 80 | - IPC_LOCK 81 | volumes: 82 | - esrepos5:/usr/share/elasticsearch/repos 83 | - ./elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml 84 | ports: 85 | - 127.0.0.1:${ES_5_PORT:-9205}:9200 86 | 87 | volumes: 88 | esrepos8: 89 | driver: local 90 | driver_opts: 91 | device: tmpfs 92 | type: tmpfs 93 | o: size=100m,uid=102,gid=102 94 | esrepos5: 95 | driver: local 96 | driver_opts: 97 | device: tmpfs 98 | type: tmpfs 99 | o: size=100m,uid=102,gid=102 100 | -------------------------------------------------------------------------------- /docker/elasticsearch-follow.yml: -------------------------------------------------------------------------------- 1 | cluster.name: "es-follow" 2 | 3 | network.host: 0.0.0.0 4 | 5 | path: 6 | data: /usr/share/elasticsearch/data 7 | logs: /usr/share/elasticsearch/logs 8 | repo: /usr/share/elasticsearch/repos 9 | 10 | transport.port: 9301 11 | http.port: 9201 12 | remote_cluster.port: 9444 13 | http.max_content_length: 50mb 14 | ingest.geoip.downloader.enabled: false 15 | -------------------------------------------------------------------------------- /docker/elasticsearch.yml: -------------------------------------------------------------------------------- 1 | cluster.name: "docker-cluster" 2 | 3 | network.host: 0.0.0.0 4 | 5 | discovery.zen.minimum_master_nodes: 1 6 | 7 | path: 8 | data: /usr/share/elasticsearch/data 9 | logs: /usr/share/elasticsearch/logs 10 | repo: /usr/share/elasticsearch/repos 11 | 12 | transport.tcp.port: 9300 13 | http.port: 9200 14 | http.max_content_length: 50mb 15 | 16 | -------------------------------------------------------------------------------- /docker/elasticsearch8plus.yml: -------------------------------------------------------------------------------- 1 | cluster.name: "docker-cluster" 2 | 3 | network.host: 0.0.0.0 4 | 5 | path: 6 | data: /usr/share/elasticsearch/data 7 | logs: /usr/share/elasticsearch/logs 8 | repo: /usr/share/elasticsearch/repos 9 | 10 | transport.port: 9300 11 | http.port: 9200 12 | remote_cluster.port: 9443 13 | http.max_content_length: 50mb 14 | ingest.geoip.downloader.enabled: false 15 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # ElastomerClient in Depth 2 | 3 | We first started building the ElastomerClient gem when an 4 | [official client](https://github.com/elasticsearch/elasticsearch-ruby) 5 | was not yet available from Elasticsearch. We were looking for a client that 6 | provided a one-to-one mapping of the Elasticsearch APIs and avoided higher level 7 | complexity such as connection pooling, round-robin connections, thrift support, 8 | and the like. We think these things are better handled at different layers and 9 | by other software libraries. 10 | 11 | Our goal is to keep our Elasticsearch client simple and then compose 12 | higher level functionality from smaller components. This is the UNIX philosophy 13 | in action. 14 | 15 | To that end we have tried to be as faithful as possible to the Elasticsearch API 16 | with our implementation. There are a few places where it made sense to wrap the 17 | Elasticsearch API inside Ruby idioms. One notable location is the 18 | [scan-scroll](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-scroll.html) 19 | search type; the ElastomerClient provides a Ruby iterator to work with these 20 | types of queries. 21 | 22 | Below are links to documents describing the various components of the ElastomerClient 23 | Client library. Start with the core components - specifically the **Client** 24 | document. All the other components are built atop the client. 25 | 26 | **Core Components** 27 | 28 | * [Client](client.md) 29 | * [Index](index.md) 30 | * [Documents](docs.md) 31 | * [Cluster](cluster.md) 32 | * [Templates](templates.md) 33 | 34 | **Bulk Components** 35 | 36 | * [Bulk Indexing](bulk_indexing.md) 37 | * [Multi-Search](multi_search.md) 38 | * [Scan/Scroll](scan_scroll.md) 39 | 40 | **Operational Components** 41 | 42 | * [Snapshots](snapshots.md) 43 | * [Notifications](notifications.md) 44 | -------------------------------------------------------------------------------- /docs/bulk_indexing.md: -------------------------------------------------------------------------------- 1 | # ElastomerClient Bulk Indexing Component 2 | 3 | ![constructocat](https://octodex.github.com/images/constructocat2.jpg) 4 | -------------------------------------------------------------------------------- /docs/cluster.md: -------------------------------------------------------------------------------- 1 | # ElastomerClient Cluster Component 2 | 3 | The cluster component deals with commands for managing cluster state and 4 | monitoring cluster health. All the commands found under the 5 | [cluster API](https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster.html) 6 | section of the Elasticsearch documentation are implemented by the 7 | [`cluster.rb`](https://github.com/github/elastomer-client/blob/main/lib/elastomer_client/client/cluster.rb) 8 | module and the [`nodes.rb`](https://github.com/github/elastomer-client/blob/main/lib/elastomer_client/client/nodes.rb) 9 | module. 10 | 11 | ## Cluster 12 | 13 | API endpoints dealing with cluster level information and settings are found in 14 | the [`Cluster`](lib/elastomer_client/client/cluster.rb) class. Each of these methods 15 | corresponds to an API endpoint described in the Elasticsearch documentation 16 | (linked to above). The params listed in the documentation can be passed to these 17 | methods, so we do not take too much trouble to enumerate them all. 18 | 19 | #### health 20 | 21 | The cluster [health API](https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-health.html) 22 | returns a very simple cluster health status report. 23 | 24 | ```ruby 25 | require 'elastomer_client/client' 26 | client = ElastomerClient::Client.new :port => 9200 27 | 28 | # the current health summary 29 | client.cluster.health 30 | ``` 31 | 32 | You can wait for a *yellow* status. 33 | 34 | ```ruby 35 | client.cluster.health \ 36 | :wait_for_status => "yellow", 37 | :timeout => "10s", 38 | :read_timeout => 12 39 | ``` 40 | 41 | And you can request index level health details. The default timeout for the 42 | health endpoint is 30 seconds; hence, we set our read timeout to 32 seconds. 43 | 44 | ```ruby 45 | client.cluster.health \ 46 | :level => "indices", 47 | :read_timeout => 32 48 | ``` 49 | 50 | #### state & stats 51 | 52 | If you need something more than basic health information, then the 53 | [`state`](https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-state.html) 54 | and [`stats`](https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-stats.html) 55 | endpoints are the next methods to call. Please look through the API 56 | documentation linked to above for all the details. And you can play with these 57 | endpoints via an IRB session. 58 | 59 | ```ruby 60 | # detailed cluster state information 61 | client.cluster.state 62 | 63 | # cluster wide statistics 64 | client.cluster.stats 65 | ``` 66 | 67 | #### settings 68 | 69 | Cluster behavior is controlled via the 70 | [settings API](https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-update-settings.html). 71 | The settings can be retrieved, and some settings can be modified at runtime to 72 | control shard allocations, routing, index replicas, and so forth. For example, 73 | when performing a [rolling restart](https://www.elastic.co/guide/en/elasticsearch/guide/current/_rolling_restarts.html) 74 | of a cluster, disabling shard allocation between restarts can reduce the 75 | cluster recovery time. 76 | 77 | ```ruby 78 | # disable all shard allocation 79 | client.cluster.update_settings :transient => { 80 | "cluster.routing.allocation.enable" => "none" 81 | } 82 | 83 | # shutdown the local node 84 | client.nodes('_local').shutdown 85 | 86 | # restart the local node and wait for it to rejoin the cluster 87 | 88 | # re-enable shard allocation 89 | client.cluster.update_settings :transient => { 90 | "cluster.routing.allocation.enable" => "all" 91 | } 92 | ``` 93 | 94 | #### extras 95 | 96 | We've added a few extras to the `cluster` module purely for convenience. These 97 | are not API mappings; they are requests we frequently make from our 98 | applications. 99 | 100 | ```ruby 101 | # the list of all index templates 102 | client.cluster.templates 103 | 104 | # list all the indices in the cluster 105 | client.cluster.indices 106 | 107 | # list all nodes that are currently part of the cluster 108 | client.cluster.nodes 109 | ``` 110 | 111 | Using these methods we can quickly get the names of all the indices in the 112 | cluster. The `indices` method returns a hash of the index settings keyed by the 113 | index name. 114 | 115 | ```ruby 116 | client.cluster.indices.keys 117 | ``` 118 | 119 | The same method can be used for getting all the template names, as well. 120 | 121 | ## Nodes 122 | 123 | There are also node level API methods that provide stats and information for 124 | individual (or multiple) nodes in the cluster. We expose these via the `nodes` 125 | module in elastomer-client. 126 | 127 | ```ruby 128 | require 'elastomer_client/client' 129 | client = ElastomerClient::Client.new :port => 9200 130 | 131 | # gather OS, JVM, and process information from the local node 132 | client.nodes("_local").info(:info => %w[os jvm process]) 133 | ``` 134 | 135 | More than one node can be queried at the same time. 136 | 137 | ```ruby 138 | client.nodes(%w[node-1.local node-2.local]).stats(:stats => %w[os process]) 139 | ``` 140 | 141 | Or you can query all nodes. 142 | 143 | ```ruby 144 | client.nodes("_all").stats(:stats => "fs") 145 | ``` 146 | 147 | Take a look at the source code documentation for all the API calls provided by 148 | elastomer-client. 149 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # ElastomerClient Index Component 2 | 3 | The index component provides access to the 4 | [indices API](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices.html) 5 | used for index management, settings, mappings, and aliases. Index 6 | [templates](templates.md) are handled via their own 7 | components. Methods for adding documents to the index and searching those 8 | documents are found in the [documents](documents.md) component. The index 9 | component deals solely with management of the indices themselves. 10 | 11 | Access to the index component is provided via the `index` method on the client. 12 | If you provide an index name then it will be used for all the API calls. 13 | However, you can omit the index name and pass it along with each API method 14 | called. 15 | 16 | ```ruby 17 | require 'elastomer_client/client' 18 | client = ElastomerClient::Client.new :port => 9200 19 | 20 | # you can provide an index name 21 | index = client.index "blog" 22 | index.status 23 | 24 | # or you can omit the index name and provide it with each API method call 25 | index = client.index 26 | index.status :index => "blog" 27 | index.status :index => "users" 28 | ``` 29 | 30 | You can operate on more than one index, too, by providing a list of index names. 31 | This is useful for maintenance operations on more than one index. 32 | 33 | ```ruby 34 | client.index(%w[blog users]).status 35 | client.index.status :index => %w[blog users] 36 | ``` 37 | 38 | Some operations do not make sense against multiple indices - index existence is a 39 | good example of this. If three indices are given it only takes one non-existent 40 | index for the response to be false. 41 | 42 | ```ruby 43 | client.index("blog").exists? #=> true 44 | client.index(%w[blog user]).exists? #=> true 45 | client.index(%w[blog user foo]).exists? #=> false 46 | ``` 47 | 48 | Let's take a look at some basic index operations. We'll be working with an 49 | imaginary "blog" index that contains standard blog post information. 50 | 51 | #### Create an Index 52 | 53 | Here we create a "blog" index that contains "post" documents. We pass the 54 | `:settings` for the index and the document type `:mappings` to the `create` 55 | method. 56 | 57 | ```ruby 58 | index = client.index "blog" 59 | index.create \ 60 | :settings => { 61 | :number_of_shards => 5, 62 | :number_of_replicas => 1 63 | }, 64 | :mappings => { 65 | :post => { 66 | :_all => { :enabled => false }, 67 | :_source => { :compress => true }, 68 | :properties => { 69 | :author => { :type => "string", :index => "not_analyzed" }, 70 | :title => { :type => "string" }, 71 | :body => { :type => "string" } 72 | } 73 | } 74 | } 75 | ``` 76 | 77 | Our "blog" index is created with 5 shards and a replication factor of 1. This 78 | gives us a total of 10 shards (5 primaries and 5 replicas). The "post" documents 79 | have an author, title, and body. 80 | 81 | #### Update Mappings 82 | 83 | It would be really nice to know when a blog post was created. We can use this in 84 | our search to limit results to recent blog posts. So let's add this information 85 | to our post document type. 86 | 87 | ```ruby 88 | index = client.index "blog" 89 | index.update_mapping :post, 90 | :post => { 91 | :properties => { 92 | :post_date => { :type => "date", :format => "dateOptionalTime" } 93 | } 94 | } 95 | ``` 96 | 97 | The `:post` type is given twice - once as a method argument, and once in the 98 | request body. This is an artifact of the Elasticsearch API. We could hide this 99 | wart, but the philosophy of the elastomer-client is to be as faithful to the API 100 | as possible. 101 | 102 | #### Analysis 103 | 104 | The [analysis](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis.html) 105 | process has the greatest impact on the relevancy of your search results. It is 106 | the process of decomposing text into searchable tokens. Understanding this 107 | process is important, and creating your own analyzers is as much an art form as 108 | it is science. 109 | 110 | Elasticsearch provides an [analyze](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-analyze.html) 111 | API for exploring the analysis process and return tokens. We can see how 112 | individual fields will analyze text. 113 | 114 | ```ruby 115 | index = client.index "blog" 116 | index.analyze "The Role of Morphology in Phoneme Prediction", 117 | :field => "post.title" 118 | ``` 119 | 120 | And we can explore the default analyzers provided by Elasticsearch. 121 | 122 | ```ruby 123 | client.index.analyze "The Role of Morphology in Phoneme Prediction", 124 | :analyzer => "snowball" 125 | ``` 126 | 127 | #### Index Maintenance 128 | 129 | A common practice when dealing with non-changing data sets (event logs) is to 130 | create a new index for each week or month. Only the current index is written to, 131 | and the older indices can be made read-only. Eventually, when it is time to 132 | expire the data, the older indices can be deleted from the cluster. 133 | 134 | Let's take a look at some simple event log maintenance using elastomer-client. 135 | 136 | ```ruby 137 | # the previous month's event log 138 | index = client.index "event-log-2014-09" 139 | 140 | # force merge the index to have only 1 segment file (expunges deleted documents) 141 | index.force merge \ 142 | :max_num_segments => 1, 143 | :wait_for_merge => true 144 | 145 | # block write operations to this index 146 | # and disable the bloom filter which is only used for indexing 147 | index.update_settings \ 148 | :index => { 149 | "blocks.write" => true, 150 | "codec.bloom.load" => false 151 | } 152 | ``` 153 | 154 | Now we have a nicely optimized event log index that can be searched but cannot 155 | be written to. Sometime in the future we can delete this index (but we should 156 | take a [snapshot](snapshots.md) first). 157 | 158 | ```ruby 159 | client.index("event-log-2014-09").delete 160 | ``` 161 | -------------------------------------------------------------------------------- /docs/multi_search.md: -------------------------------------------------------------------------------- 1 | # ElastomerClient Multi-Search Component 2 | 3 | ![constructocat](https://octodex.github.com/images/constructocat2.jpg) 4 | -------------------------------------------------------------------------------- /docs/notifications.md: -------------------------------------------------------------------------------- 1 | # Notifications Support 2 | 3 | Requiring `elastomer_client/notifications` enables support for broadcasting 4 | ElastomerClient events through ActiveSupport::Notifications. 5 | 6 | The event namespace is `request.client.elastomer`. 7 | 8 | ## Sample event payload 9 | 10 | ``` 11 | { 12 | :index => "index-test", 13 | :type => nil, 14 | :action => "docs.search", 15 | :context => nil, 16 | :body => "{\"query\":{\"match_all\":{}}}", 17 | :url => #, 18 | :method => :get, 19 | :status => 200 20 | } 21 | ``` 22 | 23 | ## Valid actions 24 | - bulk 25 | - cluster.get_aliases 26 | - cluster.get_settings 27 | - cluster.health 28 | - cluster.info 29 | - cluster.pending_tasks 30 | - cluster.ping 31 | - cluster.reroute 32 | - cluster.shutdown 33 | - cluster.state 34 | - cluster.stats 35 | - cluster.update_aliases 36 | - cluster.update_settings 37 | - docs.count 38 | - docs.delete 39 | - docs.delete_by_query 40 | - docs.exists 41 | - docs.explain 42 | - docs.get 43 | - docs.index 44 | - docs.multi_get 45 | - docs.multi_termvectors 46 | - docs.search 47 | - docs.search_shards 48 | - docs.source 49 | - docs.termvector 50 | - docs.update 51 | - docs.validate 52 | - index.add_alias 53 | - index.analyze 54 | - index.clear_cache 55 | - index.close 56 | - index.create 57 | - index.delete 58 | - index.delete_alias 59 | - index.exists 60 | - index.flush 61 | - index.get_alias 62 | - index.get_aliases 63 | - index.get_mapping 64 | - index.get_settings 65 | - index.open 66 | - index.forcemerge 67 | - index.recovery 68 | - index.refresh 69 | - index.segments 70 | - index.stats 71 | - index.update_mapping 72 | - index.update_settings 73 | - nodes.hot_threads 74 | - nodes.info 75 | - nodes.shutdown 76 | - nodes.stats 77 | - repository.create 78 | - repository.delete 79 | - repository.exists 80 | - repository.get 81 | - repository.status 82 | - repository.update 83 | - search.scroll 84 | - search.start_scroll 85 | - snapshot.create 86 | - snapshot.delete 87 | - snapshot.exists 88 | - snapshot.get 89 | - snapshot.restore 90 | - snapshot.status 91 | - template.create 92 | - template.delete 93 | - template.get 94 | -------------------------------------------------------------------------------- /docs/scan_scroll.md: -------------------------------------------------------------------------------- 1 | # ElastomerClient Scan/Scroll Component 2 | 3 | ![constructocat](https://octodex.github.com/images/constructocat2.jpg) 4 | -------------------------------------------------------------------------------- /docs/snapshots.md: -------------------------------------------------------------------------------- 1 | # ElastomerClient Snapshot Component 2 | 3 | ![constructocat](https://octodex.github.com/images/constructocat2.jpg) 4 | -------------------------------------------------------------------------------- /docs/templates.md: -------------------------------------------------------------------------------- 1 | # ElastomerClient Templates Component 2 | 3 | ![constructocat](https://octodex.github.com/images/constructocat2.jpg) 4 | -------------------------------------------------------------------------------- /elastomer-client.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # frozen_string_literal: true 3 | 4 | lib = File.expand_path("../lib", __FILE__) 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | require "elastomer_client/version" 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = "elastomer-client" 10 | spec.version = ElastomerClient::VERSION 11 | spec.authors = ["Tim Pease", "Grant Rodgers"] 12 | spec.email = ["tim.pease@github.com", "grant.rodgers@github.com"] 13 | spec.summary = %q{A library for interacting with Elasticsearch} 14 | spec.description = %q{ElastomerClient is a low level API client for the 15 | Elasticsearch HTTP interface.} 16 | spec.homepage = "https://github.com/github/elastomer-client" 17 | spec.license = "MIT" 18 | 19 | spec.files = `git ls-files -z`.split("\x0") 20 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 21 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 22 | spec.require_paths = ["lib"] 23 | 24 | spec.add_dependency "addressable", "~> 2.5" 25 | spec.add_dependency "faraday", ">= 0.17" 26 | spec.add_dependency "faraday_middleware", ">= 0.14" 27 | spec.add_dependency "multi_json", "~> 1.12" 28 | spec.add_dependency "semantic", "~> 1.6" 29 | end 30 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/ccr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Returns a CCR instance 7 | def ccr 8 | Ccr.new(self) 9 | end 10 | 11 | class Ccr 12 | # Create a new Ccr for initiating requests for cross cluster replication. 13 | # More context: https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-apis.html 14 | # 15 | # client - ElastomerClient::Client used for HTTP requests to the server 16 | def initialize(client) 17 | @client = client 18 | end 19 | 20 | attr_reader :client 21 | 22 | # Gets the parameters and status for each follower index. 23 | # 24 | # index_pattern - String name of the index pattern to get follower info for 25 | # params - Hash of query parameters 26 | # 27 | # Examples 28 | # 29 | # ccr.get_follower_info("follower_index") 30 | # ccr.get_follower_info("*") 31 | # 32 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-put-follow.html 33 | def get_follower_info(index_pattern, params = {}) 34 | response = client.get "/#{index_pattern}/_ccr/info", params.merge(action: "get_follower_info", rest_api: "ccr") 35 | response.body 36 | end 37 | 38 | # Creates a new follower index for the provided leader index on the remote cluster. 39 | # 40 | # follower_index - String name of the follower index to create 41 | # body - Hash of the request body 42 | # :remote_cluster - String name of the remote cluster. Required. 43 | # :leader_index - String name of the leader index. Required. 44 | # params - Hash of query parameters 45 | # 46 | # Examples 47 | # 48 | # ccr.follow("follower_index", { leader_index: "leader_index", remote_cluster: "leader" }) 49 | # 50 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-put-follow.html 51 | def follow(follower_index, body, params = {}) 52 | response = client.put "/#{follower_index}/_ccr/follow", params.merge(body:, action: "follow", rest_api: "ccr") 53 | response.body 54 | end 55 | 56 | # Creates a new auto-follow pattern for the provided remote cluster. 57 | # 58 | # pattern_name - String name of the auto-follow pattern to create 59 | # body - Hash of the request body 60 | # :remote_cluster - String name of the remote cluster. Required. 61 | # :leader_index_patterns - An array of simple index patterns to match against indices in the remote cluster 62 | # :leader_index_exclusion_patterns - An array of simple index patterns that can be used to exclude indices from being auto-followed. 63 | # :follow_index_pattern - The name of follower index. The template {{leader_index}} can be used to derive 64 | # the name of the follower index from the name of the leader index. 65 | # params - Hash of query parameters 66 | 67 | # Examples 68 | 69 | # ccr.auto_follow("follower_pattern", { remote_cluster: "leader", leader_index_patterns: ["leader_index*"], 70 | # follow_index_pattern: "{{leader_index}}-follower" }) 71 | 72 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-put-auto-follow-pattern.html 73 | 74 | def auto_follow(pattern_name, body, params = {}) 75 | response = client.put "/_ccr/auto_follow/#{pattern_name}", params.merge(body:, action: "create_auto_follow_pattern", rest_api: "ccr") 76 | response.body 77 | end 78 | 79 | # Deletes the auto-follow pattern for the provided remote cluster. 80 | # 81 | # pattern_name - String name of the auto-follow pattern to delete 82 | # params - Hash of query parameters 83 | # 84 | # Examples 85 | # 86 | # ccr.delete_auto_follow("follower_pattern") 87 | # 88 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-delete-auto-follow-pattern.html 89 | 90 | def delete_auto_follow(pattern_name, params = {}) 91 | response = client.delete "/_ccr/auto_follow/#{pattern_name}", params.merge(action: "delete_auto_follow_pattern", rest_api: "ccr") 92 | response.body 93 | end 94 | 95 | # Gets cross-cluster replication auto-follow patterns 96 | # 97 | # params - Hash of query parameters 98 | # :pattern_name - (Optional) String name of the auto-follow pattern. Returns all patterns if not specified 99 | # Examples 100 | # 101 | # ccr.get_auto_follow 102 | # 103 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-get-auto-follow-pattern.html 104 | 105 | def get_auto_follow(params = {}) 106 | response = client.get "/_ccr/auto_follow{/pattern_name}", params.merge(action: "get_auto_follow_pattern", rest_api: "ccr") 107 | response.body 108 | end 109 | 110 | # Pauses a follower index. 111 | # 112 | # follower_index - String name of the follower index to pause 113 | # params - Hash of the request body 114 | # 115 | # Examples 116 | # ccr.pause_follow("follower_index") 117 | # 118 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-post-pause-follow.html 119 | def pause_follow(follower_index, params = {}) 120 | response = client.post "/#{follower_index}/_ccr/pause_follow", params.merge(action: "pause_follow", rest_api: "ccr") 121 | response.body 122 | end 123 | 124 | # Unfollows a leader index given a follower index. 125 | # The follower index must be paused and closed before unfollowing. 126 | # 127 | # follower_index - String name of the follower index to unfollow 128 | # params - Hash of the request body 129 | # 130 | # Examples 131 | # ccr.unfollow("follower_index") 132 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-post-unfollow.html 133 | def unfollow(follower_index, params = {}) 134 | response = client.post "/#{follower_index}/_ccr/unfollow", params.merge(action: "unfollow", rest_api: "ccr") 135 | response.body 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/delete_by_query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | # Execute delete_by_query using the native _delete_by_query API if supported 6 | # or the application-level implementation. 7 | # 8 | # Warning: These implementations have different parameters and return types. 9 | # If you want to use one or the other consistently, use ElastomerClient::Client#native_delete_by_query 10 | # or ElastomerClient::Client#app_delete_by_query directly. 11 | def delete_by_query(query, params = {}) 12 | send(:native_delete_by_query, query, params) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | 5 | # Parent class for all ElastomerClient errors. 6 | Error = Class.new StandardError 7 | 8 | class Client 9 | 10 | # General error response from client requests. 11 | class Error < ::ElastomerClient::Error 12 | 13 | # Construct a new Error from the given response object or a message 14 | # String. If a response object is given, the error message will be 15 | # extracted from the response body. 16 | # 17 | # response - Faraday::Response object or a simple error message String 18 | # 19 | def initialize(*args) 20 | @status = nil 21 | @error = nil 22 | 23 | case args.first 24 | when Exception 25 | exception = args.shift 26 | super("#{exception.message} :: #{args.join(' ')}") 27 | set_backtrace exception.backtrace 28 | 29 | when Faraday::Response 30 | response = args.shift 31 | @status = response.status 32 | 33 | body = response.body 34 | @error = body["error"] if body.is_a?(Hash) && body.key?("error") 35 | 36 | message = @error || body.to_s 37 | super(message) 38 | 39 | else 40 | super(args.join(" ")) 41 | end 42 | end 43 | 44 | # Returns the status code from the `response` or nil if the Error was not 45 | # created with a response. 46 | attr_reader :status 47 | 48 | # Returns the Elasticsearch error from the `response` or nil if the Error 49 | # was not created with a response. 50 | attr_reader :error 51 | 52 | # Indicates that the error is fatal. The request should not be tried 53 | # again. 54 | def fatal? 55 | self.class.fatal? 56 | end 57 | 58 | # The inverse of the `fatal?` method. A request can be retried if this 59 | # method returns `true`. 60 | def retry? 61 | !fatal? 62 | end 63 | 64 | class << self 65 | # By default all client errors are fatal and indicate that a request 66 | # should not be retried. Only a few errors are retryable. 67 | def fatal 68 | return @fatal if defined? @fatal 69 | @fatal = true 70 | end 71 | attr_writer :fatal 72 | alias_method :fatal?, :fatal 73 | end 74 | 75 | end # Error 76 | 77 | # Wrapper classes for specific Faraday errors. 78 | TimeoutError = Class.new Error 79 | ConnectionFailed = Class.new Error 80 | ResourceNotFound = Class.new Error 81 | ParsingError = Class.new Error 82 | SSLError = Class.new Error 83 | ServerError = Class.new Error 84 | RequestError = Class.new Error 85 | RequestSizeError = Class.new Error 86 | 87 | # Provide some nice errors for common Elasticsearch exceptions. These are 88 | # all subclasses of the more general RequestError 89 | IndexNotFoundError = Class.new RequestError 90 | QueryParsingError = Class.new RequestError 91 | SearchContextMissing = Class.new RequestError 92 | RejectedExecutionError = Class.new RequestError 93 | DocumentAlreadyExistsError = Class.new RequestError 94 | 95 | ServerError.fatal = false 96 | TimeoutError.fatal = false 97 | ConnectionFailed.fatal = false 98 | RejectedExecutionError.fatal = false 99 | 100 | # Exception for operations that are unsupported with the version of 101 | # Elasticsearch being used. 102 | IncompatibleVersionException = Class.new Error 103 | 104 | # Exception for client-detected and server-raised invalid Elasticsearch 105 | # request parameter. 106 | IllegalArgument = Class.new Error 107 | 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/multi_percolate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Execute an array of percolate actions in bulk. Results are returned in an 7 | # array in the order the actions were sent. 8 | # 9 | # The `multi_percolate` method can be used in two ways. Without a block 10 | # the method will perform an API call, and it requires a bulk request 11 | # body and optional request parameters. 12 | # 13 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-percolate.html#_multi_percolate_api 14 | # 15 | # body - Request body as a String (required if a block is not given) 16 | # params - Optional request parameters as a Hash 17 | # block - Passed to a MultiPercolate instance which assembles the 18 | # percolate actions into a single request. 19 | # 20 | # Examples 21 | # 22 | # # index and type in request body 23 | # multi_percolate(request_body) 24 | # 25 | # # index in URI 26 | # multi_percolate(request_body, index: 'default-index') 27 | # 28 | # # block form 29 | # multi_percolate(index: 'default-index') do |m| 30 | # m.percolate({ author: "pea53" }, { type: 'default-type' }) 31 | # m.count({ author: "pea53" }, { type: 'type2' }) 32 | # ... 33 | # end 34 | # 35 | # Returns the response body as a Hash 36 | def multi_percolate(body = nil, params = nil) 37 | if block_given? 38 | params, body = (body || {}), nil 39 | yield mpercolate_obj = MultiPercolate.new(self, params) 40 | mpercolate_obj.call 41 | else 42 | raise "multi_percolate request body cannot be nil" if body.nil? 43 | params ||= {} 44 | 45 | response = self.post "{/index}{/type}/_mpercolate", params.merge(body:, action: "mpercolate", rest_api: "mpercolate") 46 | response.body 47 | end 48 | end 49 | alias_method :mpercolate, :multi_percolate 50 | 51 | # The MultiPercolate class is a helper for accumulating and submitting 52 | # multi_percolate API requests. Instances of the MultiPercolate class 53 | # accumulate percolate actions and then issue a single API request to 54 | # Elasticsearch, which runs all accumulated percolate actions in parallel 55 | # and returns each result hash aggregated into an array of result 56 | # hashes. 57 | # 58 | # Instead of instantiating this class directly, use 59 | # the block form of Client#multi_percolate. 60 | # 61 | class MultiPercolate 62 | 63 | # Create a new MultiPercolate instance for accumulating percolate actions 64 | # and submitting them all as a single request. 65 | # 66 | # client - ElastomerClient::Client used for HTTP requests to the server 67 | # params - Parameters Hash to pass to the Client#multi_percolate method 68 | def initialize(client, params = {}) 69 | @client = client 70 | @params = params 71 | 72 | @actions = [] 73 | end 74 | 75 | attr_reader :client 76 | 77 | # Add a percolate action to the multi percolate request. This percolate 78 | # action will not be executed until the multi_percolate API call is made. 79 | # 80 | # header - A Hash of the index and type, if not provided in the params 81 | # doc - A Hash of the document 82 | # 83 | # Returns this MultiPercolate instance. 84 | def percolate(doc, header = {}) 85 | add_to_actions(percolate: @params.merge(header)) 86 | add_to_actions(doc:) 87 | end 88 | 89 | # Add a percolate acount action to the multi percolate request. This 90 | # percolate count action will not be executed until the multi_percolate 91 | # API call is made. 92 | # 93 | # header - A Hash of the index and type, if not provided in the params 94 | # doc - A Hash of the document 95 | # 96 | # Returns this MultiPercolate instance. 97 | def count(doc, header = {}) 98 | add_to_actions(count: @params.merge(header)) 99 | add_to_actions(doc:) 100 | end 101 | 102 | # Execute the multi_percolate call with the accumulated percolate actions. 103 | # If the accumulated actions list is empty then no action is taken. 104 | # 105 | # Returns the response body Hash. 106 | def call 107 | return if @actions.empty? 108 | 109 | body = @actions.join("\n") + "\n" 110 | client.multi_percolate(body, @params) 111 | ensure 112 | @actions.clear 113 | end 114 | 115 | # Internal: Add an action to the pending request. Actions can be 116 | # either headers or bodies. The first action must be a percolate header, 117 | # followed by a body, then alternating headers and bodies. 118 | # 119 | # action - the Hash (header or body) to add to the pending request 120 | # 121 | # Returns this MultiPercolate instance. 122 | def add_to_actions(action) 123 | action = MultiJson.dump action 124 | @actions << action 125 | self 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/multi_search.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Execute an array of searches in bulk. Results are returned in an 7 | # array in the order the queries were sent. 8 | # 9 | # The `multi_search` method can be used in two ways. Without a block 10 | # the method will perform an API call, and it requires a bulk request 11 | # body and optional request parameters. 12 | # 13 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-multi-search.html 14 | # 15 | # body - Request body as a String (required if a block is not given) 16 | # params - Optional request parameters as a Hash 17 | # block - Passed to a MultiSearch instance which assembles the searches 18 | # into a single request. 19 | # 20 | # Examples 21 | # 22 | # # index and type in request body 23 | # multi_search(request_body) 24 | # 25 | # # index in URI 26 | # multi_search(request_body, index: 'default-index') 27 | # 28 | # # block form 29 | # multi_search(index: 'default-index') do |m| 30 | # m.search({query: {match_all: {}}, size: 0) 31 | # m.search({query: {field: {"foo" => "bar"}}}, type: 'default-type') 32 | # ... 33 | # end 34 | # 35 | # Returns the response body as a Hash 36 | def multi_search(body = nil, params = nil) 37 | if block_given? 38 | params, body = (body || {}), nil 39 | yield msearch_obj = MultiSearch.new(self, params) 40 | msearch_obj.call 41 | else 42 | raise "multi_search request body cannot be nil" if body.nil? 43 | params ||= {} 44 | 45 | response = self.post "{/index}{/type}/_msearch", params.merge(body:, action: "msearch", rest_api: "msearch") 46 | response.body 47 | end 48 | end 49 | alias_method :msearch, :multi_search 50 | 51 | # The MultiSearch class is a helper for accumulating and submitting 52 | # multi_search API requests. Instances of the MultiSearch class 53 | # accumulate searches and then issue a single API request to 54 | # Elasticsearch, which runs all accumulated searches in parallel 55 | # and returns each result hash aggregated into an array of result 56 | # hashes. 57 | # 58 | # Instead of instantiating this class directly, use 59 | # the block form of Client#multi_search. 60 | # 61 | class MultiSearch 62 | 63 | # Create a new MultiSearch instance for accumulating searches and 64 | # submitting them all as a single request. 65 | # 66 | # client - ElastomerClient::Client used for HTTP requests to the server 67 | # params - Parameters Hash to pass to the Client#multi_search method 68 | def initialize(client, params = {}) 69 | @client = client 70 | @params = params 71 | 72 | @actions = [] 73 | end 74 | 75 | attr_reader :client 76 | 77 | # Add a search to the multi search request. This search will not 78 | # be executed until the multi_search API call is made. 79 | # 80 | # query - The query body as a Hash 81 | # params - Parameters Hash 82 | # 83 | # Returns this MultiSearch instance. 84 | def search(query, params = {}) 85 | add_to_actions(params) 86 | add_to_actions(query) 87 | end 88 | 89 | # Execute the multi_search call with the accumulated searches. If 90 | # the accumulated actions list is empty then no action is taken. 91 | # 92 | # Returns the response body Hash. 93 | def call 94 | return if @actions.empty? 95 | 96 | body = @actions.join("\n") + "\n" 97 | client.multi_search(body, @params) 98 | ensure 99 | @actions.clear 100 | end 101 | 102 | # Internal: Add an action to the pending request. Actions can be 103 | # either search params or query bodies. The first action must be 104 | # a search params hash, followed by a query body, then alternating 105 | # params and queries. 106 | # 107 | # action - the Hash (params or query) to add to the pending request 108 | # 109 | # Returns this MultiSearch instance. 110 | def add_to_actions(action) 111 | action = MultiJson.dump action 112 | @actions << action 113 | self 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/native_delete_by_query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | # Delete documents based on a query using the Elasticsearch _delete_by_query API. 6 | # 7 | # query - The query body as a Hash 8 | # params - Parameters Hash 9 | # 10 | # Examples 11 | # 12 | # # request body query 13 | # native_delete_by_query({query: {match_all: {}}}, type: 'tweet') 14 | # 15 | # See https://www.elastic.co/guide/en/elasticsearch/reference/5.6/docs-delete-by-query.html 16 | # 17 | # Returns a Hash containing the _delete_by_query response body. 18 | def native_delete_by_query(query, parameters = {}) 19 | NativeDeleteByQuery.new(self, query, parameters).execute 20 | end 21 | 22 | class NativeDeleteByQuery 23 | attr_reader :client, :query, :parameters 24 | 25 | def initialize(client, query, parameters) 26 | @client = client 27 | @query = query 28 | @parameters = parameters 29 | end 30 | 31 | def execute 32 | # TODO: Require index parameter. type is optional. 33 | updated_params = parameters.merge(body: query, action: "delete_by_query", rest_api: "delete_by_query") 34 | updated_params.delete(:type) if client.version_support.es_version_8_plus? 35 | response = client.post("/{index}{/type}/_delete_by_query", updated_params) 36 | response.body 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/nodes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Provides access to node-level API commands. The default node is set to 7 | # nil which target all nodes. You can pass in "_all" (to get the 8 | # same effect) or "_local" to target only the current node the client is 9 | # connected to. And you can specify an individual node or multiple nodes. 10 | # 11 | # node_id - The node ID as a String or an Array of node IDs 12 | # 13 | # Returns a Nodes instance. 14 | def nodes(node_id = nil) 15 | Nodes.new self, node_id 16 | end 17 | 18 | 19 | class Nodes 20 | # Create a new nodes client for making API requests that pertain to 21 | # the health and management individual nodes. 22 | # 23 | # client - ElastomerClient::Client used for HTTP requests to the server 24 | # node_id - The node ID as a String or an Array of node IDs 25 | # 26 | def initialize(client, node_id) 27 | @client = client 28 | @node_id = node_id 29 | end 30 | 31 | attr_reader :client, :node_id 32 | 33 | # Retrieve one or more (or all) of the cluster nodes information. By 34 | # default all information is returned from all nodes. You can select the 35 | # information to be returned by passing in the `:info` from the list of 36 | # "settings", "os", "process", "jvm", "thread_pool", "network", 37 | # "transport", "http" and "plugins". 38 | # 39 | # params - Parameters Hash 40 | # :node_id - a single node ID or Array of node IDs 41 | # :info - a single information attribute or an Array 42 | # 43 | # Examples 44 | # 45 | # info(info: "_all") 46 | # info(info: "os") 47 | # info(info: %w[os jvm process]) 48 | # 49 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-nodes-info.html 50 | # 51 | # Returns the response as a Hash 52 | def info(params = {}) 53 | response = client.get "/_nodes{/node_id}{/info}", update_params(params, action: "nodes.info", rest_api: "nodes.info") 54 | response.body 55 | end 56 | 57 | # Retrieve one or more (or all) of the cluster nodes statistics. For 1.x 58 | # stats filtering, use the :stats parameter key. 59 | # 60 | # params - Parameters Hash 61 | # :node_id - a single node ID or Array of node IDs 62 | # :stats - a single stats value or an Array of stats values 63 | # 64 | # Examples 65 | # 66 | # stats(stats: "thread_pool") 67 | # stats(stats: %w[os process]) 68 | # 69 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-nodes-stats.html 70 | # 71 | # Returns the response as a Hash 72 | def stats(params = {}) 73 | response = client.get "/_nodes{/node_id}/stats{/stats}", update_params(params, action: "nodes.stats", rest_api: "nodes.stats") 74 | response.body 75 | end 76 | 77 | # Get the current hot threads on each node in the cluster. The return 78 | # value is a human formatted String - i.e. a String with newlines and 79 | # other formatting characters suitable for display in a terminal window. 80 | # 81 | # params - Parameters Hash 82 | # :node_id - a single node ID or Array of node IDs 83 | # :threads - number of hot threads to provide 84 | # :interval - sampling interval [default is 500ms] 85 | # :type - the type to sample: "cpu", "wait", or "block" 86 | # 87 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-nodes-hot-threads.html 88 | # 89 | # Returns the response as a String 90 | def hot_threads(params = {}) 91 | response = client.get "/_nodes{/node_id}/hot_threads", update_params(params, action: "nodes.hot_threads", rest_api: "nodes.hot_threads") 92 | response.body 93 | end 94 | 95 | # Internal: Add default parameters to the `params` Hash and then apply 96 | # `overrides` to the params if any are given. 97 | # 98 | # params - Parameters Hash 99 | # overrides - Optional parameter overrides as a Hash 100 | # 101 | # Returns a new params Hash. 102 | def update_params(params, overrides = nil) 103 | h = { node_id: }.update params 104 | h.update overrides unless overrides.nil? 105 | h 106 | end 107 | 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/percolator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | class Percolator 7 | 8 | # Create a new Percolator for managing a query. 9 | # 10 | # client - ElastomerClient::Client used for HTTP requests to the server 11 | # index_name - The index name 12 | # id - The _id for the query 13 | def initialize(client, index_name, id) 14 | @client = client 15 | @index_name = client.assert_param_presence(index_name, "index name") 16 | @id = client.assert_param_presence(id, "id") 17 | end 18 | 19 | attr_reader :client, :index_name, :id 20 | 21 | # Create a percolator query. 22 | # 23 | # Examples 24 | # 25 | # percolator = $client.index("default-index").percolator "1" 26 | # percolator.create query: { match_all: { } } 27 | # 28 | # Returns the response body as a Hash 29 | def create(body, params = {}) 30 | response = client.put("/{index}/percolator/{id}", defaults.merge(params.merge(body:, action: "percolator.create"))) 31 | response.body 32 | end 33 | 34 | # Gets a percolator query. 35 | # 36 | # Examples 37 | # 38 | # percolator = $client.index("default-index").percolator "1" 39 | # percolator.get 40 | # 41 | # Returns the response body as a Hash 42 | def get(params = {}) 43 | response = client.get("/{index}/percolator/{id}", defaults.merge(params.merge(action: "percolator.get"))) 44 | response.body 45 | end 46 | 47 | # Delete a percolator query. 48 | # 49 | # Examples 50 | # 51 | # percolator = $client.index("default-index").percolator "1" 52 | # percolator.delete 53 | # 54 | # Returns the response body as a Hash 55 | def delete(params = {}) 56 | response = client.delete("/{index}/percolator/{id}", defaults.merge(params.merge(action: "percolator.delete"))) 57 | response.body 58 | end 59 | 60 | # Checks for the existence of a percolator query. 61 | # 62 | # Examples 63 | # 64 | # percolator = $client.index("default-index").percolator "1" 65 | # percolator.exists? 66 | # 67 | # Returns a boolean 68 | def exists?(params = {}) 69 | get(params)["found"] 70 | end 71 | 72 | # Internal: Returns a Hash containing default parameters. 73 | def defaults 74 | {index: index_name, id:} 75 | end 76 | 77 | end # Percolator 78 | end # Client 79 | end # ElastomerClient 80 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/reindex.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Returns a Reindex instance 7 | def reindex 8 | Reindex.new(self) 9 | end 10 | 11 | class Reindex 12 | # Create a new Reindex for initiating reindex tasks. 13 | # More context: https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-reindex.html 14 | # 15 | # client - ElastomerClient::Client used for HTTP requests to the server 16 | def initialize(client) 17 | @client = client 18 | end 19 | 20 | attr_reader :client 21 | 22 | def reindex(body, params = {}) 23 | response = client.post "/_reindex", params.merge(body:, action: "reindex", rest_api: "reindex") 24 | response.body 25 | end 26 | 27 | def rethrottle(task_id, params = {}) 28 | response = client.post "/_reindex/#{task_id}/_rethrottle", params.merge(action: "rethrottle", rest_api: "reindex") 29 | response.body 30 | end 31 | 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Returns a Repository instance. 7 | def repository(name = nil) 8 | Repository.new(self, name) 9 | end 10 | 11 | class Repository 12 | # Create a new index client for making API requests that pertain to 13 | # the health and management individual indexes. 14 | # 15 | # client - ElastomerClient::Client used for HTTP requests to the server 16 | # name - The name of the index as a String or an Array of names 17 | def initialize(client, name = nil) 18 | @client = client 19 | @name = @client.assert_param_presence(name, "repository name") unless name.nil? 20 | end 21 | 22 | attr_reader :client, :name 23 | 24 | # Check for the existence of the repository. 25 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_repositories 26 | # 27 | # params - Parameters Hash 28 | # 29 | # Returns true if the repository exists 30 | def exists?(params = {}) 31 | response = client.get "/_snapshot{/repository}", update_params(params, action: "repository.exists", rest_api: "snapshot.get_repository") 32 | response.success? 33 | rescue ElastomerClient::Client::Error => err 34 | if err.error && err.error.dig("root_cause", 0, "type") == "repository_missing_exception" 35 | false 36 | else 37 | raise err 38 | end 39 | end 40 | alias_method :exist?, :exists? 41 | 42 | # Create the repository. 43 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_repositories 44 | # 45 | # body - The repository type and settings as a Hash or a JSON encoded String 46 | # params - Parameters Hash 47 | # 48 | # Returns the response body as a Hash 49 | def create(body, params = {}) 50 | response = client.put "/_snapshot/{repository}", update_params(params, body:, action: "repository.create", rest_api: "snapshot.create_repository") 51 | response.body 52 | end 53 | 54 | # Get repository type and settings. 55 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_repositories 56 | # 57 | # params - Parameters Hash 58 | # 59 | # Returns the response body as a Hash 60 | def get(params = {}) 61 | response = client.get "/_snapshot{/repository}", update_params(params, action: "repository.get", rest_api: "snapshot.get_repository") 62 | response.body 63 | end 64 | 65 | # Get status information on snapshots in progress. 66 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_repositories 67 | # 68 | # params - Parameters Hash 69 | # 70 | # Returns the response body as a Hash 71 | def status(params = {}) 72 | response = client.get "/_snapshot{/repository}/_status", update_params(params, action: "repository.status", rest_api: "snapshot.status") 73 | response.body 74 | end 75 | 76 | # Update the repository. 77 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_repositories 78 | # 79 | # body - The repository type and settings as a Hash or a JSON encoded String 80 | # params - Parameters Hash 81 | # 82 | # Returns the response body as a Hash 83 | def update(body, params = {}) 84 | response = client.put "/_snapshot/{repository}", update_params(params, body:, action: "repository.update", rest_api: "snapshot.create_repository") 85 | response.body 86 | end 87 | 88 | # Delete the repository. 89 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_repositories 90 | # 91 | # params - Parameters Hash 92 | # 93 | # Returns the response body as a Hash 94 | def delete(params = {}) 95 | response = client.delete "/_snapshot/{repository}", update_params(params, action: "repository.delete", rest_api: "snapshot.delete_repository") 96 | response.body 97 | end 98 | 99 | # Provides access to snapshot API commands. These commands will be 100 | # scoped to this repository and the given snapshot name. 101 | # 102 | # snapshot - The snapshot name as a String, or nil for all snapshots. 103 | # 104 | # Returns a Snapshot instance. 105 | def snapshot(snapshot = nil) 106 | client.snapshot(name, snapshot) 107 | end 108 | alias_method :snapshots, :snapshot 109 | 110 | # Internal: Add default parameters to the `params` Hash and then apply 111 | # `overrides` to the params if any are given. 112 | # 113 | # params - Parameters Hash 114 | # overrides - Optional parameter overrides as a Hash 115 | # 116 | # Returns a new params Hash. 117 | def update_params(params, overrides = nil) 118 | h = defaults.update params 119 | h.update overrides unless overrides.nil? 120 | h 121 | end 122 | 123 | # Internal: Returns a Hash containing default parameters. 124 | def defaults 125 | { repository: name } 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/rest_api_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Provides access to the versioned REST API specs for Elasticsearch. 7 | module RestApiSpec 8 | 9 | # Returns an ApiSpec instance for the given Elasticsearcion version. This 10 | # method will load the ApiSpec version class if it has not already been 11 | # defined. This prevents bloat by only loading the version specs that are 12 | # needed. 13 | # 14 | # Because of this lazy loading, this method is _not_ thread safe. 15 | # 16 | # version - the Elasticsearch version String 17 | # 18 | # Returns the requested ApiSpec version if available 19 | def self.api_spec(version) 20 | classname = "ApiSpecV#{to_class_version(version)}" 21 | load_api_spec(version) if !self.const_defined? classname 22 | self.const_get(classname).new 23 | end 24 | 25 | # Internal: Load the specific ApiSpec version class for the given version. 26 | def self.load_api_spec(version) 27 | path = File.expand_path("../rest_api_spec/api_spec_v#{to_class_version(version)}.rb", __FILE__) 28 | if File.exist? path 29 | load path 30 | else 31 | raise RuntimeError, "Unsupported REST API spec version: #{version}" 32 | end 33 | end 34 | 35 | # Internal: Convert a dotted version String into an underscore format 36 | # suitable for use in Ruby class names. 37 | def self.to_class_version(version) 38 | version.to_s.split(".").slice(0, 2).join("_") 39 | end 40 | end 41 | end 42 | end 43 | 44 | require_relative "rest_api_spec/api_spec" 45 | require_relative "rest_api_spec/rest_api" 46 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/rest_api_spec/api_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient::Client::RestApiSpec 4 | 5 | # This is the superclass for the version specific API Spec classes that will 6 | # be generated using the `script/generate-rest-api-spec` script. Each version 7 | # of Elasticsarch we support will have it's own ApiSpec class that will 8 | # validate the API request params for that particular version. 9 | class ApiSpec 10 | 11 | attr_reader :rest_apis 12 | attr_reader :common_params 13 | 14 | def initialize 15 | @rest_apis ||= {} 16 | @common_params ||= {} 17 | @common_params_set = Set.new(@common_params.keys) 18 | end 19 | 20 | # Given an API descriptor name and a set of request parameters, select those 21 | # params that are accepted by the API endpoint. 22 | # 23 | # api - the api descriptor name as a String 24 | # from - the Hash containing the request params 25 | # 26 | # Returns a new Hash containing the valid params for the api 27 | def select_params(api:, from:) 28 | rest_api = get(api) 29 | return from if rest_api.nil? 30 | rest_api.select_params(from:) 31 | end 32 | 33 | # Given an API descriptor name and a single request parameter, returns 34 | # `true` if the parameter is valid for the given API. This method always 35 | # returns `true` if the API is unknown. 36 | # 37 | # api - the api descriptor name as a String 38 | # param - the request parameter name as a String 39 | # 40 | # Returns `true` if the param is valid for the API. 41 | def valid_param?(api:, param:) 42 | rest_api = get(api) 43 | return true if rest_api.nil? 44 | rest_api.valid_param?(param) 45 | end 46 | 47 | # Given an API descriptor name and a set of request path parts, select those 48 | # parts that are accepted by the API endpoint. 49 | # 50 | # api - the api descriptor name as a String 51 | # from - the Hash containing the path parts 52 | # 53 | # Returns a new Hash containing the valid path parts for the api 54 | def select_parts(api:, from:) 55 | rest_api = get(api) 56 | return from if rest_api.nil? 57 | rest_api.select_parts(from:) 58 | end 59 | 60 | # Given an API descriptor name and a single path part, returns `true` if the 61 | # path part is valid for the given API. This method always returns `true` if 62 | # the API is unknown. 63 | # 64 | # api - the api descriptor name as a String 65 | # part - the path part name as a String 66 | # 67 | # Returns `true` if the path part is valid for the API. 68 | def valid_part?(api:, part:) 69 | rest_api = get(api) 70 | return true if rest_api.nil? 71 | rest_api.valid_part?(part) 72 | end 73 | 74 | # Select the common request parameters from the given params. 75 | # 76 | # from - the Hash containing the request params 77 | # 78 | # Returns a new Hash containing the valid common request params 79 | def select_common_params(from:) 80 | return from if @common_params.empty? 81 | from.select { |k, v| valid_common_param?(k) } 82 | end 83 | 84 | # Returns `true` if the param is a common request parameter. 85 | def valid_common_param?(param) 86 | @common_params_set.include?(param.to_s) 87 | end 88 | 89 | # Given an API descriptor name and a set of request parameters, ensure that 90 | # all the request parameters are valid for the API endpoint. If an invalid 91 | # parameter is found then an IllegalArgument exception is raised. 92 | # 93 | # api - the api descriptor name as a String 94 | # from - the Hash containing the request params 95 | # 96 | # Returns the params unmodified 97 | # Raises an IllegalArgument exception if an invalid parameter is found. 98 | def validate_params!(api:, params:) 99 | rest_api = get(api) 100 | return params if rest_api.nil? 101 | 102 | params.keys.each do |key| 103 | unless rest_api.valid_param?(key) || valid_common_param?(key) 104 | raise ::ElastomerClient::Client::IllegalArgument, "'#{key}' is not a valid parameter for the '#{api}' API" 105 | end 106 | end 107 | params 108 | end 109 | 110 | # Internal: Retrieve the `RestApi` descriptor for the given named `api`. If 111 | # an unkonwn `api` is passed in, then `nil` is returned. 112 | # 113 | # api - the api descriptor name as a String 114 | # 115 | # Returns a RestApi instance or nil. 116 | def get(api) 117 | rest_apis[api] 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/rest_api_spec/rest_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "forwardable" 4 | 5 | module ElastomerClient::Client::RestApiSpec 6 | class RestApi 7 | extend Forwardable 8 | 9 | attr_reader :documentation 10 | attr_reader :methods 11 | attr_reader :url 12 | attr_reader :body 13 | 14 | def_delegators :@url, 15 | :select_parts, :select_params, :valid_part?, :valid_param? 16 | 17 | def initialize(documentation:, methods:, url:, body: nil) 18 | @documentation = documentation 19 | @methods = Array(methods) 20 | @url = Url.new(**url) 21 | @body = body 22 | end 23 | 24 | def body? 25 | !body.nil? 26 | end 27 | 28 | class Url 29 | attr_reader :path 30 | attr_reader :paths 31 | attr_reader :parts 32 | attr_reader :params 33 | 34 | def initialize(path:, paths: [], parts: {}, params: {}) 35 | @path = path 36 | @paths = Array(paths) 37 | @parts = parts 38 | @params = params 39 | 40 | @parts_set = Set.new(@parts.keys) 41 | @params_set = Set.new(@params.keys) 42 | end 43 | 44 | def select_parts(from:) 45 | from.select { |k, v| valid_part?(k) } 46 | end 47 | 48 | def valid_part?(part) 49 | @parts_set.include?(part.to_s) 50 | end 51 | 52 | def select_params(from:) 53 | from.select { |k, v| valid_param?(k) } 54 | end 55 | 56 | def valid_param?(param) 57 | @params_set.include?(param.to_s) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/snapshot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Provides access to snapshot API commands. 7 | # 8 | # repository - The name of the repository as a String 9 | # name - The name of the snapshot as a String 10 | # 11 | # Returns a Snapshot instance. 12 | def snapshot(repository = nil, name = nil) 13 | Snapshot.new self, repository, name 14 | end 15 | 16 | class Snapshot 17 | # Create a new snapshot object for making API requests that pertain to 18 | # creating, restoring, deleting, and retrieving snapshots. 19 | # 20 | # client - ElastomerClient::Client used for HTTP requests to the server 21 | # repository - The name of the repository as a String. Cannot be nil if 22 | # snapshot name is not nil. 23 | # name - The name of the snapshot as a String 24 | def initialize(client, repository = nil, name = nil) 25 | @client = client 26 | # don't allow nil repository if snapshot name is not nil 27 | @repository = @client.assert_param_presence(repository, "repository name") unless repository.nil? && name.nil? 28 | @name = @client.assert_param_presence(name, "snapshot name") unless name.nil? 29 | end 30 | 31 | attr_reader :client, :repository, :name 32 | 33 | # Check for the existence of the snapshot. 34 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_snapshot 35 | # 36 | # params - Parameters Hash 37 | # 38 | # Returns true if the snapshot exists 39 | def exists?(params = {}) 40 | response = client.get "/_snapshot/{repository}/{snapshot}", update_params(params, action: "snapshot.exists", rest_api: "snapshot.get") 41 | response.success? 42 | rescue ElastomerClient::Client::Error => err 43 | if err.error && err.error.dig("root_cause", 0, "type") == "snapshot_missing_exception" 44 | false 45 | else 46 | raise err 47 | end 48 | end 49 | alias_method :exist?, :exists? 50 | 51 | # Create the snapshot. 52 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_snapshot 53 | # 54 | # body - The snapshot options as a Hash or a JSON encoded String 55 | # params - Parameters Hash 56 | # 57 | # Returns the response body as a Hash 58 | def create(body = {}, params = {}) 59 | response = client.put "/_snapshot/{repository}/{snapshot}", update_params(params, body:, action: "snapshot.create", rest_api: "snapshot.create") 60 | response.body 61 | end 62 | 63 | # Get snapshot progress information. 64 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_snapshot 65 | # 66 | # params - Parameters Hash 67 | # 68 | # Returns the response body as a Hash 69 | def get(params = {}) 70 | # Set snapshot name or we'll get the repository instead 71 | snapshot = name || "_all" 72 | response = client.get "/_snapshot/{repository}/{snapshot}", update_params(params, snapshot:, action: "snapshot.get", rest_api: "snapshot.get") 73 | response.body 74 | end 75 | 76 | # Get detailed snapshot status. 77 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_snapshot 78 | # 79 | # params - Parameters Hash 80 | # 81 | # Returns the response body as a Hash 82 | def status(params = {}) 83 | response = client.get "/_snapshot{/repository}{/snapshot}/_status", update_params(params, action: "snapshot.status", rest_api: "snapshot.status") 84 | response.body 85 | end 86 | 87 | # Restore the snapshot. 88 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_snapshot 89 | # 90 | # body - The restore options as a Hash or a JSON encoded String 91 | # params - Parameters Hash 92 | # 93 | # Returns the response body as a Hash 94 | def restore(body = {}, params = {}) 95 | response = client.post "/_snapshot/{repository}/{snapshot}/_restore", update_params(params, body:, action: "snapshot.restore", rest_api: "snapshot.restore") 96 | response.body 97 | end 98 | 99 | # Delete the snapshot. 100 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html#_snapshot 101 | # 102 | # params - Parameters Hash 103 | # 104 | # Returns the response body as a Hash 105 | def delete(params = {}) 106 | response = client.delete "/_snapshot/{repository}/{snapshot}", update_params(params, action: "snapshot.delete", rest_api: "snapshot.delete") 107 | response.body 108 | end 109 | 110 | # Internal: Add default parameters to the `params` Hash and then apply 111 | # `overrides` to the params if any are given. 112 | # 113 | # params - Parameters Hash 114 | # overrides - Optional parameter overrides as a Hash 115 | # 116 | # Returns a new params Hash. 117 | def update_params(params, overrides = nil) 118 | h = defaults.update params 119 | h.update overrides unless overrides.nil? 120 | h 121 | end 122 | 123 | # Internal: Returns a Hash containing default parameters. 124 | def defaults 125 | { repository:, snapshot: name } 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Returns a Tasks instance for querying the cluster bound to this client for 7 | # metadata about internal tasks in flight, and to submit administrative 8 | # requests (like cancellation) concerning those tasks. 9 | # 10 | # Returns a new Tasks object associated with this client 11 | def tasks 12 | Tasks.new(self) 13 | end 14 | 15 | class Tasks 16 | 17 | # Create a new Tasks for introspecting on internal cluster activity. 18 | # More context: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/tasks.html 19 | # 20 | # client - ElastomerClient::Client used for HTTP requests to the server 21 | # 22 | # Raises IncompatibleVersionException if caller attempts to access Tasks API on ES version < 5.0.0 23 | def initialize(client) 24 | @client = client 25 | end 26 | 27 | attr_reader :client 28 | 29 | # Fetch results from the generic _tasks endpoint. 30 | # 31 | # params - Hash of request parameters, including: 32 | # 33 | # Examples 34 | # 35 | # tasks.get 36 | # tasks.get nodes: "DmteLdw1QmSgW3GZmjmoKA,DmteLdw1QmSgW3GZmjmoKB", actions: "cluster:*", detailed: true 37 | # 38 | # Examples (ES 5+ only) 39 | # 40 | # tasks.get group_by: "parents" 41 | # tasks.get group_by: "parents", actions: "*reindex", ... 42 | # 43 | # Returns the response body as a Hash 44 | def get(params = {}) 45 | response = client.get "/_tasks", params.merge(action: "tasks.list", rest_api: "tasks.list") 46 | response.body 47 | end 48 | 49 | # Fetch results from the _tasks endpoint for a particular cluster node and task ID. 50 | # NOTE: the API docs note the behavior wrong for this call; "task_id:" is really ":" 51 | # where "node_id" is a value from the "nodes" hash returned from the /_tasks endpoint, and "task_id" is 52 | # from the "tasks" child hash of any of the "nodes" entries of the /_tasks endpoint 53 | # 54 | # node_id - the name of the ES cluster node hosting the target task 55 | # task_id - the numerical ID of the task to return data about in the response 56 | # params - Hash of request parameters to include 57 | # 58 | # Examples 59 | # 60 | # tasks.get_by_id "DmteLdw1QmSgW3GZmjmoKA", 123 61 | # tasks.get_by_id "DmteLdw1QmSgW3GZmjmoKA", 456, pretty: true 62 | # 63 | # Returns the response body as a Hash 64 | def get_by_id(node_id, task_id, params = {}) 65 | raise ArgumentError, "invalid node ID provided: #{node_id.inspect}" if node_id.to_s.empty? 66 | raise ArgumentError, "invalid task ID provided: #{task_id.inspect}" unless task_id.is_a?(Integer) 67 | 68 | # in this API, the task ID is included in the path, not as a request parameter. 69 | response = client.get "/_tasks/{task_id}", params.merge(task_id: "#{node_id}:#{task_id}", action: "tasks.get", rest_api: "tasks.get") 70 | response.body 71 | end 72 | 73 | # Fetch task details for all child tasks of the specified parent task. 74 | # NOTE: the API docs note the behavior wrong for this call: "parentTaskId:" 75 | # is not the correct syntax for the parent_task_id param value. The correct 76 | # value syntax is ":" 77 | # 78 | # parent_node_id - ID of the node the parent task is hosted by 79 | # parent_task_id - ID of a parent task who's child tasks' data will be returned in the response 80 | # params - Hash of request parameters to include 81 | # 82 | # Examples 83 | # 84 | # tasks.get_by_parent_id "DmteLdw1QmSgW3GZmjmoKA", 123 85 | # tasks.get_by_parent_id "DmteLdw1QmSgW3GZmjmoKB", 456, :detailed => true 86 | # 87 | # Returns the response body as a Hash 88 | def get_by_parent_id(parent_node_id, parent_task_id, params = {}) 89 | raise ArgumentError, "invalid parent node ID provided: #{parent_node_id.inspect}" if node_id.to_s.empty? 90 | raise ArgumentError, "invalid parent task ID provided: #{parent_task_id.inspect}" unless parent_task_id.is_a?(Integer) 91 | 92 | parent_task_id = "#{parent_node_id}:#{parent_task_id}" 93 | params = params.merge(action: "tasks.parent", rest_api: "tasks.list") 94 | 95 | params[:parent_task_id] = parent_task_id 96 | 97 | response = client.get "/_tasks", params 98 | response.body 99 | end 100 | 101 | # Wait for the specified amount of time (10 seconds by default) for some task(s) to complete. 102 | # Filters for task(s) to wait upon using same filter params as Tasks#get(params) 103 | # 104 | # timeout - maximum time to wait for target task to complete before returning, example: "5s" 105 | # params - Hash of request params to include (mostly task filters in this context) 106 | # 107 | # Examples 108 | # 109 | # tasks.wait_for "5s", actions: "*health" 110 | # tasks.wait_for("30s", actions: "*reindex", nodes: "DmteLdw1QmSgW3GZmjmoKA,DmteLdw1QmSgW3GZmjmoKB") 111 | # 112 | # Returns the response body as a Hash when timeout expires or target tasks complete 113 | # COMPATIBILITY WARNING: the response body differs between ES versions for this API 114 | def wait_for(timeout = "10s", params = {}) 115 | self.get params.merge(wait_for_completion: true, timeout:) 116 | end 117 | 118 | # Wait for the specified amount of time (10 seconds by default) for some task(s) to complete. 119 | # Filters for task(s) to wait upon using same IDs and filter params as Tasks#get_by_id(params) 120 | # 121 | # node_id - the ID of the node on which the target task is hosted 122 | # task_id - the ID of the task to wait on 123 | # timeout - time for call to await target tasks completion before returning 124 | # params - Hash of request params to include (mostly task filters in this context) 125 | # 126 | # Examples 127 | # 128 | # tasks.wait_by_id "DmteLdw1QmSgW3GZmjmoKA", 123, "15s" 129 | # tasks.wait_by_id "DmteLdw1QmSgW3GZmjmoKA", 456, "30s", actions: "*search" 130 | # 131 | # Returns the response body as a Hash when timeout expires or target tasks complete 132 | def wait_by_id(node_id, task_id, timeout = "10s", params = {}) 133 | raise ArgumentError, "invalid node ID provided: #{node_id.inspect}" if node_id.to_s.empty? 134 | raise ArgumentError, "invalid task ID provided: #{task_id.inspect}" unless task_id.is_a?(Integer) 135 | 136 | self.get_by_id(node_id, task_id, params.merge(wait_for_completion: true, timeout:)) 137 | end 138 | 139 | # Cancels a task running on a particular node. 140 | # NOTE: the API docs note the behavior wrong for this call; "task_id:" is really ":" 141 | # where "node_id" is a value from the "nodes" hash returned from the /_tasks endpoint, and "task_id" is 142 | # from the "tasks" child hash of any of the "nodes" entries of the /_tasks endpoint 143 | # 144 | # node_id - the ES node hosting the task to be cancelled 145 | # task_id - ID of the task to be cancelled 146 | # params - Hash of request parameters to include 147 | # 148 | # Examples 149 | # 150 | # tasks.cancel_by_id "DmteLdw1QmSgW3GZmjmoKA", 123 151 | # tasks.cancel_by_id "DmteLdw1QmSgW3GZmjmoKA", 456, pretty: true 152 | # 153 | # Returns the response body as a Hash 154 | def cancel_by_id(node_id, task_id, params = {}) 155 | raise ArgumentError, "invalid node ID provided: #{node_id.inspect}" if node_id.to_s.empty? 156 | raise ArgumentError, "invalid task ID provided: #{task_id.inspect}" unless task_id.is_a?(Integer) 157 | 158 | self.cancel(params.merge(task_id: "#{node_id}:#{task_id}")) 159 | end 160 | 161 | # Cancels a task or group of tasks using various filtering parameters. 162 | # 163 | # params - Hash of request parameters to include 164 | # 165 | # Examples 166 | # 167 | # tasks.cancel actions: "*reindex" 168 | # tasks.cancel actions: "*search", nodes: "DmteLdw1QmSgW3GZmjmoKA,DmteLdw1QmSgW3GZmjmoKB,DmteLdw1QmSgW3GZmjmoKC" 169 | # 170 | # Returns the response body as a Hash 171 | def cancel(params = {}) 172 | response = client.post "/_tasks{/task_id}/_cancel", params.merge(action: "tasks.cancel", rest_api: "tasks.cancel") 173 | response.body 174 | end 175 | 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/template.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | 6 | # Returns a Template instance. 7 | def template(name) 8 | Template.new self, name 9 | end 10 | 11 | 12 | class Template 13 | 14 | # Create a new template client for making API requests that pertain to 15 | # template management. 16 | # 17 | # client - ElastomerClient::Client used for HTTP requests to the server 18 | # name - The name of the template as a String 19 | # 20 | def initialize(client, name) 21 | @client = client 22 | @name = name 23 | end 24 | 25 | attr_reader :client, :name 26 | 27 | # Returns true if the template already exists on the cluster. 28 | def exists?(params = {}) 29 | response = client.head "/_template/{template}", update_params(params, action: "template.exists", rest_api: "indices.exists_template") 30 | response.success? 31 | end 32 | alias_method :exist?, :exists? 33 | 34 | # Get the template from the cluster. 35 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html#getting 36 | # 37 | # params - Parameters Hash 38 | # 39 | # Returns the response body as a Hash 40 | def get(params = {}) 41 | response = client.get "/_template/{template}", update_params(params, action: "template.get", rest_api: "indices.get_template") 42 | response.body 43 | end 44 | 45 | # Create the template on the cluster. 46 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html 47 | # 48 | # template - The template as a Hash or a JSON encoded String 49 | # params - Parameters Hash 50 | # 51 | # Returns the response body as a Hash 52 | def create(template, params = {}) 53 | response = client.put "/_template/{template}", update_params(params, body: template, action: "template.create", rest_api: "indices.put_template") 54 | response.body 55 | end 56 | 57 | # Delete the template from the cluster. 58 | # See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-templates.html#delete 59 | # 60 | # params - Parameters Hash 61 | # 62 | # Returns the response body as a Hash 63 | def delete(params = {}) 64 | response = client.delete "/_template/{template}", update_params(params, action: "template.delete", rest_api: "indices.delete_template") 65 | response.body 66 | end 67 | 68 | # Internal: Add default parameters to the `params` Hash and then apply 69 | # `overrides` to the params if any are given. 70 | # 71 | # params - Parameters Hash 72 | # overrides - Optional parameter overrides as a Hash 73 | # 74 | # Returns a new params Hash. 75 | def update_params(params, overrides = nil) 76 | h = defaults.update params 77 | h.update overrides unless overrides.nil? 78 | h 79 | end 80 | 81 | # Internal: Returns a Hash containing default parameters. 82 | def defaults 83 | { template: name } 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/elastomer_client/client/update_by_query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class Client 5 | # Update documents based on a query using the Elasticsearch _update_by_query API. 6 | # 7 | # query - The query body as a Hash 8 | # params - Parameters Hash 9 | # 10 | # Examples 11 | # 12 | # # request body query 13 | # update_by_query({ 14 | # "script": { 15 | # "source": "ctx._source.count++", 16 | # "lang": "painless" 17 | # }, 18 | # "query": { 19 | # "term": { 20 | # "user.id": "kimchy" 21 | # } 22 | # } 23 | # }) 24 | # 25 | # See https://www.elastic.co/guide/en/elasticsearch/reference/8.7/docs-update-by-query.html 26 | # 27 | # Returns a Hash containing the _update_by_query response body. 28 | def update_by_query(query, parameters = {}) 29 | UpdateByQuery.new(self, query, parameters).execute 30 | end 31 | 32 | class UpdateByQuery 33 | attr_reader :client, :query, :parameters 34 | 35 | def initialize(client, query, parameters) 36 | @client = client 37 | @query = query 38 | @parameters = parameters 39 | end 40 | 41 | def execute 42 | # TODO: Require index parameter. type is optional. 43 | updated_params = parameters.merge(body: query, action: "update_by_query", rest_api: "update_by_query") 44 | updated_params.delete(:type) if client.version_support.es_version_8_plus? 45 | response = client.post("/{index}{/type}/_update_by_query", updated_params) 46 | response.body 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/elastomer_client/core_ext/time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "time" 4 | 5 | class Time 6 | def to_json(ignore = nil) 7 | %Q["#{self.iso8601(3)}"] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/elastomer_client/middleware/compress.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "stringio" 3 | 4 | module ElastomerClient 5 | module Middleware 6 | # Request middleware that compresses request bodies with GZip for supported 7 | # versions of Elasticsearch. 8 | # 9 | # It will only compress when there is a request body that is a String. This 10 | # middleware should be inserted after JSON serialization. 11 | class Compress < Faraday::Middleware 12 | CONTENT_ENCODING = "Content-Encoding" 13 | GZIP = "gzip" 14 | # An Ethernet packet can hold 1500 bytes. No point in compressing anything smaller than that (plus some wiggle room). 15 | MIN_BYTES_FOR_COMPRESSION = 1400 16 | 17 | attr_reader :compression 18 | 19 | # options - The Hash of "keyword" arguments. 20 | # :compression - the compression level (0-9, default Zlib::DEFAULT_COMPRESSION) 21 | def initialize(app, options = {}) 22 | super(app) 23 | @compression = options[:compression] || Zlib::DEFAULT_COMPRESSION 24 | end 25 | 26 | def call(env) 27 | if body = env[:body] 28 | if body.is_a?(String) && body.bytesize > MIN_BYTES_FOR_COMPRESSION 29 | output = StringIO.new 30 | output.set_encoding("BINARY") 31 | gz = Zlib::GzipWriter.new(output, compression, Zlib::DEFAULT_STRATEGY) 32 | gz.write(env[:body]) 33 | gz.close 34 | env[:body] = output.string 35 | env[:request_headers][CONTENT_ENCODING] = GZIP 36 | end 37 | end 38 | 39 | @app.call(env) 40 | end 41 | end 42 | end 43 | end 44 | 45 | Faraday::Request.register_middleware(elastomer_compress: ElastomerClient::Middleware::Compress) 46 | -------------------------------------------------------------------------------- /lib/elastomer_client/middleware/encode_json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | module Middleware 5 | # Request middleware that encodes the body as JSON. 6 | # 7 | # Processes only requests with matching Content-type or those without a type. 8 | # If a request doesn't have a type but has a body, it sets the Content-type 9 | # to JSON MIME-type. 10 | # 11 | # Doesn't try to encode bodies that already are in string form. 12 | class EncodeJson < Faraday::Middleware 13 | CONTENT_TYPE = "Content-Type".freeze 14 | MIME_TYPE = "application/json".freeze 15 | 16 | def call(env) 17 | match_content_type(env) do |data| 18 | env[:body] = encode data 19 | end 20 | @app.call env 21 | end 22 | 23 | def encode(data) 24 | MultiJson.dump data 25 | end 26 | 27 | def match_content_type(env) 28 | add_content_type!(env) 29 | if process_request?(env) 30 | yield env[:body] unless env[:body].respond_to?(:to_str) 31 | end 32 | end 33 | 34 | def process_request?(env) 35 | type = request_type(env) 36 | has_body?(env) && (type.empty? || type == MIME_TYPE) 37 | end 38 | 39 | def has_body?(env) 40 | (body = env[:body]) && !(body.respond_to?(:to_str) && body.empty?) 41 | end 42 | 43 | def request_type(env) 44 | type = env[:request_headers][CONTENT_TYPE].to_s 45 | type = type.split(";", 2).first if type.index(";") 46 | type 47 | end 48 | 49 | def add_content_type!(env) 50 | method = env[:method] 51 | if method == :put || method == :post || has_body?(env) 52 | env[:request_headers][CONTENT_TYPE] ||= MIME_TYPE 53 | end 54 | end 55 | end 56 | end 57 | end 58 | 59 | Faraday::Request.register_middleware \ 60 | encode_json: ElastomerClient::Middleware::EncodeJson 61 | -------------------------------------------------------------------------------- /lib/elastomer_client/middleware/limit_size.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | module Middleware 5 | 6 | # Request middleware that raises an exception if the request body exceeds a 7 | # `max_request_size`. 8 | class LimitSize < Faraday::Middleware 9 | 10 | def initialize(app = nil, options = {}) 11 | super(app) 12 | @max_request_size = options.fetch(:max_request_size) 13 | end 14 | 15 | attr_reader :max_request_size 16 | 17 | def call(env) 18 | if body = env[:body] 19 | if body.is_a?(String) && body.bytesize > max_request_size 20 | raise ::ElastomerClient::Client::RequestSizeError, 21 | "Request of size `#{body.bytesize}` exceeds the maximum requst size: #{max_request_size}" 22 | end 23 | end 24 | @app.call(env) 25 | end 26 | 27 | end 28 | end 29 | end 30 | 31 | Faraday::Request.register_middleware \ 32 | limit_size: ElastomerClient::Middleware::LimitSize 33 | -------------------------------------------------------------------------------- /lib/elastomer_client/middleware/opaque_id.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | module ElastomerClient 6 | module Middleware 7 | 8 | # This Faraday middleware implements the "X-Opaque-Id" request / response 9 | # headers for Elasticsearch. The X-Opaque-Id header, when provided on the 10 | # request header, will be returned as a header in the response. This is 11 | # useful in environments which reuse connections to ensure that cross-talk 12 | # does not occur between two requests. 13 | # 14 | # The SecureRandom lib is used to generate a UUID string for each request. 15 | # This value is used as the content for the "X-Opaque-Id" header. If the 16 | # value is different between the request and the response, then an 17 | # `ElastomerClient::Client::OpaqueIdError` is raised. In this case no response 18 | # will be returned. 19 | # 20 | # See [Elasticsearch "X-Opaque-Id" 21 | # header](https://github.com/elasticsearch/elasticsearch/issues/1202) 22 | # for more details. 23 | class OpaqueId < ::Faraday::Middleware 24 | X_OPAQUE_ID = "X-Opaque-Id".freeze 25 | COUNTER_MAX = 2**32 - 1 26 | 27 | # Faraday middleware implementation. 28 | # 29 | # env - Faraday environment Hash 30 | # 31 | # Returns the environment Hash 32 | def call(env) 33 | uuid = generate_uuid.freeze 34 | env[:request_headers][X_OPAQUE_ID] = uuid 35 | 36 | @app.call(env).on_complete do |renv| 37 | response_uuid = renv[:response_headers][X_OPAQUE_ID] 38 | # Don't raise OpaqueIdError if the response is a 5xx 39 | if !response_uuid.nil? && uuid != response_uuid && renv.status < 500 40 | raise ::ElastomerClient::Client::OpaqueIdError, 41 | "Conflicting 'X-Opaque-Id' headers: request #{uuid.inspect}, response #{response_uuid.inspect}" 42 | end 43 | end 44 | end 45 | 46 | # Generate a UUID using the built-in SecureRandom class. This can be a 47 | # little slow at times, so we will reuse the same UUID and append an 48 | # incrementing counter. 49 | # 50 | # Returns the UUID string. 51 | def generate_uuid 52 | t = Thread.current 53 | 54 | unless t.key? :opaque_id_base 55 | t[:opaque_id_base] = (SecureRandom.urlsafe_base64(12) + "%08x").freeze 56 | t[:opaque_id_counter] = -1 57 | end 58 | 59 | t[:opaque_id_counter] += 1 60 | t[:opaque_id_counter] = 0 if t[:opaque_id_counter] > COUNTER_MAX 61 | t[:opaque_id_base] % t[:opaque_id_counter] 62 | end 63 | 64 | end # OpaqueId 65 | end # Middleware 66 | 67 | # Error raised when a conflict is detected between the UUID sent in the 68 | # 'X-Opaque-Id' request header and the one received in the response header. 69 | Client::OpaqueIdError = Class.new Client::Error 70 | 71 | end # ElastomerClient 72 | 73 | Faraday::Request.register_middleware \ 74 | opaque_id: ElastomerClient::Middleware::OpaqueId 75 | -------------------------------------------------------------------------------- /lib/elastomer_client/middleware/parse_json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | module Middleware 5 | 6 | # Parse response bodies as JSON. 7 | class ParseJson < Faraday::Middleware 8 | CONTENT_TYPE = "Content-Type".freeze 9 | MIME_TYPE = "application/json".freeze 10 | 11 | def call(environment) 12 | @app.call(environment).on_complete do |env| 13 | if process_response?(env) 14 | env[:body] = parse env[:body] 15 | end 16 | end 17 | end 18 | 19 | # Parse the response body. 20 | def parse(body) 21 | MultiJson.load(body) if body.respond_to?(:to_str) && !body.strip.empty? 22 | rescue StandardError, SyntaxError => e 23 | raise Faraday::ParsingError, e 24 | end 25 | 26 | def process_response?(env) 27 | type = response_type(env) 28 | type.empty? || type == MIME_TYPE 29 | end 30 | 31 | def response_type(env) 32 | type = env[:response_headers][CONTENT_TYPE].to_s 33 | type = type.split(";", 2).first if type.index(";") 34 | type 35 | end 36 | end 37 | end 38 | end 39 | 40 | Faraday::Response.register_middleware \ 41 | parse_json: ElastomerClient::Middleware::ParseJson 42 | -------------------------------------------------------------------------------- /lib/elastomer_client/notifications.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support" 4 | require "active_support/notifications" 5 | require "securerandom" 6 | require "elastomer_client/client" 7 | 8 | module ElastomerClient 9 | 10 | # So you want to get notifications from your Elasticsearch client? Well, 11 | # you've come to the right place! 12 | # 13 | # require 'elastomer_client/notifications' 14 | # 15 | # Requiring this module will add ActiveSupport notifications to all 16 | # Elasticsearch requests. To subscribe to those requests ... 17 | # 18 | # ActiveSupport::Notifications.subscribe('request.client.elastomer') do |name, start_time, end_time, _, payload| 19 | # duration = end_time - start_time 20 | # $stderr.puts '[%s] %s %s (%.3f)' % [payload[:status], payload[:index], payload[:action], duration] 21 | # end 22 | # 23 | # The payload contains the following bits of information: 24 | # 25 | # * :index - index name (if any) 26 | # * :type - document type (if any) 27 | # * :action - the action being performed 28 | # * :url - request URL 29 | # * :method - request method (:head, :get, :put, :post, :delete) 30 | # * :status - response status code 31 | # 32 | # If you want to use your own notifications service then you will need to 33 | # let ElastomerClient know by setting the `service` here in the Notifications 34 | # module. The service should adhere to the ActiveSupport::Notifications 35 | # specification. 36 | # 37 | # ElastomerClient::Notifications.service = your_own_service 38 | # 39 | module Notifications 40 | 41 | class << self 42 | attr_accessor :service 43 | end 44 | 45 | # The name to subscribe to for notifications 46 | NAME = "request.client.elastomer".freeze 47 | 48 | # Internal: Execute the given block and provide instrumentation info to 49 | # subscribers. The name we use for subscriptions is 50 | # `request.client.elastomer` and a supplemental payload is provided with 51 | # more information about the specific Elasticsearch request. 52 | # 53 | # path - The full request path as a String 54 | # body - The request body as a String or `nil` 55 | # params - The request params Hash 56 | # block - The block that will be instrumented 57 | # 58 | # Returns the response from the block 59 | def instrument(path, body, params) 60 | payload = { 61 | index: params[:index], 62 | type: params[:type], 63 | action: params[:action], 64 | context: params[:context], 65 | request_body: body, 66 | body: # for backwards compatibility 67 | } 68 | 69 | ::ElastomerClient::Notifications.service.instrument(NAME, payload) do 70 | response = yield 71 | payload[:url] = response.env[:url] 72 | payload[:method] = response.env[:method] 73 | payload[:status] = response.status 74 | payload[:response_body] = response.body 75 | response 76 | end 77 | end 78 | end 79 | 80 | # use ActiveSupport::Notifications as the default instrumentation service 81 | Notifications.service = ActiveSupport::Notifications 82 | 83 | # inject our instrument method into the Client class 84 | class Client 85 | remove_method :instrument 86 | include ::ElastomerClient::Notifications 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/elastomer_client/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | VERSION = "6.2.3" 5 | 6 | def self.version 7 | VERSION 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/elastomer_client/version_support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | class VersionSupport 5 | 6 | attr_reader :version 7 | 8 | # version - an Elasticsearch version string e.g., 5.6.6 or 8.7.0 9 | # 10 | # Raises ArgumentError if version is unsupported. 11 | def initialize(version) 12 | if version < "5.0" || version >= "9.0" 13 | raise ArgumentError, "Elasticsearch version #{version} is not supported by elastomer-client" 14 | end 15 | 16 | @version = version 17 | end 18 | 19 | # Returns true if Elasticsearch version is 8.x or higher. 20 | def es_version_8_plus? 21 | version >= "8.0.0" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | cd "$(dirname "$0:a")/.." 5 | if bundle check 1>/dev/null 2>&1; then 6 | echo "Gem environment up-to-date" 7 | else 8 | echo "Installing gem dependencies" 9 | exec bundle install "$@" 10 | exec bundle binstubs --all 11 | fi 12 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "irb" 5 | require "rubygems" 6 | require "bundler/setup" 7 | 8 | $LOAD_PATH.unshift "lib" 9 | require "elastomer_client/client" 10 | 11 | IRB.start 12 | -------------------------------------------------------------------------------- /script/generate-rest-api-spec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Usage: 5 | # 6 | # script/generate-rest-api-spec 7 | # 8 | # Use this script to generate a REST API spec for the given 9 | # `elasticserach-version`. This will create a new `ApiSpec` class configured 10 | # to validate the request parameters for the particular Elasticsearch version. 11 | 12 | require "erb" 13 | require "rubygems" 14 | require "bundler/setup" 15 | 16 | $LOAD_PATH.unshift "lib" 17 | require "elastomer_client/client" 18 | require "elastomer_client/version_support" 19 | 20 | class RestApiSpecGenerator 21 | WORKING_DIR = "vendor/elasticsearch" 22 | 23 | attr_reader :version, :short_version, :class_version 24 | 25 | def initialize(version = "8.18") 26 | @version = version 27 | 28 | sliced = @version.split(".").slice(0, 2) 29 | @short_version = sliced.join(".") 30 | @class_version = sliced.join("_") 31 | 32 | @version_support = ElastomerClient::VersionSupport.new(version) 33 | end 34 | 35 | # Setup the working directory and generate the Ruby API spec for the 36 | # elasticsearch version. 37 | def run 38 | setup 39 | File.open(ruby_spec_filename, "w") do |fd| 40 | fd.puts ERB.new(DATA.read, trim_mode: "-").result(binding) 41 | end 42 | ensure 43 | reset 44 | end 45 | 46 | # The name of the Ruby API spec file for this particular Elasticsearch version. 47 | def ruby_spec_filename 48 | "lib/elastomer_client/client/rest_api_spec/api_spec_v#{class_version}.rb" 49 | end 50 | 51 | # Returns true if the elasticserach working directory exists. 52 | def working_dir_exists? 53 | File.directory?(WORKING_DIR) && File.exist?(WORKING_DIR) 54 | end 55 | 56 | # Iterate over each of the REST API specs yield the name and the descriptor 57 | # hash for that particular API spec. 58 | def each_api 59 | Dir.glob("#{WORKING_DIR}/rest-api-spec/src/main/resources/rest-api-spec/api/*.json").sort.each do |filename| 60 | next if filename =~ /\/_common\.json\Z/ 61 | 62 | hash = MultiJson.load(File.read(filename)) 63 | key = hash.keys.first 64 | value = hash.values.first 65 | yield(key, value) 66 | end 67 | end 68 | 69 | # Iterate over each of the common request parameters and yield them as key / 70 | # value pairs. 71 | def each_common 72 | filename = "#{WORKING_DIR}/rest-api-spec/src/main/resources/rest-api-spec/api/_common.json" 73 | if File.exist? filename 74 | hash = MultiJson.load(File.read(filename)) 75 | hash["params"].each { |k, v| yield(k, v) } 76 | end 77 | end 78 | 79 | def generate_documentation(data) 80 | if @version_support.es_version_8_plus? 81 | data["documentation"]["url"].to_s 82 | else 83 | data["documentation"].to_s 84 | end 85 | end 86 | 87 | def generate_methods(data) 88 | if @version_support.es_version_8_plus? 89 | data["url"]["paths"].map { |h| h["methods"] }.flatten.uniq 90 | else 91 | Array(data["methods"]).to_s 92 | end 93 | end 94 | 95 | def generate_path(url) 96 | if @version_support.es_version_8_plus? 97 | url["paths"].map { |h| h["path"] }.flatten.uniq.first 98 | else 99 | url["path"] 100 | end 101 | end 102 | 103 | def generate_paths(url) 104 | if @version_support.es_version_8_plus? 105 | url["paths"].map { |h| h["path"] }.flatten.uniq 106 | else 107 | Array(url["paths"]).to_s 108 | end 109 | end 110 | 111 | def generate_parts(url) 112 | if @version_support.es_version_8_plus? 113 | url["paths"].map { |h| h["parts"] }.compact.reduce({}, :merge) 114 | else 115 | url["parts"] 116 | end 117 | end 118 | 119 | def generate_params(data) 120 | if @version_support.es_version_8_plus? 121 | data["params"] 122 | else 123 | data["url"]["params"] 124 | end 125 | end 126 | 127 | 128 | # Perform a sparse checkout of the elasticsearch git repository and then check 129 | # out the branch corresponding to the ES version passed to this script. 130 | def setup 131 | if !working_dir_exists? 132 | system <<-SH 133 | mkdir -p #{WORKING_DIR} && 134 | cd #{WORKING_DIR} && 135 | git init . && 136 | git remote add -f origin https://github.com/elastic/elasticsearch.git && 137 | git config core.sparsecheckout true && 138 | echo /rest-api-spec/src/main/resources/rest-api-spec/api/ >> .git/info/sparse-checkout && 139 | git pull origin main 140 | SH 141 | end 142 | 143 | system <<-SH 144 | cd #{WORKING_DIR} && 145 | git pull origin main && 146 | git checkout -q origin/#{short_version} 147 | SH 148 | end 149 | 150 | # Reset the elasticsearch working directory back to the main branch of the 151 | # git repository. 152 | def reset 153 | system <<-SH 154 | cd #{WORKING_DIR} && 155 | git checkout main 156 | SH 157 | end 158 | end 159 | 160 | puts RestApiSpecGenerator.new(*ARGV).run 161 | 162 | __END__ 163 | # Generated REST API spec file - DO NOT EDIT! 164 | # Date: <%= Time.now.strftime("%Y-%m-%d") %> 165 | # ES version: <%= version %> 166 | 167 | module ElastomerClient::Client::RestApiSpec 168 | class ApiSpecV<%= class_version %> < ApiSpec 169 | def initialize 170 | @rest_apis = { 171 | <%- each_api do |name,data| -%> 172 | <%- url = data["url"] -%> 173 | "<%= name %>" => RestApi.new( 174 | documentation: "<%= generate_documentation(data) %>", 175 | methods: <%= generate_methods(data) %>, 176 | body: <%= data["body"] ? data["body"].to_s : "nil" %>, 177 | url: { 178 | path: "<%= generate_path(url) %>", 179 | paths: <%= generate_paths(url) %>, 180 | <% if (parts = generate_parts(url)) && !parts.empty? -%> 181 | parts: { 182 | <% parts.each do |k,v| -%> 183 | "<%= k %>" => <%= v.to_s %>, 184 | <% end -%> 185 | }, 186 | <% end -%> 187 | <% params = generate_params(data) -%> 188 | <% if !params.nil? && !params.empty? -%> 189 | params: { 190 | <% params.each do |k,v| -%> 191 | "<%= k %>" => <%= v.to_s %>, 192 | <% end -%> 193 | } 194 | <% end -%> 195 | } 196 | ), 197 | <% end -%> 198 | } 199 | @common_params = { 200 | <% each_common do |k,v| -%> 201 | "<%= k %>" => <%= v.to_s %>, 202 | <% end -%> 203 | } 204 | super 205 | end 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /script/poll-for-es: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script will poll the Elasticsearch health endpoint until the cluster 4 | # reaches a yellow state which is good enough for testing. This script will poll 5 | # for up to 30 seconds waiting for Elasticsearch to start. It will give up at 6 | # that time and return a non-zero exit code. 7 | 8 | es_port=${ES_PORT:-9200} 9 | count=0 10 | 11 | until $(curl -s "localhost:${es_port}/_cluster/health?wait_for_status=yellow&timeout=30s" > /dev/null 2>&1); do 12 | sleep 0.50 13 | count=$(($count+1)) 14 | if [ "$count" -gt 60 ]; then 15 | echo "Timed out waiting for Elasticsearch at localhost:${es_port}" 16 | exit 1 17 | fi 18 | done 19 | 20 | echo "Elasticsearch is ready at localhost:${es_port}" 21 | -------------------------------------------------------------------------------- /script/setup-ccr: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Function to display help 4 | show_help() { 5 | echo "Usage: $0 [option] [license]" 6 | echo "Options:" 7 | echo " up - Start the clusters and apply the license" 8 | echo " down - Shut down the clusters" 9 | echo " help - Display this help message" 10 | } 11 | 12 | # Function to apply the license to a cluster 13 | apply_license() { 14 | local port=$1 15 | local license="$2" 16 | local response_file=$(mktemp) 17 | local http_code 18 | http_code=$(curl -s -o "$response_file" -w "%{http_code}" -X PUT "http://localhost:$port/_license?pretty" -H "Content-Type: application/json" -d "$license") 19 | 20 | if [ "$http_code" -ne 200 ]; then 21 | echo "Failed to apply license to cluster on port $port. HTTP status code: $http_code" 22 | echo "Error response: $(cat "$response_file")" 23 | rm "$response_file" 24 | exit 1 25 | fi 26 | } 27 | 28 | # Function to shut down the clusters 29 | shutdown_clusters() { 30 | docker compose --project-directory docker --profile ccr down 31 | echo "Clusters shut down." 32 | } 33 | 34 | # Check for options 35 | case "$1" in 36 | up) 37 | 38 | # Get the directory of the current script 39 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 40 | 41 | # Start the clusters 42 | docker compose --project-directory docker --profile ccr up -d 43 | 44 | # Wait for both clusters to be online 45 | export ES_PORT=9208 46 | "$SCRIPT_DIR/poll-for-es" 47 | export ES_PORT=9209 48 | "$SCRIPT_DIR/poll-for-es" 49 | 50 | # Apply the license to both clusters 51 | LICENSE=$2 52 | if [ -z "$LICENSE" ]; then 53 | echo "License key is required as the second argument." 54 | exit 1 55 | fi 56 | 57 | echo "Applying license to cluster on port 9208..." 58 | apply_license 9208 "$LICENSE" 59 | echo "Applying license to cluster on port 9209..." 60 | apply_license 9209 "$LICENSE" 61 | echo "License applied to both clusters." 62 | 63 | # Set up the remote connection between the clusters 64 | curl -X PUT "http://localhost:9209/_cluster/settings" -H "Content-Type: application/json" -d '{ 65 | "persistent": { 66 | "cluster": { 67 | "remote": { 68 | "leader": { 69 | "seeds": ["es8.18:9300"] 70 | } 71 | } 72 | } 73 | } 74 | }' 75 | 76 | echo "Clusters setup completed." 77 | ;; 78 | down) 79 | shutdown_clusters 80 | ;; 81 | help) 82 | show_help 83 | ;; 84 | *) 85 | echo "Invalid option: $1" 86 | show_help 87 | exit 1 88 | ;; 89 | esac 90 | -------------------------------------------------------------------------------- /test/assertions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest::Assertions 4 | # COMPATIBILITY 5 | # ES8+ response uses "result" instead of "created" 6 | def assert_created(response) 7 | assert $client.version_support.es_version_8_plus? ? response["result"] == "created" : response["created"], "document was not created" 8 | end 9 | 10 | def assert_acknowledged(response) 11 | assert response["acknowledged"], "document was not acknowledged" 12 | end 13 | 14 | def assert_found(response) 15 | assert response["found"], "document was not found" 16 | end 17 | 18 | def refute_found(response) 19 | refute response["found"] || response["exists"], "document was unexpectedly found" 20 | end 21 | 22 | def assert_bulk_index(item, message = "bulk index did not succeed") 23 | status = item["index"]["status"] 24 | 25 | assert_equal(201, status, message) 26 | end 27 | 28 | def assert_bulk_create(item, message = "bulk create did not succeed") 29 | status = item["create"]["status"] 30 | 31 | assert_equal(201, status, message) 32 | end 33 | 34 | def assert_bulk_delete(item, message = "bulk delete did not succeed") 35 | status = item["delete"]["status"] 36 | 37 | assert_equal(200, status, message) 38 | end 39 | 40 | # COMPATIBILITY 41 | # ES8+ no longer supports types 42 | def assert_mapping_exists(response, type, message = "mapping expected to exist, but doesn't") 43 | mapping = 44 | if $client.version_support.es_version_8_plus? 45 | response["mappings"] 46 | else 47 | response["mappings"][type] 48 | end 49 | 50 | refute_nil mapping, message 51 | end 52 | 53 | # COMPATIBILITY 54 | # ES8+ no longer supports types 55 | def assert_property_exists(response, type, property, message = "property expected to exist, but doesn't") 56 | mapping = 57 | if response.has_key?("mappings") 58 | if $client.version_support.es_version_8_plus? 59 | response["mappings"] 60 | else 61 | response["mappings"][type] 62 | end 63 | else 64 | response[type] 65 | end 66 | 67 | assert mapping["properties"].has_key?(property), message 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/client/ccr_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Ccr do 6 | before do 7 | skip "Cannot test Ccr API without a replica cluster" unless $replica_client.available? 8 | 9 | begin 10 | ccr.delete_auto_follow("follower_pattern") 11 | rescue StandardError 12 | puts "No auto-follow pattern to delete" 13 | end 14 | 15 | @leader_index = $client.index("leader_index") 16 | @follower_index = $replica_client.index("follower_index") 17 | @auto_followed_index = $client.index("followed_index") 18 | @auto_follower_index = $replica_client.index("followed_index-follower") 19 | 20 | if @leader_index.exists? 21 | @leader_index.delete 22 | end 23 | if @auto_followed_index.exists? 24 | @auto_followed_index.delete 25 | end 26 | if @follower_index.exists? 27 | @follower_index.delete 28 | end 29 | if @auto_follower_index.exists? 30 | @auto_follower_index.delete 31 | end 32 | 33 | @leader_index.create(default_index_settings) 34 | wait_for_index(@leader_index.name, "green") 35 | end 36 | 37 | after do 38 | @leader_index.delete if @leader_index.exists? 39 | @follower_index.delete if @follower_index.exists? 40 | @auto_followed_index.delete if @auto_followed_index.exists? 41 | @auto_follower_index.delete if @auto_follower_index.exists? 42 | 43 | begin 44 | ccr.delete_auto_follow("follower_pattern") 45 | rescue StandardError 46 | puts "No auto-follow pattern to delete" 47 | end 48 | end 49 | 50 | def follow_index(follower_index_name, leader_index_name) 51 | ccr = $replica_client.ccr 52 | response = ccr.follow(follower_index_name, { leader_index: leader_index_name, remote_cluster: "leader" }) 53 | wait_for_index(follower_index_name, "green") 54 | response 55 | end 56 | 57 | def pause_follow(follower_index_name) 58 | ccr = $replica_client.ccr 59 | response = ccr.pause_follow(follower_index_name) 60 | wait_for_index(follower_index_name, "green") 61 | response 62 | end 63 | 64 | def unfollow_index(follower_index_name) 65 | ccr = $replica_client.ccr 66 | response = ccr.unfollow(follower_index_name) 67 | wait_for_index(follower_index_name, "green") 68 | response 69 | end 70 | 71 | def create_document(index, type, document) 72 | response = index.docs.index(document_wrapper(type, document)) 73 | index.refresh 74 | response 75 | end 76 | 77 | it "successfully follows a leader index" do 78 | create_document(@leader_index, "book", { _id: 1, title: "Book 1" }) 79 | follow_index(@follower_index.name, @leader_index.name) 80 | 81 | doc = @follower_index.docs.get(id: 1, type: "book") 82 | 83 | assert_equal "Book 1", doc["_source"]["title"] 84 | end 85 | 86 | it "successfully gets info for all follower indices" do 87 | follow_index(@follower_index.name, @leader_index.name) 88 | 89 | response = $replica_client.ccr.get_follower_info("*") 90 | 91 | assert_equal response["follower_indices"][0]["follower_index"], @follower_index.name 92 | assert_equal response["follower_indices"][0]["leader_index"], @leader_index.name 93 | end 94 | 95 | it "successfully pauses a follower index" do 96 | follow_index(@follower_index.name, @leader_index.name) 97 | 98 | response = pause_follow(@follower_index.name) 99 | 100 | assert response["acknowledged"] 101 | 102 | create_document(@leader_index, "book", { _id: 2, title: "Book 2" }) 103 | 104 | doc = @follower_index.docs.get(id: 2, type: "book") 105 | 106 | refute doc["found"] 107 | end 108 | 109 | it "successfully unfollow a leader index" do 110 | follow_index(@follower_index.name, @leader_index.name) 111 | 112 | pause_follow(@follower_index.name) 113 | 114 | @follower_index.close 115 | 116 | response = unfollow_index(@follower_index.name) 117 | 118 | assert response["acknowledged"] 119 | 120 | @follower_index.open 121 | 122 | wait_for_index(@follower_index.name, "green") 123 | 124 | create_document(@leader_index, "book", { _id: 2, title: "Book 2" }) 125 | 126 | doc = @follower_index.docs.get(id: 2, type: "book") 127 | 128 | refute doc["found"] 129 | end 130 | 131 | it "successfully implements an auto-follow policy" do 132 | ccr = $replica_client.ccr 133 | 134 | ccr.auto_follow("follower_pattern", { remote_cluster: "leader", leader_index_patterns: ["*"], follow_index_pattern: "{{leader_index}}-follower" }) 135 | 136 | @auto_followed_index.create(default_index_settings) 137 | wait_for_index(@auto_followed_index.name, "green") 138 | 139 | @auto_follower_index = $replica_client.index("followed_index-follower") 140 | wait_for_index(@auto_follower_index.name, "green") 141 | 142 | resp = ccr.get_auto_follow(pattern_name: "follower_pattern") 143 | 144 | assert_equal "follower_pattern", resp["patterns"].first["name"] 145 | 146 | assert_predicate @auto_follower_index, :exists? 147 | end 148 | 149 | end 150 | -------------------------------------------------------------------------------- /test/client/cluster_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Cluster do 6 | 7 | before do 8 | @name = "elastomer-cluster-test" 9 | @index = $client.index @name 10 | @index.delete if @index.exists? 11 | @cluster = $client.cluster 12 | end 13 | 14 | after do 15 | @index.delete if @index.exists? 16 | end 17 | 18 | it "gets the cluster health" do 19 | h = @cluster.health 20 | 21 | assert h.key?("cluster_name"), "the cluster name is returned" 22 | assert h.key?("status"), "the cluster status is returned" 23 | end 24 | 25 | it "gets the cluster state" do 26 | h = @cluster.state 27 | 28 | assert h.key?("cluster_name"), "the cluster name is returned" 29 | assert h.key?("master_node"), "the master node is returned" 30 | assert_instance_of Hash, h["nodes"], "the node list is returned" 31 | assert_instance_of Hash, h["metadata"], "the metadata are returned" 32 | end 33 | 34 | it "filters cluster state by metrics" do 35 | h = @cluster.state(metrics: "nodes") 36 | 37 | refute h.key("metadata"), "expected only nodes state" 38 | h = @cluster.state(metrics: "metadata") 39 | 40 | refute h.key("nodes"), "expected only metadata state" 41 | end 42 | 43 | it "filters cluster state by indices" do 44 | @index.create(default_index_settings) unless @index.exists? 45 | h = @cluster.state(metrics: "metadata", indices: @name) 46 | 47 | assert_equal [@name], h["metadata"]["indices"].keys 48 | end 49 | 50 | it "gets the cluster settings" do 51 | h = @cluster.get_settings 52 | 53 | assert_instance_of Hash, h["persistent"], "the persistent settings are returned" 54 | assert_instance_of Hash, h["transient"], "the transient settings are returned" 55 | end 56 | 57 | it "gets the cluster settings with .settings" do 58 | h = @cluster.settings 59 | 60 | assert_instance_of Hash, h["persistent"], "the persistent settings are returned" 61 | assert_instance_of Hash, h["transient"], "the transient settings are returned" 62 | end 63 | 64 | it "updates the cluster settings" do 65 | @cluster.update_settings transient: { "indices.recovery.max_bytes_per_sec" => "30mb" } 66 | h = @cluster.settings 67 | 68 | value = h["transient"]["indices"]["recovery"]["max_bytes_per_sec"] 69 | 70 | assert_equal "30mb", value 71 | 72 | @cluster.update_settings transient: { "indices.recovery.max_bytes_per_sec" => "60mb" } 73 | h = @cluster.settings 74 | 75 | value = h["transient"]["indices"]["recovery"]["max_bytes_per_sec"] 76 | 77 | assert_equal "60mb", value 78 | end 79 | 80 | it "returns cluster stats" do 81 | h = @cluster.stats 82 | expected = $client.version_support.es_version_8_plus? ? %w[ccs cluster_name cluster_uuid indices nodes repositories snapshots status timestamp] : %w[cluster_name indices nodes status timestamp] 83 | 84 | expected.unshift("_nodes") 85 | 86 | assert_equal expected, h.keys.sort 87 | end 88 | 89 | it "returns a list of pending tasks" do 90 | h = @cluster.pending_tasks 91 | 92 | assert_equal %w[tasks], h.keys.sort 93 | assert_kind_of Array, h["tasks"], "the tasks lists is always an Array even if empty" 94 | end 95 | 96 | it "returns the list of indices in the cluster" do 97 | @index.create(default_index_settings) unless @index.exists? 98 | indices = @cluster.indices 99 | 100 | refute_empty indices, "expected to see an index" 101 | end 102 | 103 | it "returns the list of nodes in the cluster" do 104 | nodes = @cluster.nodes 105 | 106 | refute_empty nodes, "we have to have some nodes" 107 | end 108 | 109 | describe "when working with aliases" do 110 | before do 111 | @name = "elastomer-cluster-test" 112 | @index = $client.index @name 113 | @index.create(default_index_settings) unless @index.exists? 114 | wait_for_index(@name) 115 | end 116 | 117 | after do 118 | @index.delete if @index.exists? 119 | end 120 | 121 | it "adds and gets an alias" do 122 | hash = @cluster.get_aliases 123 | 124 | assert_empty hash[@name]["aliases"] 125 | 126 | @cluster.update_aliases \ 127 | add: {index: @name, alias: "elastomer-test-unikitty"} 128 | 129 | hash = @cluster.get_aliases 130 | 131 | assert_equal ["elastomer-test-unikitty"], hash[@name]["aliases"].keys 132 | end 133 | 134 | it "adds and gets an alias with .aliases" do 135 | hash = @cluster.aliases 136 | 137 | assert_empty hash[@name]["aliases"] 138 | 139 | @cluster.update_aliases \ 140 | add: {index: @name, alias: "elastomer-test-unikitty"} 141 | 142 | hash = @cluster.aliases 143 | 144 | assert_equal ["elastomer-test-unikitty"], hash[@name]["aliases"].keys 145 | end 146 | 147 | it "removes an alias" do 148 | @cluster.update_aliases \ 149 | add: {index: @name, alias: "elastomer-test-unikitty"} 150 | 151 | hash = @cluster.get_aliases 152 | 153 | assert_equal ["elastomer-test-unikitty"], hash[@name]["aliases"].keys 154 | 155 | @cluster.update_aliases([ 156 | {add: {index: @name, alias: "elastomer-test-SpongeBob-SquarePants"}}, 157 | {remove: {index: @name, alias: "elastomer-test-unikitty"}} 158 | ]) 159 | 160 | hash = @cluster.get_aliases 161 | 162 | assert_equal ["elastomer-test-SpongeBob-SquarePants"], hash[@name]["aliases"].keys 163 | end 164 | 165 | it "accepts the full aliases actions hash" do 166 | @cluster.update_aliases actions: [ 167 | {add: {index: @name, alias: "elastomer-test-He-Man"}}, 168 | {add: {index: @name, alias: "elastomer-test-Skeletor"}} 169 | ] 170 | 171 | hash = @cluster.get_aliases(index: @name) 172 | 173 | assert_equal %w[elastomer-test-He-Man elastomer-test-Skeletor], hash[@name]["aliases"].keys.sort 174 | end 175 | end 176 | 177 | end 178 | -------------------------------------------------------------------------------- /test/client/errors_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Error do 6 | 7 | it "is instantiated with a simple message" do 8 | err = ElastomerClient::Client::Error.new "something went wrong" 9 | 10 | assert_equal "something went wrong", err.message 11 | end 12 | 13 | it "is instantiated from an HTTP response" do 14 | response = Faraday::Response.new(body: "UTF8Error invalid middle-byte") 15 | err = ElastomerClient::Client::Error.new(response) 16 | 17 | assert_equal "UTF8Error invalid middle-byte", err.message 18 | 19 | response = Faraday::Response.new(body: {"error" => "IndexMissingException"}) 20 | err = ElastomerClient::Client::Error.new(response) 21 | 22 | assert_equal "IndexMissingException", err.message 23 | assert_equal "IndexMissingException", err.error 24 | 25 | body = { 26 | "error" => { 27 | "index" => "non-existent-index", 28 | "reason" => "no such index", 29 | "resource.id" => "non-existent-index", 30 | "resource.type" => "index_or_alias", 31 | "root_cause"=> [{ 32 | "index" => "non-existent-index", 33 | "reason" => "no such index", 34 | "resource.id" => "non-existent-index", 35 | "resource.type" => "index_or_alias", 36 | "type" => "index_not_found_exception" 37 | }], 38 | "type" => "index_not_found_exception" 39 | }, 40 | "status" => 404 41 | } 42 | response = Faraday::Response.new(body:) 43 | err = ElastomerClient::Client::Error.new(response) 44 | 45 | assert_equal body["error"].to_s, err.message 46 | assert_equal body["error"], err.error 47 | end 48 | 49 | it "is instantiated from another exception" do 50 | err = Faraday::ConnectionFailed.new "could not connect to host" 51 | err.set_backtrace %w[one two three four] 52 | 53 | err = ElastomerClient::Client::Error.new(err, "POST", "/index/doc") 54 | 55 | assert_equal "could not connect to host :: POST /index/doc", err.message 56 | assert_equal %w[one two three four], err.backtrace 57 | end 58 | 59 | it "is fatal by default" do 60 | assert ElastomerClient::Client::Error.fatal, "client errors are fatal by default" 61 | 62 | error = ElastomerClient::Client::Error.new "oops!" 63 | 64 | refute_predicate error, :retry?, "client errors are not retryable by default" 65 | end 66 | 67 | it "supports .fatal? alias" do 68 | assert_predicate ElastomerClient::Client::Error, :fatal?, "client errors support .fatal?" 69 | end 70 | 71 | it "has some fatal subclasses" do 72 | assert ElastomerClient::Client::ResourceNotFound.fatal, "Resource not found is fatal" 73 | assert ElastomerClient::Client::ParsingError.fatal, "Parsing error is fatal" 74 | assert ElastomerClient::Client::SSLError.fatal, "SSL error is fatal" 75 | assert ElastomerClient::Client::RequestError.fatal, "Request error is fatal" 76 | assert ElastomerClient::Client::DocumentAlreadyExistsError.fatal, "DocumentAlreadyExistsError error is fatal" 77 | end 78 | 79 | it "has some non-fatal subclasses" do 80 | refute ElastomerClient::Client::TimeoutError.fatal, "Timeouts are not fatal" 81 | refute ElastomerClient::Client::ConnectionFailed.fatal, "Connection failures are not fatal" 82 | refute ElastomerClient::Client::ServerError.fatal, "Server errors are not fatal" 83 | refute ElastomerClient::Client::RejectedExecutionError.fatal, "Rejected execution errors are not fatal" 84 | end 85 | 86 | it "wraps illegal argument exceptions" do 87 | begin 88 | $client.get("/_cluster/health?consistency=all") 89 | 90 | assert false, "IllegalArgument exception was not raised" 91 | rescue ElastomerClient::Client::IllegalArgument => err 92 | assert_match(/request \[\/_cluster\/health\] contains unrecognized parameter: \[consistency\]/, err.message) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/client/multi_percolate_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::MultiPercolate do 6 | 7 | before do 8 | if $client.version_support.es_version_8_plus? 9 | skip "Multi percolate not supported in ES version #{$client.version}" 10 | end 11 | 12 | @name = "elastomer-mpercolate-test" 13 | @index = $client.index(@name) 14 | 15 | unless @index.exists? 16 | base_mappings_settings = { 17 | settings: { "index.number_of_shards" => 1, "index.number_of_replicas" => 0 }, 18 | mappings: { 19 | doc1: { 20 | _source: { enabled: true }, _all: { enabled: false }, 21 | properties: { 22 | title: { type: "text", analyzer: "standard" }, 23 | author: { type: "keyword" } 24 | } 25 | }, 26 | doc2: { 27 | _source: { enabled: true }, _all: { enabled: false }, 28 | properties: { 29 | title: { type: "text", analyzer: "standard" }, 30 | author: { type: "keyword" } 31 | } 32 | } 33 | } 34 | } 35 | 36 | base_mappings_settings[:mappings][:percolator] = { properties: { query: { type: "percolator" } } } 37 | 38 | @index.create base_mappings_settings 39 | wait_for_index(@name) 40 | end 41 | 42 | @docs = @index.docs 43 | end 44 | 45 | after do 46 | @index.delete if @index.exists? 47 | end 48 | 49 | it "performs multi percolate queries" do 50 | populate! 51 | 52 | body = [ 53 | '{"percolate" : {"index": "elastomer-mpercolate-test", "type": "doc2"}}', 54 | '{"doc": {"author": "pea53"}}', 55 | '{"percolate" : {"index": "elastomer-mpercolate-test", "type": "doc2"}}', 56 | '{"doc": {"author": "grantr"}}', 57 | '{"count" : {"index": "elastomer-mpercolate-test", "type": "doc2"}}', 58 | '{"doc": {"author": "grantr"}}', 59 | nil 60 | ] 61 | body = body.join "\n" 62 | h = $client.multi_percolate body 63 | response1, response2, response3 = h["responses"] 64 | 65 | assert_equal ["1", "2"], response1["matches"].map { |match| match["_id"] }.sort 66 | assert_equal ["1", "3"], response2["matches"].map { |match| match["_id"] }.sort 67 | assert_equal 2, response3["total"] 68 | end 69 | 70 | it "performs multi percolate queries with .mpercolate" do 71 | populate! 72 | 73 | body = [ 74 | '{"percolate" : {"index": "elastomer-mpercolate-test", "type": "doc2"}}', 75 | '{"doc": {"author": "pea53"}}', 76 | '{"percolate" : {"index": "elastomer-mpercolate-test", "type": "doc2"}}', 77 | '{"doc": {"author": "grantr"}}', 78 | '{"count" : {"index": "elastomer-mpercolate-test", "type": "doc2"}}', 79 | '{"doc": {"author": "grantr"}}', 80 | nil 81 | ] 82 | body = body.join "\n" 83 | h = $client.mpercolate body 84 | response1, response2, response3 = h["responses"] 85 | 86 | assert_equal ["1", "2"], response1["matches"].map { |match| match["_id"] }.sort 87 | assert_equal ["1", "3"], response2["matches"].map { |match| match["_id"] }.sort 88 | assert_equal 2, response3["total"] 89 | end 90 | 91 | it "supports a nice block syntax" do 92 | populate! 93 | 94 | h = $client.multi_percolate(index: @name, type: "doc2") do |m| 95 | m.percolate author: "pea53" 96 | m.percolate author: "grantr" 97 | m.count({ author: "grantr" }) 98 | end 99 | 100 | response1, response2, response3 = h["responses"] 101 | 102 | assert_equal ["1", "2"], response1["matches"].map { |match| match["_id"] }.sort 103 | assert_equal ["1", "3"], response2["matches"].map { |match| match["_id"] }.sort 104 | assert_equal 2, response3["total"] 105 | end 106 | 107 | def populate! 108 | @docs.index \ 109 | _id: 1, 110 | _type: "doc1", 111 | title: "the author of gravatar", 112 | author: "mojombo" 113 | 114 | @docs.index \ 115 | _id: 2, 116 | _type: "doc1", 117 | title: "the author of resque", 118 | author: "defunkt" 119 | 120 | @docs.index \ 121 | _id: 1, 122 | _type: "doc2", 123 | title: "the author of logging", 124 | author: "pea53" 125 | 126 | @docs.index \ 127 | _id: 2, 128 | _type: "doc2", 129 | title: "the author of rubber-band", 130 | author: "grantr" 131 | 132 | percolator1 = @index.percolator "1" 133 | percolator1.create query: { match_all: { } } 134 | percolator2 = @index.percolator "2" 135 | percolator2.create query: { match: { author: "pea53" } } 136 | percolator2 = @index.percolator "3" 137 | percolator2.create query: { match: { author: "grantr" } } 138 | 139 | @index.refresh 140 | end 141 | # rubocop:enable Metrics/MethodLength 142 | end 143 | -------------------------------------------------------------------------------- /test/client/multi_search_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::MultiSearch do 6 | 7 | before do 8 | @name = "elastomer-msearch-test" 9 | @index = $client.index(@name) 10 | 11 | unless @index.exists? 12 | @index.create \ 13 | settings: { "index.number_of_shards" => 1, "index.number_of_replicas" => 0 }, 14 | mappings: mappings_wrapper("book", { 15 | _source: { enabled: true }, 16 | properties: { 17 | title: { type: "text", analyzer: "standard" }, 18 | author: { type: "keyword" } 19 | } 20 | }, !$client.version_support.es_version_8_plus?) 21 | wait_for_index(@name) 22 | end 23 | 24 | @docs = @index.docs 25 | end 26 | 27 | after do 28 | @index.delete if @index.exists? 29 | end 30 | 31 | it "performs multisearches" do 32 | populate! 33 | 34 | body = [ 35 | '{"index" : "elastomer-msearch-test"}', 36 | '{"query" : {"match_all" : {}}}', 37 | '{"index" : "elastomer-msearch-test"}', 38 | '{"query" : {"match": {"author" : "Author 2"}}}', 39 | nil 40 | ] 41 | body = body.join "\n" 42 | h = $client.multi_search body 43 | response1, response2 = h["responses"] 44 | 45 | if $client.version_support.es_version_8_plus? 46 | assert_equal 2, response1["hits"]["total"]["value"] 47 | assert_equal 1, response2["hits"]["total"]["value"] 48 | else 49 | assert_equal 2, response1["hits"]["total"] 50 | assert_equal 1, response2["hits"]["total"] 51 | end 52 | 53 | assert_equal "2", response2["hits"]["hits"][0]["_id"] 54 | 55 | body = [ 56 | "{}", 57 | '{"query" : {"match": {"author" : "Author 2"}}}', 58 | nil 59 | ] 60 | body = body.join "\n" 61 | h = $client.multi_search body, index: @name 62 | response1 = h["responses"].first 63 | 64 | if $client.version_support.es_version_8_plus? 65 | assert_equal 1, response1["hits"]["total"]["value"] 66 | else 67 | assert_equal 1, response1["hits"]["total"] 68 | end 69 | 70 | assert_equal "2", response1["hits"]["hits"][0]["_id"] 71 | end 72 | 73 | it "performs multisearches with .msearch" do 74 | populate! 75 | 76 | body = [ 77 | '{"index" : "elastomer-msearch-test"}', 78 | '{"query" : {"match_all" : {}}}', 79 | '{"index" : "elastomer-msearch-test"}', 80 | '{"query" : {"match": {"author" : "Author 2"}}}', 81 | nil 82 | ] 83 | body = body.join "\n" 84 | h = $client.msearch body 85 | response1, response2 = h["responses"] 86 | 87 | if $client.version_support.es_version_8_plus? 88 | assert_equal 2, response1["hits"]["total"]["value"] 89 | assert_equal 1, response2["hits"]["total"]["value"] 90 | else 91 | assert_equal 2, response1["hits"]["total"] 92 | assert_equal 1, response2["hits"]["total"] 93 | end 94 | 95 | assert_equal "2", response2["hits"]["hits"][0]["_id"] 96 | 97 | body = [ 98 | "{}", 99 | '{"query" : {"match": {"author" : "Author 2"}}}', 100 | nil 101 | ] 102 | body = body.join "\n" 103 | h = $client.msearch body, index: @name 104 | response1 = h["responses"].first 105 | 106 | if $client.version_support.es_version_8_plus? 107 | assert_equal 1, response1["hits"]["total"]["value"] 108 | else 109 | assert_equal 1, response1["hits"]["total"] 110 | end 111 | 112 | assert_equal "2", response1["hits"]["hits"][0]["_id"] 113 | end 114 | 115 | it "supports a nice block syntax" do 116 | populate! 117 | 118 | h = $client.multi_search do |m| 119 | m.search({query: { match_all: {}}}, index: @name) 120 | m.search({query: { match: { "title" => "author" }}}, index: @name) 121 | end 122 | 123 | response1, response2 = h["responses"] 124 | 125 | if $client.version_support.es_version_8_plus? 126 | assert_equal 2, response1["hits"]["total"]["value"] 127 | assert_equal 2, response2["hits"]["total"]["value"] 128 | else 129 | assert_equal 2, response1["hits"]["total"] 130 | assert_equal 2, response2["hits"]["total"] 131 | end 132 | 133 | h = @index.multi_search do |m| 134 | m.search({query: { match_all: {}}}) 135 | m.search({query: { match: { "title" => "author" }}}) 136 | end 137 | 138 | response1, response2 = h["responses"] 139 | 140 | if $client.version_support.es_version_8_plus? 141 | assert_equal 2, response1["hits"]["total"]["value"] 142 | assert_equal 2, response2["hits"]["total"]["value"] 143 | else 144 | assert_equal 2, response1["hits"]["total"] 145 | assert_equal 2, response2["hits"]["total"] 146 | end 147 | 148 | type = $client.version_support.es_version_8_plus? ? "" : "book" 149 | h = @index.docs(type).multi_search do |m| 150 | m.search({query: { match_all: {}}}) 151 | m.search({query: { match: { "title" => "2" }}}) 152 | end 153 | 154 | response1, response2 = h["responses"] 155 | 156 | if $client.version_support.es_version_8_plus? 157 | assert_equal 2, response1["hits"]["total"]["value"] 158 | assert_equal 1, response2["hits"]["total"]["value"] 159 | else 160 | assert_equal 2, response1["hits"]["total"] 161 | assert_equal 1, response2["hits"]["total"] 162 | end 163 | end 164 | 165 | it "performs suggestion queries using the search endpoint" do 166 | populate! 167 | 168 | h = @index.multi_search do |m| 169 | m.search({ 170 | query: { 171 | match: { 172 | title: "by author" 173 | } 174 | }, 175 | suggest: { 176 | suggestion1: { 177 | text: "by author", 178 | term: { 179 | field: "author" 180 | } 181 | } 182 | } 183 | }) 184 | end 185 | 186 | response = h["responses"][0] 187 | 188 | if $client.version_support.es_version_8_plus? 189 | assert_equal 2, response["hits"]["total"]["value"] 190 | else 191 | assert_equal 2, response["hits"]["total"] 192 | end 193 | 194 | refute_nil response["suggest"], "expected suggester text to be returned" 195 | end 196 | 197 | def populate! 198 | @docs.index \ 199 | document_wrapper("book", { 200 | _id: 1, 201 | title: "Book 1 by author 1", 202 | author: "Author 1" 203 | }) 204 | 205 | @docs.index \ 206 | document_wrapper("book", { 207 | _id: 2, 208 | title: "Book 2 by author 2", 209 | author: "Author 2" 210 | }) 211 | 212 | @index.refresh 213 | end 214 | # rubocop:enable Metrics/MethodLength 215 | end 216 | -------------------------------------------------------------------------------- /test/client/native_delete_by_query_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::NativeDeleteByQuery do 6 | before do 7 | @index = $client.index "elastomer-delete-by-query-test" 8 | @index.delete if @index.exists? 9 | @docs = @index.docs("docs") 10 | end 11 | 12 | after do 13 | @index.delete if @index.exists? 14 | end 15 | 16 | describe "when an index with documents exists" do 17 | before do 18 | @index.create(nil) 19 | wait_for_index(@index.name) 20 | end 21 | 22 | it "deletes by query" do 23 | @docs.index({ _id: 0, name: "mittens" }) 24 | @docs.index({ _id: 1, name: "luna" }) 25 | 26 | @index.refresh 27 | 28 | query = { 29 | query: { 30 | match: { 31 | name: "mittens" 32 | } 33 | } 34 | } 35 | 36 | response = @index.native_delete_by_query(query) 37 | 38 | refute_nil response["took"] 39 | refute(response["timed_out"]) 40 | assert_equal(1, response["batches"]) 41 | assert_equal(1, response["total"]) 42 | assert_equal(1, response["deleted"]) 43 | assert_empty(response["failures"]) 44 | 45 | @index.refresh 46 | response = @docs.multi_get(ids: [0, 1]) 47 | 48 | refute_found response.fetch("docs")[0] 49 | assert_found response.fetch("docs")[1] 50 | end 51 | 52 | it "fails when internal version is 0" do 53 | if $client.version_support.es_version_8_plus? 54 | skip "Concurrency control with internal version is not supported in ES #{$client.version}" 55 | end 56 | @docs.index({_id: 0, name: "mittens"}) 57 | # Creating a document with external version 0 also sets the internal version to 0 58 | # Otherwise you can't index a document with version 0. 59 | @docs.index({_id: 1, _version: 0, _version_type: "external", name: "mittens"}) 60 | @index.refresh 61 | 62 | query = { 63 | query: { 64 | match: { 65 | name: "mittens" 66 | } 67 | } 68 | } 69 | 70 | assert_raises(ElastomerClient::Client::RequestError) do 71 | @index.native_delete_by_query(query) 72 | end 73 | end 74 | 75 | it "fails when an unknown parameter is provided" do 76 | assert_raises(ElastomerClient::Client::IllegalArgument) do 77 | @index.native_delete_by_query({}, foo: "bar") 78 | end 79 | end 80 | 81 | it "deletes by query when routing is specified" do 82 | index = $client.index "elastomer-delete-by-query-routing-test" 83 | index.delete if index.exists? 84 | type = "docs" 85 | # default number of shards in ES8 is 1, so set it to 2 shards so routing to different shards can be tested 86 | settings = $client.version_support.es_version_8_plus? ? { number_of_shards: 2 } : {} 87 | index.create({ 88 | settings:, 89 | mappings: mappings_wrapper(type, { 90 | properties: { 91 | name: { type: "text", analyzer: "standard" }, 92 | }, 93 | _routing: { required: true } 94 | }) 95 | }) 96 | wait_for_index(@index.name) 97 | docs = index.docs(type) 98 | 99 | docs.index({ _id: 0, _routing: "cat", name: "mittens" }) 100 | docs.index({ _id: 1, _routing: "cat", name: "luna" }) 101 | docs.index({ _id: 2, _routing: "dog", name: "mittens" }) 102 | 103 | query = { 104 | query: { 105 | match: { 106 | name: "mittens" 107 | } 108 | } 109 | } 110 | 111 | index.refresh 112 | response = index.native_delete_by_query(query, routing: "cat") 113 | 114 | assert_equal(1, response["deleted"]) 115 | 116 | response = docs.multi_get({ 117 | docs: [ 118 | { _id: 0, routing: "cat" }, 119 | { _id: 1, routing: "cat" }, 120 | { _id: 2, routing: "dog" }, 121 | ] 122 | }) 123 | 124 | refute_found response["docs"][0] 125 | assert_found response["docs"][1] 126 | assert_found response["docs"][2] 127 | 128 | index.delete if index.exists? 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /test/client/nodes_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Nodes do 6 | 7 | it "gets info for the node(s)" do 8 | h = $client.nodes.info 9 | 10 | assert h.key?("cluster_name"), "the cluster name is returned" 11 | assert_instance_of Hash, h["nodes"], "the node list is returned" 12 | end 13 | 14 | it "gets stats for the node(s)" do 15 | h = $client.nodes.stats 16 | 17 | assert_instance_of Hash, h["nodes"], "the node list is returned" 18 | 19 | node = h["nodes"].values.first 20 | 21 | assert node.key?("indices"), "indices stats are returned" 22 | end 23 | 24 | it "filters node info" do 25 | h = $client.nodes.info(info: "os") 26 | node = h["nodes"].values.first 27 | 28 | assert node.key?("os"), "expected os info to be present" 29 | refute node.key?("jvm"), "expected jvm info to be absent" 30 | 31 | h = $client.nodes.info(info: %w[jvm process]) 32 | node = h["nodes"].values.first 33 | 34 | assert node.key?("jvm"), "expected jvm info to be present" 35 | assert node.key?("process"), "expected process info to be present" 36 | refute node.key?("network"), "expected network info to be absent" 37 | end 38 | 39 | it "filters node stats" do 40 | h = $client.nodes.stats(stats: "http") 41 | node = h["nodes"].values.first 42 | 43 | assert node.key?("http"), "expected http stats to be present" 44 | refute node.key?("indices"), "expected indices stats to be absent" 45 | end 46 | 47 | it "gets the hot threads for the node(s)" do 48 | str = $client.nodes.hot_threads read_timeout: 2 49 | 50 | assert_instance_of String, str 51 | refute_nil str, "expected response to not be nil" 52 | refute_empty str, "expected response to not be empty" 53 | end 54 | 55 | it "can be scoped to a single node" do 56 | h = $client.nodes("node-with-no-name").info 57 | 58 | assert_empty h["nodes"] 59 | end 60 | 61 | it "can be scoped to multiple nodes" do 62 | h = $client.nodes(%w[node1 node2 node3]).info 63 | 64 | assert_empty h["nodes"] 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /test/client/percolator_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Percolator do 6 | 7 | before do 8 | if $client.version_support.es_version_8_plus? 9 | skip "Percolate not supported in ES version #{$client.version}" 10 | end 11 | 12 | @index = $client.index "elastomer-percolator-test" 13 | @index.delete if @index.exists? 14 | @docs = @index.docs("docs") 15 | end 16 | 17 | after do 18 | @index.delete if @index.exists? 19 | end 20 | 21 | describe "when an index exists" do 22 | before do 23 | base_mappings = { mappings: { percolator: { properties: { query: { type: "percolator" } } } } } 24 | 25 | @index.create(base_mappings) 26 | wait_for_index(@index.name) 27 | end 28 | 29 | it "creates a query" do 30 | percolator = @index.percolator "1" 31 | response = percolator.create query: { match_all: { } } 32 | 33 | assert response["created"], "Couldn't create the percolator query" 34 | end 35 | 36 | it "gets a query" do 37 | percolator = @index.percolator "1" 38 | percolator.create query: { match_all: { } } 39 | response = percolator.get 40 | 41 | assert response["found"], "Couldn't find the percolator query" 42 | end 43 | 44 | it "deletes a query" do 45 | percolator = @index.percolator "1" 46 | percolator.create query: { match_all: { } } 47 | response = percolator.delete 48 | 49 | assert response["found"], "Couldn't find the percolator query" 50 | end 51 | 52 | it "checks for the existence of a query" do 53 | percolator = @index.percolator "1" 54 | 55 | refute_predicate percolator, :exists?, "Percolator query exists" 56 | percolator.create query: { match_all: { } } 57 | 58 | assert_predicate percolator, :exists?, "Percolator query does not exist" 59 | end 60 | 61 | it "cannot delete all percolators by providing a nil id" do 62 | assert_raises(ArgumentError) { @index.percolator nil } 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/client/reindex_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Reindex do 6 | before do 7 | @source_index = $client.index("source_index") 8 | @dest_index = $client.index("dest_index") 9 | @non_existent_index = $client.index("non_existent_index") 10 | if @source_index.exists? 11 | @source_index.delete 12 | end 13 | if @dest_index.exists? 14 | @dest_index.delete 15 | end 16 | if @non_existent_index.exists? 17 | @non_existent_index.delete 18 | end 19 | @source_index.create(default_index_settings) 20 | @dest_index.create(default_index_settings) 21 | wait_for_index(@source_index.name, "green") 22 | wait_for_index(@dest_index.name, "green") 23 | 24 | # Index a document in the source index 25 | @source_index.docs.index(document_wrapper("book", { _id: 1, title: "Book 1" })) 26 | @source_index.refresh 27 | end 28 | 29 | after do 30 | @source_index.delete if @source_index.exists? 31 | @dest_index.delete if @dest_index.exists? 32 | @non_existent_index.delete if @non_existent_index.exists? 33 | end 34 | 35 | it "reindexes documents from one index to another" do 36 | reindex = $client.reindex 37 | body = { 38 | source: { index: @source_index.name }, 39 | dest: { index: @dest_index.name } 40 | } 41 | reindex.reindex(body) 42 | 43 | # Refresh the destination index to make sure the document is searchable 44 | @dest_index.refresh 45 | 46 | # Verify that the document has been reindexed 47 | doc = @dest_index.docs.get(id: 1, type: "book") 48 | 49 | assert_equal "Book 1", doc["_source"]["title"] 50 | end 51 | 52 | it "successfully rethrottles a reindex task" do 53 | reindex = $client.reindex 54 | body = { 55 | source: { index: @source_index.name }, 56 | dest: { index: @dest_index.name } 57 | } 58 | response = reindex.reindex(body, requests_per_second: 0.01, wait_for_completion: false) 59 | task_id = response["task"] 60 | node_id = task_id.split(":").first 61 | task_number = task_id.split(":").last.to_i 62 | 63 | response = reindex.rethrottle(task_id, requests_per_second: 1) 64 | 65 | assert_equal 1, response["nodes"][node_id]["tasks"][task_id]["status"]["requests_per_second"] 66 | 67 | # wait for the task to complete 68 | tasks = $client.tasks 69 | tasks.wait_by_id(node_id, task_number, "30s") 70 | 71 | # Verify that the document has been reindexed 72 | doc = @dest_index.docs.get(id: 1, type: "book") 73 | 74 | assert_equal "Book 1", doc["_source"]["title"] 75 | end 76 | 77 | it "creates a new index when the destination index does not exist" do 78 | reindex = $client.reindex 79 | body = { 80 | source: { index: @source_index.name }, 81 | dest: { index: "non_existent_index" } 82 | } 83 | reindex.reindex(body) 84 | new_index = $client.index("non_existent_index") 85 | 86 | assert_predicate(new_index, :exists?) 87 | end 88 | 89 | it "fails when the source index does not exist" do 90 | reindex = $client.reindex 91 | body = { 92 | source: { index: "non_existent_index" }, 93 | dest: { index: @dest_index.name } 94 | } 95 | 96 | exception = assert_raises(ElastomerClient::Client::RequestError) do 97 | reindex.reindex(body) 98 | end 99 | assert_equal(404, exception.status) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/client/repository_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Repository do 6 | before do 7 | if !run_snapshot_tests? 8 | skip "To enable snapshot tests, add a path.repo setting to your elasticsearch.yml file." 9 | end 10 | 11 | @name = "elastomer-repository-test" 12 | @repo = $client.repository(@name) 13 | end 14 | 15 | it "determines if a repo exists" do 16 | refute_predicate @repo, :exists? 17 | refute_predicate @repo, :exist? 18 | with_tmp_repo(@name) do 19 | assert_predicate @repo, :exists? 20 | end 21 | end 22 | 23 | it "creates repos" do 24 | response = create_repo(@name) 25 | 26 | assert response["acknowledged"] 27 | delete_repo(@name) 28 | end 29 | 30 | it "cannot create a repo without a name" do 31 | _(lambda { 32 | create_repo(nil) 33 | }).must_raise ArgumentError 34 | end 35 | 36 | it "gets repos" do 37 | with_tmp_repo do |repo| 38 | response = repo.get 39 | 40 | refute_nil response[repo.name] 41 | end 42 | end 43 | 44 | it "gets all repos" do 45 | with_tmp_repo do |repo| 46 | response = $client.repository.get 47 | 48 | refute_nil response[repo.name] 49 | end 50 | end 51 | 52 | it "gets repo status" do 53 | with_tmp_repo do |repo| 54 | response = repo.status 55 | 56 | assert_empty response["snapshots"] 57 | end 58 | end 59 | 60 | it "gets status of all repos" do 61 | response = $client.repository.status 62 | 63 | assert_empty response["snapshots"] 64 | end 65 | 66 | it "updates repos" do 67 | with_tmp_repo do |repo| 68 | settings = repo.get[repo.name]["settings"] 69 | response = repo.update(type: "fs", settings: settings.merge("compress" => true)) 70 | 71 | assert response["acknowledged"] 72 | assert_equal "true", repo.get[repo.name]["settings"]["compress"] 73 | end 74 | end 75 | 76 | it "cannot update a repo without a name" do 77 | with_tmp_repo do |repo| 78 | _(lambda { 79 | settings = repo.get[repo.name]["settings"] 80 | $client.repository.update(type: "fs", settings: settings.merge("compress" => true)) 81 | }).must_raise ArgumentError 82 | end 83 | end 84 | 85 | it "deletes repos" do 86 | with_tmp_repo do |repo| 87 | response = repo.delete 88 | 89 | assert response["acknowledged"] 90 | refute_predicate repo, :exists? 91 | end 92 | end 93 | 94 | it "cannot delete a repo without a name" do 95 | _(lambda { 96 | $client.repository.delete 97 | }).must_raise ArgumentError 98 | end 99 | 100 | it "gets snapshots" do 101 | with_tmp_repo do |repo| 102 | response = repo.snapshots.get 103 | 104 | assert_empty response["snapshots"] 105 | 106 | create_snapshot(repo, "test-snapshot") 107 | response = repo.snapshot.get 108 | 109 | assert_equal ["test-snapshot"], response["snapshots"].collect { |info| info["snapshot"] } 110 | 111 | create_snapshot(repo, "test-snapshot2") 112 | response = repo.snapshots.get 113 | snapshot_names = response["snapshots"].collect { |info| info["snapshot"] } 114 | 115 | assert_includes snapshot_names, "test-snapshot" 116 | assert_includes snapshot_names, "test-snapshot2" 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/client/rest_api_spec/api_spec_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../test_helper" 4 | 5 | describe ElastomerClient::Client::RestApiSpec::ApiSpec do 6 | before do 7 | @api_spec = ElastomerClient::Client::RestApiSpec.api_spec("5.6.4") 8 | end 9 | 10 | it "selects valid path parts" do 11 | parts = {:index => "test", "type" => "doc", :foo => "bar"} 12 | result = @api_spec.select_parts(api: "search", from: parts) 13 | 14 | assert_equal({:index => "test", "type" => "doc"}, result) 15 | end 16 | 17 | it "identifies valid path parts" do 18 | assert @api_spec.valid_part?(api: "search", part: "index") 19 | assert @api_spec.valid_part?(api: "search", part: :type) 20 | refute @api_spec.valid_part?(api: "search", part: :id) 21 | end 22 | 23 | it "selects valid request params" do 24 | params = {:explain => true, "preference" => "local", :nope => "invalid"} 25 | result = @api_spec.select_params(api: "search", from: params) 26 | 27 | assert_equal({:explain => true, "preference" => "local"}, result) 28 | end 29 | 30 | it "identifies valid request params" do 31 | assert @api_spec.valid_param?(api: "search", param: "explain") 32 | assert @api_spec.valid_param?(api: "search", param: :preference) 33 | assert @api_spec.valid_param?(api: "search", param: :routing) 34 | refute @api_spec.valid_param?(api: "search", param: "pretty") 35 | end 36 | 37 | it "selects common request params" do 38 | params = {:pretty => true, "human" => true, :nope => "invalid"} 39 | result = @api_spec.select_common_params(from: params) 40 | 41 | assert_equal({:pretty => true, "human" => true}, result) 42 | end 43 | 44 | it "identifies common request params" do 45 | assert @api_spec.valid_common_param?("pretty") 46 | assert @api_spec.valid_common_param?(:human) 47 | assert @api_spec.valid_common_param?(:source) 48 | refute @api_spec.valid_common_param?("nope") 49 | end 50 | 51 | it "validates request params" do 52 | params = {q: "*:*", pretty: true, "nope": false} 53 | assert_raises(ElastomerClient::Client::IllegalArgument, "'nope' is not a valid parameter for the 'search' API") { 54 | @api_spec.validate_params!(api: "search", params:) 55 | } 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/client/rest_api_spec/rest_api_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../test_helper" 4 | 5 | describe ElastomerClient::Client::RestApiSpec::RestApi do 6 | before do 7 | @rest_api = ElastomerClient::Client::RestApiSpec::RestApi.new \ 8 | documentation: "https://www.elastic.co/guide/en/elasticsearch/reference/5.x/cluster-state.html", 9 | methods: ["GET"], 10 | body: nil, 11 | url: { 12 | path: "/_cluster/state", 13 | paths: ["/_cluster/state", "/_cluster/state/{metric}", "/_cluster/state/{metric}/{index}"], 14 | parts: { 15 | "index" => {"type"=>"list", "description"=>"A comma-separated list of index names; use `_all` or empty string to perform the operation on all indices"}, 16 | "metric" => {"type"=>"list", "options"=>["_all", "blocks", "metadata", "nodes", "routing_table", "routing_nodes", "master_node", "version"], "description"=>"Limit the information returned to the specified metrics"}, 17 | }, 18 | params: { 19 | "local" => {"type"=>"boolean", "description"=>"Return local information, do not retrieve the state from master node (default: false)"}, 20 | "master_timeout" => {"type"=>"time", "description"=>"Specify timeout for connection to master"}, 21 | "flat_settings" => {"type"=>"boolean", "description"=>"Return settings in flat format (default: false)"}, 22 | "ignore_unavailable" => {"type"=>"boolean", "description"=>"Whether specified concrete indices should be ignored when unavailable (missing or closed)"}, 23 | "allow_no_indices" => {"type"=>"boolean", "description"=>"Whether to ignore if a wildcard indices expression resolves into no concrete indices. (This includes `_all` string or when no indices have been specified)"}, 24 | "expand_wildcards" => {"type"=>"enum", "options"=>["open", "closed", "none", "all"], "default"=>"open", "description"=>"Whether to expand wildcard expression to concrete indices that are open, closed or both."}, 25 | } 26 | } 27 | end 28 | 29 | it "selects valid path parts" do 30 | hash = { 31 | :index => "test", 32 | "metric" => "os", 33 | :nope => "not selected" 34 | } 35 | selected = @rest_api.select_parts(from: hash) 36 | 37 | refute selected.key?(:nope) 38 | assert selected.key?(:index) 39 | assert selected.key?("metric") 40 | end 41 | 42 | it "identifies valid parts" do 43 | assert @rest_api.valid_part? :index 44 | assert @rest_api.valid_part? "metric" 45 | refute @rest_api.valid_part? :nope 46 | end 47 | 48 | it "selects valid request params" do 49 | hash = { 50 | :local => true, 51 | "flat_settings" => true, 52 | :expand_wildcards => "all", 53 | :nope => "not selected" 54 | } 55 | selected = @rest_api.select_params(from: hash) 56 | 57 | refute selected.key?(:nope) 58 | assert selected.key?(:local) 59 | assert selected.key?("flat_settings") 60 | assert selected.key?(:expand_wildcards) 61 | end 62 | 63 | it "identifies valid params" do 64 | assert @rest_api.valid_param? :local 65 | assert @rest_api.valid_param? "flat_settings" 66 | refute @rest_api.valid_param? :nope 67 | end 68 | 69 | it "accesses the documentation url" do 70 | assert_equal "https://www.elastic.co/guide/en/elasticsearch/reference/5.x/cluster-state.html", @rest_api.documentation 71 | end 72 | 73 | it "exposes the HTTP methods as an Array" do 74 | assert_equal %w[GET], @rest_api.methods 75 | end 76 | 77 | it "accesses the body settings" do 78 | assert_nil @rest_api.body 79 | end 80 | 81 | describe "accessing the url" do 82 | it "accesses the path" do 83 | assert_equal "/_cluster/state", @rest_api.url.path 84 | end 85 | 86 | it "exposes the paths as an Array" do 87 | assert_equal %w[/_cluster/state /_cluster/state/{metric} /_cluster/state/{metric}/{index}], @rest_api.url.paths 88 | end 89 | 90 | it "accesses the path parts" do 91 | assert_equal %w[index metric], @rest_api.url.parts.keys 92 | end 93 | 94 | it "accesses the request params" do 95 | assert_equal %w[local master_timeout flat_settings ignore_unavailable allow_no_indices expand_wildcards], @rest_api.url.params.keys 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/client/scroller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Scroller do 6 | 7 | before do 8 | @name = "elastomer-scroller-test" 9 | @index = $client.index(@name) 10 | @type = $client.version_support.es_version_8_plus? ? "_doc" : "book" 11 | 12 | unless @index.exists? 13 | @index.create \ 14 | settings: { "index.number_of_shards" => 1, "index.number_of_replicas" => 0 }, 15 | mappings: mappings_wrapper("book", { 16 | _source: { enabled: true }, 17 | properties: { 18 | title: { type: "text", analyzer: "standard" }, 19 | author: { type: "keyword" }, 20 | sorter: { type: "integer" } 21 | } 22 | }, true) 23 | 24 | wait_for_index(@name) 25 | populate! 26 | end 27 | end 28 | 29 | after do 30 | @index.delete if @index.exists? 31 | end 32 | 33 | it "scans over all documents in an index" do 34 | scan = @index.scan '{"query":{"match_all":{}}}', size: 10 35 | 36 | count = 0 37 | scan.each_document { |h| count += 1 } 38 | 39 | assert_equal 25, count 40 | end 41 | 42 | it "limits results by query" do 43 | scan = @index.scan query: { bool: { should: [ 44 | {match: {title: "17"}} 45 | ]}} 46 | 47 | count = 0 48 | scan.each_document { |h| count += 1 } 49 | 50 | assert_equal 1, count 51 | end 52 | 53 | it "scrolls and sorts over all documents" do 54 | scroll = @index.scroll({ 55 | query: {match_all: {}}, 56 | sort: {sorter: {order: :asc}} 57 | }, type: @type) 58 | 59 | books = [] 60 | scroll.each_document { |h| books << h["_id"].to_i } 61 | 62 | expected = (0...25).to_a.reverse 63 | 64 | assert_equal expected, books 65 | end 66 | 67 | it "propagates URL query strings" do 68 | scan = @index.scan(nil, { q: "title:1 || title:17" }) 69 | 70 | count = 0 71 | scan.each_document { |h| count += 1 } 72 | 73 | assert_equal 2, count 74 | end 75 | 76 | it "clears one or more scroll IDs" do 77 | h = $client.start_scroll \ 78 | body: {query: {match_all: {}}}, 79 | index: @index.name, 80 | type: @type, 81 | scroll: "1m", 82 | size: 10 83 | 84 | refute_nil h["_scroll_id"], "response is missing a scroll ID" 85 | 86 | response = $client.clear_scroll(h["_scroll_id"]) 87 | 88 | assert response["succeeded"] 89 | assert_equal 1, response["num_freed"] 90 | end 91 | 92 | it "raises an exception on existing sort in query" do 93 | assert_raises(ArgumentError) { @index.scan sort: [:_doc] , query: {} } 94 | end 95 | 96 | def populate! 97 | h = @index.bulk do |b| 98 | 25.times { |num| 99 | if $client.version_support.es_version_8_plus? 100 | b.index %Q({"author":"Pratchett","title":"DiscWorld Book #{num}","sorter":#{25-num}}), _id: num 101 | else 102 | b.index %Q({"author":"Pratchett","title":"DiscWorld Book #{num}","sorter":#{25-num}}), _id: num, _type: "book" 103 | end 104 | } 105 | end 106 | 107 | h["items"].each { |item| assert_bulk_index(item) } 108 | 109 | @index.refresh 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/client/snapshot_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Snapshot do 6 | before do 7 | @index = nil 8 | @restored_index = nil 9 | 10 | if !run_snapshot_tests? 11 | skip "To enable snapshot tests, add a path.repo setting to your elasticsearch.yml file." 12 | end 13 | 14 | @index_name = "elastomer-snapshot-test-index" 15 | @index = $client.index(@index_name) 16 | @name = "elastomer-test" 17 | if $client.version_support.es_version_8_plus? 18 | $client.cluster.update_settings persistent: { "ingest.geoip.downloader.enabled" => "false" } 19 | end 20 | end 21 | 22 | after do 23 | @index.delete if @index && @index.exists? 24 | end 25 | 26 | it "determines if a snapshot exists" do 27 | with_tmp_repo do |repo| 28 | snapshot = repo.snapshot(@name) 29 | 30 | refute_predicate snapshot, :exists? 31 | refute_predicate snapshot, :exist? 32 | snapshot.create({}, wait_for_completion: true) 33 | 34 | assert_predicate snapshot, :exist? 35 | end 36 | end 37 | 38 | it "creates snapshots" do 39 | with_tmp_repo do |repo| 40 | response = repo.snapshot(@name).create({}, wait_for_completion: true) 41 | 42 | assert_equal @name, response["snapshot"]["snapshot"] 43 | end 44 | end 45 | 46 | it "creates snapshots with options" do 47 | @index.create(settings: { number_of_shards: 1, number_of_replicas: 0 }) 48 | with_tmp_repo do |repo| 49 | response = repo.snapshot(@name).create({ indices: [@index_name] }, wait_for_completion: true) 50 | 51 | assert_equal [@index_name], response["snapshot"]["indices"] 52 | assert_equal 1, response["snapshot"]["shards"]["total"] 53 | end 54 | end 55 | 56 | it "gets snapshot info for one and all" do 57 | with_tmp_snapshot do |snapshot, repo| 58 | response = snapshot.get 59 | 60 | assert_equal snapshot.name, response["snapshots"][0]["snapshot"] 61 | response = repo.snapshots.get 62 | 63 | assert_equal snapshot.name, response["snapshots"][0]["snapshot"] 64 | end 65 | end 66 | 67 | it "gets snapshot status for one and all" do 68 | @index.create(settings: { number_of_shards: 1, number_of_replicas: 0 }) 69 | with_tmp_repo do |repo| 70 | repo.snapshot(@name).create({indices: [@index_name]}, wait_for_completion: true) 71 | response = repo.snapshot(@name).status 72 | 73 | assert_equal 1, response["snapshots"][0]["shards_stats"]["total"] 74 | end 75 | end 76 | 77 | it "gets status of snapshots in progress" do 78 | # we can't reliably get status of an in-progress snapshot in tests, so 79 | # check for an empty result instead 80 | with_tmp_repo do |repo| 81 | response = repo.snapshots.status 82 | 83 | assert_empty response["snapshots"] 84 | response = $client.snapshot.status 85 | 86 | assert_empty response["snapshots"] 87 | end 88 | end 89 | 90 | it "disallows nil repo name with non-nil snapshot name" do 91 | assert_raises(ArgumentError) { $client.repository.snapshot("snapshot") } 92 | assert_raises(ArgumentError) { $client.snapshot(nil, "snapshot") } 93 | end 94 | 95 | it "deletes snapshots" do 96 | with_tmp_snapshot do |snapshot| 97 | response = snapshot.delete 98 | 99 | assert response["acknowledged"] 100 | end 101 | end 102 | 103 | it "restores snapshots" do 104 | @index.create(settings: { number_of_shards: 1, number_of_replicas: 0 }) 105 | wait_for_index(@index_name) 106 | with_tmp_repo do |repo| 107 | snapshot = repo.snapshot(@name) 108 | snapshot.create({ indices: [@index_name] }, wait_for_completion: true) 109 | @index.delete 110 | response = snapshot.restore({}, wait_for_completion: true) 111 | 112 | assert_equal 1, response["snapshot"]["shards"]["total"] 113 | end 114 | end 115 | 116 | describe "restoring to a different index" do 117 | before do 118 | @restored_index_name = "#{@index_name}-restored" 119 | @restored_index = $client.index(@restored_index_name) 120 | end 121 | 122 | after do 123 | @restored_index.delete if @restored_index && @restored_index.exists? 124 | end 125 | 126 | it "restores snapshots with options" do 127 | @index.create(settings: { number_of_shards: 1, number_of_replicas: 0 }) 128 | wait_for_index(@index_name) 129 | with_tmp_repo do |repo| 130 | snapshot = repo.snapshot(@name) 131 | snapshot.create({indices: [@index_name]}, wait_for_completion: true) 132 | response = snapshot.restore({ 133 | rename_pattern: @index_name, 134 | rename_replacement: @restored_index_name 135 | }, wait_for_completion: true) 136 | 137 | assert_equal [@restored_index_name], response["snapshot"]["indices"] 138 | assert_equal 1, response["snapshot"]["shards"]["total"] 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/client/stubbed_client_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe "stubbed client tests" do 6 | before do 7 | @stubs = Faraday::Adapter.lookup_middleware(:test)::Stubs.new 8 | @client = ElastomerClient::Client.new adapter: [:test, @stubs] 9 | @client.instance_variable_set(:@version, "5.6.4") 10 | end 11 | 12 | describe ElastomerClient::Client::Cluster do 13 | it "reroutes shards" do 14 | @stubs.post "/_cluster/reroute?dry_run=true" do |env| 15 | assert_match %r/^\{"commands":\[\{"move":\{[^\{\}]+\}\}\]\}$/, env[:body] 16 | [200, {"Content-Type" => "application/json"}, '{"acknowledged" : true}'] 17 | end 18 | 19 | commands = { move: { index: "test", shard: 0, from_node: "node1", to_node: "node2" }} 20 | h = @client.cluster.reroute commands, dry_run: true 21 | 22 | assert_acknowledged h 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/client/tasks_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Tasks do 6 | before do 7 | @tasks = $client.tasks 8 | 9 | @index = $client.index("elastomer-tasks-test") 10 | @index.create(default_index_settings) 11 | wait_for_index(@index.name) 12 | end 13 | 14 | after do 15 | @index.delete if @index.exists? 16 | end 17 | 18 | it "list all in-flight tasks" do 19 | h = @tasks.get 20 | 21 | assert_operator h["nodes"].keys.size, :>, 0 22 | 23 | total_tasks = h["nodes"].map { |k, v| v["tasks"].keys.count }.sum 24 | 25 | assert_operator total_tasks, :>, 0 26 | end 27 | 28 | it "groups by parent->child relationships when get-all tasks API is grouped by 'parents'" do 29 | h = @tasks.get group_by: "parents" 30 | parent_id = h["tasks"].select { |k, v| v.key?("children") }.keys.first 31 | childs_parent_ref = h.dig("tasks", parent_id, "children").first["parent_task_id"] 32 | 33 | assert_equal parent_id, childs_parent_ref 34 | end 35 | 36 | it "raises exception when get_by_id is called without required task & node IDs" do 37 | assert_raises(ArgumentError) do 38 | @tasks.get_by_id 39 | end 40 | end 41 | 42 | it "raises exception when get_by_id is called w/invalid task ID is supplied" do 43 | node_id = @tasks.get["nodes"].map { |k, v| k }.first 44 | assert_raises(ArgumentError) do 45 | @tasks.get_by_id node_id, "task_id_should_be_integer" 46 | end 47 | end 48 | 49 | it "raises exception when get_by_id is called w/invalid node ID is supplied" do 50 | assert_raises(ArgumentError) do 51 | @tasks.get_by_id nil, 42 52 | end 53 | end 54 | 55 | it "successfully waits for task to complete when wait_for_completion and timeout flags are set" do 56 | test_thread = nil 57 | begin 58 | # poulate the index in a background thread to generate long-running tasks we can query 59 | test_thread = populate_background_index!(@index.name) 60 | 61 | # ensure we can wait on completion of a task 62 | success = false 63 | query_long_running_tasks.each do |ts| 64 | t = ts.values.first 65 | begin 66 | resp = @tasks.wait_by_id t["node"], t["id"], "3s" 67 | success = !resp.key?("node_failures") 68 | rescue ElastomerClient::Client::ServerError => e 69 | # this means the timeout expired before the task finished, but it's a good thing! 70 | success = /Timed out waiting for completion/ =~ e.message 71 | end 72 | break if success 73 | end 74 | 75 | assert success 76 | ensure 77 | test_thread.join unless test_thread.nil? 78 | end 79 | end 80 | 81 | it "locates the task properly by ID when valid node and task IDs are supplied" do 82 | test_thread = nil 83 | begin 84 | # make an index with a new client (in this thread, to avoid query check race after) 85 | # poulate the index in a background thread to generate long-running tasks we can query 86 | test_thread = populate_background_index!(@index.name) 87 | 88 | # look up and verify found task 89 | found_by_id = false 90 | query_long_running_tasks.each do |ts| 91 | t = ts.values.first 92 | resp = @tasks.get_by_id t["node"], t["id"] 93 | 94 | found_by_id = resp["task"]["node"] == t["node"] && resp["task"]["id"] == t["id"] 95 | 96 | break if found_by_id 97 | end 98 | 99 | assert found_by_id 100 | ensure 101 | test_thread.join unless test_thread.nil? 102 | end 103 | end 104 | 105 | it "raises exception when cancel_by_id is called without required task & node IDs" do 106 | assert_raises(ArgumentError) do 107 | @tasks.cancel_by_id 108 | end 109 | end 110 | 111 | it "raises exception when cancel_by_id is called w/invalid task ID is supplied" do 112 | node_id = @tasks.get["nodes"].map { |k, v| k }.first 113 | assert_raises(ArgumentError) do 114 | @tasks.cancel_by_id node_id, "not_an_integer_id" 115 | end 116 | end 117 | 118 | it "raises exception when cancel_by_id is called w/invalid node IDs is supplied" do 119 | assert_raises(ArgumentError) do 120 | @tasks.cancel_by_id nil, 42 121 | end 122 | end 123 | 124 | # TODO: test this behavior MORE! 125 | it "raises exception when cancel_by_id is called w/invalid node and task IDs are supplied" do 126 | assert_raises(ArgumentError) do 127 | @tasks.cancel_by_id "", "also_should_be_integer_id" 128 | end 129 | end 130 | 131 | # NOTE: unlike get_by_id, cancellation API doesn't return 404 when valid node_id and task_id 132 | # params don't match known nodes/running tasks, so there's no matching test for that here. 133 | 134 | end 135 | -------------------------------------------------------------------------------- /test/client/template_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::Cluster do 6 | 7 | before do 8 | @name = "elastomer-template-test" 9 | @template = $client.template @name 10 | end 11 | 12 | after do 13 | @template.delete if @template.exists? 14 | end 15 | 16 | it "lists templates in the cluster" do 17 | if $client.version_support.es_version_8_plus? 18 | @template.create({index_patterns: ["test-elastomer*"]}) 19 | else 20 | @template.create({template: "test-elastomer*"}) 21 | end 22 | templates = $client.cluster.templates 23 | 24 | refute_empty templates, "expected to see a template" 25 | end 26 | 27 | it "creates a template" do 28 | refute_predicate @template, :exists?, "the template should not exist" 29 | 30 | if $client.version_support.es_version_8_plus? 31 | template_config = {index_patterns: ["test-elastomer*"]} 32 | else 33 | template_config = {template: "test-elastomer*"} 34 | end 35 | 36 | template_config.merge!({ 37 | settings: { number_of_shards: 3 }, 38 | mappings: mappings_wrapper("book", { 39 | _source: { enabled: false } 40 | }) 41 | }) 42 | 43 | @template.create(template_config) 44 | 45 | assert_predicate @template, :exists?, " we now have a cluster-test template" 46 | 47 | template = @template.get 48 | 49 | assert_equal [@name], template.keys 50 | 51 | if $client.version_support.es_version_8_plus? 52 | assert_equal "test-elastomer*", template[@name]["index_patterns"][0] 53 | else 54 | assert_equal "test-elastomer*", template[@name]["template"] 55 | end 56 | 57 | assert_equal "3", template[@name]["settings"]["index"]["number_of_shards"] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/client/update_by_query_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../test_helper" 4 | 5 | describe ElastomerClient::Client::UpdateByQuery do 6 | before do 7 | @index = $client.index "elastomer-update-by-query-test" 8 | @index.delete if @index.exists? 9 | @docs = @index.docs("docs") 10 | end 11 | 12 | after do 13 | @index.delete if @index.exists? 14 | end 15 | 16 | describe "when an index with documents exists" do 17 | before do 18 | @index.create(nil) 19 | wait_for_index(@index.name) 20 | end 21 | 22 | it "updates by query" do 23 | @docs.index({ _id: 0, name: "mittens" }) 24 | @docs.index({ _id: 1, name: "luna" }) 25 | 26 | @index.refresh 27 | 28 | query = { 29 | query: { 30 | match: { 31 | name: "mittens" 32 | } 33 | }, 34 | script: { 35 | source: "ctx._source.name = 'mittens updated'" 36 | } 37 | } 38 | 39 | response = @index.update_by_query(query) 40 | 41 | refute_nil response["took"] 42 | refute(response["timed_out"]) 43 | assert_equal(1, response["batches"]) 44 | assert_equal(1, response["total"]) 45 | assert_equal(1, response["updated"]) 46 | assert_empty(response["failures"]) 47 | 48 | @index.refresh 49 | response = @docs.multi_get(ids: [0, 1]) 50 | 51 | assert_equal "mittens updated", response.fetch("docs")[0]["_source"]["name"] 52 | assert_equal "luna", response.fetch("docs")[1]["_source"]["name"] 53 | end 54 | 55 | it "fails when internal version is 0" do 56 | if $client.version_support.es_version_8_plus? 57 | skip "Concurrency control with internal version is not supported in ES #{$client.version}" 58 | end 59 | @docs.index({_id: 0, name: "mittens"}) 60 | # Creating a document with external version 0 also sets the internal version to 0 61 | # Otherwise you can't index a document with version 0. 62 | @docs.index({_id: 1, _version: 0, _version_type: "external", name: "mittens"}) 63 | @index.refresh 64 | 65 | query = { 66 | query: { 67 | match: { 68 | name: "mittens" 69 | } 70 | } 71 | } 72 | 73 | assert_raises(ElastomerClient::Client::RequestError) do 74 | @index.update_by_query(query) 75 | end 76 | end 77 | 78 | it "fails when an unknown parameter is provided" do 79 | assert_raises(ElastomerClient::Client::IllegalArgument) do 80 | @index.update_by_query({}, foo: "bar") 81 | end 82 | end 83 | 84 | it "updates by query when routing is specified" do 85 | index = $client.index "elastomer-update-by-query-routing-test" 86 | index.delete if index.exists? 87 | type = "docs" 88 | # default number of shards in ES8 is 1, so set it to 2 shards so routing to different shards can be tested 89 | settings = $client.version_support.es_version_8_plus? ? { number_of_shards: 2 } : {} 90 | index.create({ 91 | settings:, 92 | mappings: mappings_wrapper(type, { 93 | properties: { 94 | name: { type: "text", analyzer: "standard" }, 95 | }, 96 | _routing: { required: true } 97 | }) 98 | }) 99 | wait_for_index(@index.name) 100 | docs = index.docs(type) 101 | 102 | docs.index({ _id: 0, _routing: "cat", name: "mittens" }) 103 | docs.index({ _id: 1, _routing: "cat", name: "luna" }) 104 | docs.index({ _id: 2, _routing: "dog", name: "mittens" }) 105 | 106 | query = { 107 | query: { 108 | match: { 109 | name: "mittens" 110 | } 111 | }, 112 | script: { 113 | source: "ctx._source.name = 'mittens updated'" 114 | } 115 | } 116 | 117 | index.refresh 118 | response = index.update_by_query(query, routing: "cat") 119 | 120 | assert_equal(1, response["updated"]) 121 | 122 | response = docs.multi_get({ 123 | docs: [ 124 | { _id: 0, routing: "cat" }, 125 | { _id: 1, routing: "cat" }, 126 | { _id: 2, routing: "dog" }, 127 | ] 128 | }) 129 | 130 | assert_equal "mittens updated", response.fetch("docs")[0]["_source"]["name"] 131 | assert_equal "luna", response.fetch("docs")[1]["_source"]["name"] 132 | assert_equal "mittens", response.fetch("docs")[2]["_source"]["name"] 133 | 134 | index.delete if index.exists? 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/core_ext/time_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("../../test_helper", __FILE__) 4 | require "elastomer_client/core_ext/time" 5 | 6 | describe "JSON conversions for Time" do 7 | before do 8 | @name = "elastomer-time-test" 9 | @index = $client.index(@name) 10 | 11 | unless @index.exists? 12 | @index.create \ 13 | settings: { "index.number_of_shards" => 1, "index.number_of_replicas" => 0 }, 14 | mappings: mappings_wrapper("book", { 15 | _source: { enabled: true }, 16 | properties: { 17 | title: { type: "keyword" }, 18 | created_at: { type: "date" } 19 | } 20 | }, !$client.version_support.es_version_8_plus?) 21 | 22 | wait_for_index(@name) 23 | end 24 | 25 | @docs = @index.docs 26 | end 27 | 28 | after do 29 | @index.delete if @index.exists? 30 | end 31 | 32 | it "generates ISO8601 formatted time strings" do 33 | time = Time.utc(2013, 5, 3, 10, 1, 31) 34 | 35 | assert_equal '"2013-05-03T10:01:31.000Z"', MultiJson.encode(time) 36 | end 37 | 38 | it "indexes time fields" do 39 | time = Time.utc(2013, 5, 3, 10, 1, 31) 40 | h = @docs.index(document_wrapper("book", {title: "Book 1", created_at: time})) 41 | 42 | assert_created(h) 43 | 44 | doc = $client.version_support.es_version_8_plus? ? @docs.get(id: h["_id"]) : @docs.get(type: "book", id: h["_id"]) 45 | 46 | assert_equal "2013-05-03T10:01:31.000Z", doc["_source"]["created_at"] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/middleware/encode_json_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("../../test_helper", __FILE__) 4 | 5 | describe ElastomerClient::Middleware::EncodeJson do 6 | let(:middleware) { ElastomerClient::Middleware::EncodeJson.new(lambda { |env| env }) } 7 | 8 | def process(body, content_type: nil, method: :post) 9 | env = { body:, request_headers: Faraday::Utils::Headers.new, method: } 10 | env[:request_headers]["content-type"] = content_type if content_type 11 | middleware.call(env) 12 | end 13 | 14 | it "handles no body" do 15 | result = process(nil) 16 | 17 | assert_nil result[:body] 18 | assert_equal "application/json", result[:request_headers]["content-type"] 19 | 20 | result = process(nil, method: :get) 21 | 22 | assert_nil result[:body] 23 | assert_nil result[:request_headers]["content-type"] 24 | end 25 | 26 | it "handles empty body" do 27 | result = process("") 28 | 29 | assert_empty result[:body] 30 | assert_equal "application/json", result[:request_headers]["content-type"] 31 | 32 | result = process("", method: :get) 33 | 34 | assert_empty result[:body] 35 | assert_nil result[:request_headers]["content-type"] 36 | end 37 | 38 | it "handles string body" do 39 | result = process('{"a":1}') 40 | 41 | assert_equal '{"a":1}', result[:body] 42 | assert_equal "application/json", result[:request_headers]["content-type"] 43 | end 44 | 45 | it "handles object body" do 46 | result = process({a: 1}) 47 | 48 | assert_equal '{"a":1}', result[:body] 49 | assert_equal "application/json", result[:request_headers]["content-type"] 50 | end 51 | 52 | it "handles empty object body" do 53 | result = process({}) 54 | 55 | assert_equal "{}", result[:body] 56 | assert_equal "application/json", result[:request_headers]["content-type"] 57 | end 58 | 59 | it "handles object body with json type" do 60 | result = process({a: 1}, content_type: "application/json; charset=utf-8") 61 | 62 | assert_equal '{"a":1}', result[:body] 63 | assert_equal "application/json; charset=utf-8", result[:request_headers]["content-type"] 64 | end 65 | 66 | it "handles object body with incompatible type" do 67 | result = process({a: 1}, content_type: "application/xml; charset=utf-8") 68 | 69 | assert_equal({a: 1}, result[:body]) 70 | assert_equal "application/xml; charset=utf-8", result[:request_headers]["content-type"] 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/middleware/opaque_id_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("../../test_helper", __FILE__) 4 | 5 | describe ElastomerClient::Middleware::OpaqueId do 6 | 7 | before do 8 | stubs = Faraday::Adapter::Test::Stubs.new do |stub| 9 | stub.get("/_cluster/health") { |env| 10 | [200, 11 | 12 | { "X-Opaque-Id" => env[:request_headers]["X-Opaque-Id"], 13 | "Content-Type" => "application/json; charset=UTF-8", 14 | "Content-Length" => "49" }, 15 | 16 | %q[{"cluster_name":"elasticsearch","status":"green"}] 17 | ] 18 | } 19 | 20 | stub.get("/_cluster/state") { |env| 21 | [200, {"X-Opaque-Id" => "00000000-0000-0000-0000-000000000000"}, %q[{"foo":"bar"}]] 22 | } 23 | end 24 | 25 | opts = $client_params.merge \ 26 | opaque_id: true, 27 | adapter: [:test, stubs] 28 | 29 | @client = ElastomerClient::Client.new(**opts) 30 | @client.instance_variable_set(:@version, "5.6.4") 31 | end 32 | 33 | it 'generates an "X-Opaque-Id" header' do 34 | health = @client.cluster.health 35 | 36 | assert_equal({"cluster_name" => "elasticsearch", "status" => "green"}, health) 37 | end 38 | 39 | it "raises an exception on conflicting headers" do 40 | assert_raises(ElastomerClient::Client::OpaqueIdError) { @client.cluster.state } 41 | end 42 | 43 | it "generates a UUID per call" do 44 | opaque_id = ElastomerClient::Middleware::OpaqueId.new 45 | 46 | uuid1 = opaque_id.generate_uuid 47 | uuid2 = opaque_id.generate_uuid 48 | 49 | refute_equal uuid1, uuid2, "UUIDs should be unique" 50 | end 51 | 52 | it "generates a UUID per thread" do 53 | opaque_id = ElastomerClient::Middleware::OpaqueId.new 54 | uuids = [] 55 | threads = [] 56 | 57 | 3.times do 58 | threads << Thread.new { uuids << opaque_id.generate_uuid } 59 | end 60 | threads.each { |t| t.join } 61 | 62 | assert_equal 3, uuids.length, "expecting 3 UUIDs to be generated" 63 | 64 | # each UUID has 16 random characters as the base ID 65 | uuids.each { |uuid| assert_match(%r/\A[a-zA-Z0-9_-]{16}0{8}\z/, uuid) } 66 | 67 | bases = uuids.map { |uuid| uuid[0, 16] } 68 | 69 | assert_equal 3, bases.uniq.length, "each thread did not get a unique base ID" 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/middleware/parse_json_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("../../test_helper", __FILE__) 4 | 5 | describe ElastomerClient::Middleware::ParseJson do 6 | let(:middleware) { ElastomerClient::Middleware::ParseJson.new(lambda { |env| Faraday::Response.new(env) }) } 7 | let(:headers) { Hash.new } 8 | 9 | def process(body, content_type = nil) 10 | env = { body:, response_headers: Faraday::Utils::Headers.new(headers) } 11 | env[:response_headers]["content-type"] = content_type if content_type 12 | middleware.call(env) 13 | end 14 | 15 | it "doesn't change nil body" do 16 | response = process(nil) 17 | 18 | assert_nil response.body 19 | end 20 | 21 | it "nullifies empty body" do 22 | response = process("") 23 | 24 | assert_nil response.body 25 | end 26 | 27 | it "nullifies blank body" do 28 | response = process(" ") 29 | 30 | assert_nil response.body 31 | end 32 | 33 | it "parses json body with empty type" do 34 | response = process('{"a":1}') 35 | 36 | assert_equal({"a" => 1}, response.body) 37 | end 38 | 39 | it "parses json body of correct type" do 40 | response = process('{"a":1}', "application/json; charset=utf-8") 41 | 42 | assert_equal({"a" => 1}, response.body) 43 | end 44 | 45 | it "ignores json body if incorrect type" do 46 | response = process('{"a":1}', "application/xml; charset=utf-8") 47 | 48 | assert_equal('{"a":1}', response.body) 49 | end 50 | 51 | it "chokes on invalid json" do 52 | assert_raises(Faraday::ParsingError) { process "{!" } 53 | assert_raises(Faraday::ParsingError) { process "invalid" } 54 | 55 | # surprisingly these are all valid according to MultiJson 56 | # 57 | # assert_raises(Faraday::ParsingError) { process '"a"' } 58 | # assert_raises(Faraday::ParsingError) { process 'true' } 59 | # assert_raises(Faraday::ParsingError) { process 'null' } 60 | # assert_raises(Faraday::ParsingError) { process '1' } 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/mock_response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ElastomerClient 4 | module Middleware 5 | class MockResponse < Faraday::Middleware 6 | def initialize(app, &block) 7 | super(app) 8 | @response_block = block 9 | end 10 | 11 | def call(env) 12 | env.clear_body if env.needs_body? 13 | 14 | env.status = 200 15 | env.response_headers = ::Faraday::Utils::Headers.new 16 | env.response_headers["Fake"] = "yes" 17 | env.response = ::Faraday::Response.new 18 | 19 | @response_block&.call(env) 20 | 21 | env.response.finish(env) unless env.parallel? 22 | 23 | env.response 24 | end 25 | end 26 | end 27 | end 28 | 29 | Faraday::Request.register_middleware \ 30 | mock_response: ElastomerClient::Middleware::MockResponse 31 | -------------------------------------------------------------------------------- /test/notifications_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("../test_helper", __FILE__) 4 | require "elastomer_client/notifications" 5 | 6 | describe ElastomerClient::Notifications do 7 | before do 8 | @name = "elastomer-notifications-test" 9 | @index = $client.index @name 10 | @index.delete if @index.exists? 11 | @events = [] 12 | @subscriber = ActiveSupport::Notifications.subscribe do |*args| 13 | @events << ActiveSupport::Notifications::Event.new(*args) 14 | end 15 | end 16 | 17 | after do 18 | ActiveSupport::Notifications.unsubscribe(@subscriber) 19 | @index.delete if @index.exists? 20 | end 21 | 22 | it "instruments timeouts" do 23 | $client.stub :connection, lambda { raise Faraday::TimeoutError } do 24 | assert_raises(ElastomerClient::Client::TimeoutError) { $client.info } 25 | event = @events.detect { |e| e.payload[:action] == "cluster.info" } 26 | exception = event.payload[:exception] 27 | 28 | assert_equal "ElastomerClient::Client::TimeoutError", exception[0] 29 | assert_match "timeout", exception[1] 30 | end 31 | end 32 | 33 | it "instruments cluster actions" do 34 | $client.ping; assert_action_event("cluster.ping") 35 | $client.info; assert_action_event("cluster.info") 36 | end 37 | 38 | it "instruments node actions" do 39 | nodes = $client.nodes 40 | nodes.info; assert_action_event("nodes.info") 41 | nodes.stats; assert_action_event("nodes.stats") 42 | nodes.hot_threads; assert_action_event("nodes.hot_threads") 43 | end 44 | 45 | it "instruments index actions" do 46 | @index.exists?; assert_action_event("index.exists") 47 | @index.create(default_index_settings) 48 | 49 | assert_action_event("index.create") 50 | wait_for_index(@index.name) 51 | 52 | @index.get_settings; assert_action_event("index.get_settings") 53 | @index.update_settings(number_of_replicas: 0) 54 | 55 | assert_action_event("index.get_settings") 56 | wait_for_index(@index.name) 57 | 58 | @index.close; assert_action_event("index.close") 59 | @index.open; assert_action_event("index.open") 60 | @index.delete; assert_action_event("index.delete") 61 | end 62 | 63 | it "includes the response body in the payload" do 64 | @index.create(default_index_settings) 65 | event = @events.detect { |e| e.payload[:action] == "index.create" } 66 | 67 | assert event.payload[:response_body] 68 | end 69 | 70 | it "includes the request body in the payload" do 71 | @index.create(default_index_settings) 72 | event = @events.detect { |e| e.payload[:action] == "index.create" } 73 | 74 | payload = event.payload 75 | 76 | assert payload[:response_body] 77 | assert payload[:request_body] 78 | assert_same payload[:body], payload[:request_body] 79 | end 80 | 81 | def assert_action_event(action) 82 | assert @events.detect { |e| e.payload[:action] == action }, "expected #{action} event" 83 | end 84 | 85 | def stub_client(method, url, status = 200, body = '{"acknowledged":true}') 86 | stubs = Faraday::Adapter::Test::Stubs.new do |stub| 87 | stub.send(method, url) { |env| [status, {}, body] } 88 | end 89 | ElastomerClient::Client.new($client_params.merge(opaque_id: false, adapter: [:test, stubs])) 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems" unless defined? Gem 4 | require "bundler" 5 | Bundler.require(:default, :development) 6 | 7 | require "webmock/minitest" 8 | WebMock.allow_net_connect! 9 | 10 | require "securerandom" 11 | 12 | if ENV["COVERAGE"] == "true" 13 | require "simplecov" 14 | SimpleCov.start do 15 | add_filter "/test/" 16 | add_filter "/vendor/" 17 | end 18 | end 19 | 20 | require "minitest/spec" 21 | require "minitest/autorun" 22 | require "minitest/focus" 23 | 24 | # used in a couple test files, makes them available for all 25 | require "active_support/core_ext/enumerable" 26 | require "active_support/core_ext/hash" 27 | 28 | # push the lib folder onto the load path 29 | $LOAD_PATH.unshift "lib" 30 | require "elastomer_client/client" 31 | 32 | # we are going to use the same client instance everywhere! 33 | # the client should always be stateless 34 | $client_params = { 35 | port: ENV.fetch("ES_PORT", 9200), 36 | read_timeout: 10, 37 | open_timeout: 1, 38 | opaque_id: false, 39 | strict_params: true, 40 | compress_body: true 41 | } 42 | $client = ElastomerClient::Client.new(**$client_params) 43 | 44 | # ensure we have an Elasticsearch server to test with 45 | raise "No server available at #{$client.url}" unless $client.available? 46 | 47 | puts "Elasticsearch version is #{$client.version}" 48 | 49 | # Create client instance for replica cluster 50 | $replica_client_params = { 51 | port: ENV.fetch("ES_REPLICA_PORT", 9201), 52 | read_timeout: 10, 53 | open_timeout: 1, 54 | opaque_id: false, 55 | strict_params: true, 56 | compress_body: true 57 | } 58 | $replica_client = ElastomerClient::Client.new(**$replica_client_params) 59 | 60 | puts "Replica server is unavailable at #{$replica_client.url}" unless $replica_client.available? 61 | 62 | # remove any lingering test indices from the cluster 63 | Minitest.after_run do 64 | $client.cluster.indices.keys.each do |name| 65 | next unless name =~ /^elastomer-/i 66 | $client.index(name).delete 67 | end 68 | 69 | $client.cluster.templates.keys.each do |name| 70 | next unless name =~ /^elastomer-/i 71 | $client.template(name).delete 72 | end 73 | end 74 | 75 | # add custom assertions 76 | require File.expand_path("../assertions", __FILE__) 77 | 78 | # require 'elastomer_client/notifications' 79 | # require 'pp' 80 | 81 | # ActiveSupport::Notifications.subscribe('request.client.elastomer') do |name, start_time, end_time, transaction_id, payload| 82 | # $stdout.puts '-'*100 83 | # #$stdout.puts "-- #{payload[:action].inspect}" 84 | # pp payload #if payload[:action].nil? 85 | # end 86 | 87 | # Wait for an index to be created. Since index creation requests return 88 | # before the index is actually ready to receive documents, one needs to wait 89 | # until the cluster status recovers before proceeding. 90 | # 91 | # name - The index name to wait for 92 | # status - The status to wait for. Defaults to yellow. Yellow is the 93 | # preferred status for tests, because it waits for at least one 94 | # shard to be active, but doesn't wait for all replicas. Single 95 | # node clusters will never achieve green status with the default 96 | # setting of 1 replica. 97 | # 98 | # Returns the cluster health response. 99 | # Raises ElastomerClient::Client::TimeoutError if requested status is not achieved 100 | # within 5 seconds. 101 | def wait_for_index(name, status = "yellow") 102 | $client.cluster.health( 103 | index: name, 104 | wait_for_status: status, 105 | timeout: "5s" 106 | ) 107 | end 108 | 109 | def default_index_settings 110 | {settings: {index: {number_of_shards: 1, number_of_replicas: 0}}} 111 | end 112 | 113 | def run_snapshot_tests? 114 | unless defined? $run_snapshot_tests 115 | begin 116 | create_repo("elastomer-client-snapshot-test") 117 | $run_snapshot_tests = true 118 | rescue ElastomerClient::Client::Error 119 | puts "Could not create a snapshot repo. Snapshot tests will be disabled." 120 | puts "To enable snapshot tests, add a path.repo setting to your elasticsearch.yml file." 121 | $run_snapshot_tests = false 122 | ensure 123 | delete_repo("elastomer-client-snapshot-test") 124 | end 125 | end 126 | $run_snapshot_tests 127 | end 128 | 129 | def create_repo(name, settings = {}) 130 | location = File.join(*[ENV["SNAPSHOT_DIR"], name].compact) 131 | default_settings = {type: "fs", settings: {location:}} 132 | $client.repository(name).create(default_settings.merge(settings)) 133 | end 134 | 135 | def delete_repo(name) 136 | repo = $client.repository(name) 137 | repo.delete if repo.exists? 138 | end 139 | 140 | def delete_repo_snapshots(name) 141 | repo = $client.repository(name) 142 | if repo.exists? 143 | response = repo.snapshots.get 144 | response["snapshots"].each do |snapshot_info| 145 | repo.snapshot(snapshot_info["snapshot"]).delete 146 | end 147 | end 148 | end 149 | 150 | def with_tmp_repo(name = SecureRandom.uuid, &block) 151 | begin 152 | create_repo(name) 153 | yield $client.repository(name) 154 | ensure 155 | delete_repo_snapshots(name) 156 | delete_repo(name) 157 | end 158 | end 159 | 160 | def create_snapshot(repo, name = SecureRandom.uuid) 161 | repo.snapshot(name).create({}, wait_for_completion: true) 162 | end 163 | 164 | def with_tmp_snapshot(name = SecureRandom.uuid, &block) 165 | with_tmp_repo do |repo| 166 | create_snapshot(repo, name) 167 | yield repo.snapshot(name), repo 168 | end 169 | end 170 | 171 | # Just some busy work in the background for tasks API to detect in test cases 172 | # 173 | # Returns the thread and index references so caller can join the thread and delete 174 | # the index after the checks are performed 175 | def populate_background_index!(name) 176 | # make an index with a new client (in this thread, to avoid query check race after) 177 | name.freeze 178 | index = $client.dup.index(name) 179 | docs = index.docs("widget") 180 | 181 | # do some busy work in background thread to generate bulk-indexing tasks we 182 | # can query at the caller. return the thread ref so caller can join on it 183 | Thread.new do 184 | 100.times.each do |i| 185 | docs.bulk do |d| 186 | (1..500).each do |j| 187 | d.index \ 188 | foo: "foo_#{i}_#{j}", 189 | bar: "bar_#{i}_#{j}", 190 | baz: "baz_#{i}_#{j}" 191 | end 192 | end 193 | index.refresh 194 | end 195 | end 196 | end 197 | 198 | # when populate_background_index! is running, this query returns healthcheck tasks 199 | # that are long-running enough to be queried again for verification in test cases 200 | def query_long_running_tasks 201 | Kernel.sleep(0.01) 202 | target_tasks = [] 203 | 100.times.each do 204 | target_tasks = @tasks.get["nodes"] 205 | .map { |k, v| v["tasks"] } 206 | .flatten.map { |ts| ts.select { |k, v| /bulk/ =~ v["action"] } } 207 | .flatten.reject { |t| t.empty? } 208 | break if target_tasks.size > 0 209 | end 210 | 211 | target_tasks 212 | end 213 | 214 | # The methods below are to support intention-revealing names about version 215 | # differences in the tests. If necessary for general operation they can be moved 216 | # into ElastomerClient::VersionSupport. 217 | 218 | # COMPATIBILITY 219 | # ES8 drops mapping types, so don't wrap with a mapping type for ES8+ 220 | def mappings_wrapper(type, body, disable_all = false) 221 | if $client.version_support.es_version_8_plus? 222 | body 223 | else 224 | mapping = { 225 | _default_: { 226 | dynamic: "strict" 227 | } 228 | } 229 | mapping[type] = body 230 | if disable_all then mapping[type]["_all"] = { "enabled": false } end 231 | mapping 232 | end 233 | end 234 | 235 | # COMPATIBILITY 236 | # ES8 drops mapping types, so append type to the document only if ES version < 8 237 | def document_wrapper(type, body) 238 | if $client.version_support.es_version_8_plus? 239 | body 240 | else 241 | body.merge({_type: type}) 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /test/version_support_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "test_helper" 4 | 5 | describe ElastomerClient::VersionSupport do 6 | describe "supported versions" do 7 | it "allows 5.0.0 to 8.x" do 8 | five_series = ["5.0.0", "5.0.9", "5.1.0", "5.9.0", "5.99.100"] 9 | eight_series = ["8.0.0", "8.0.9", "8.1.0", "8.9.0", "8.99.100"] 10 | 11 | five_series.each do |version| 12 | assert ElastomerClient::VersionSupport.new(version) 13 | end 14 | 15 | eight_series.each do |version| 16 | assert_predicate ElastomerClient::VersionSupport.new(version), :es_version_8_plus? 17 | end 18 | end 19 | end 20 | 21 | describe "unsupported versions" do 22 | it "blow up" do 23 | too_low = ["0.90", "1.0.1", "2.0.0", "2.2.0"] 24 | too_high = ["9.0.0"] 25 | 26 | (too_low + too_high).each do |version| 27 | exception = assert_raises(ArgumentError, "expected #{version} to not be supported") do 28 | ElastomerClient::VersionSupport.new(version) 29 | end 30 | 31 | assert_match version, exception.message 32 | assert_match "is not supported", exception.message 33 | end 34 | end 35 | end 36 | end 37 | --------------------------------------------------------------------------------