├── .ruby-version ├── .rspec ├── lib ├── meilisearch │ ├── version.rb │ ├── multi_search.rb │ ├── tenant_token.rb │ ├── task.rb │ ├── error.rb │ ├── utils.rb │ ├── models │ │ └── task.rb │ ├── http_request.rb │ └── client.rb └── meilisearch.rb ├── .yamllint.yml ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── release-drafter.yml │ ├── gempush.yml │ ├── pre-release-tests.yml │ └── tests.yml ├── dependabot.yml ├── scripts │ └── check-release.sh └── release-draft-template.yml ├── spec ├── meilisearch │ ├── client │ │ ├── dumps_spec.rb │ │ ├── snapshots_spec.rb │ │ ├── stats_spec.rb │ │ ├── requests_spec.rb │ │ ├── health_spec.rb │ │ ├── experimental_features_spec.rb │ │ ├── batches_spec.rb │ │ ├── errors_spec.rb │ │ ├── multi_search_spec.rb │ │ ├── keys_spec.rb │ │ ├── token_spec.rb │ │ ├── tasks_spec.rb │ │ └── indexes_spec.rb │ ├── index │ │ ├── search │ │ │ ├── ranking_score_threshold_spec.rb │ │ │ ├── distinct_spec.rb │ │ │ ├── show_ranking_score_details_spec.rb │ │ │ ├── show_ranking_score_spec.rb │ │ │ ├── matching_strategy_spec.rb │ │ │ ├── matches_spec.rb │ │ │ ├── limit_spec.rb │ │ │ ├── offset_spec.rb │ │ │ ├── attributes_to_highlight_spec.rb │ │ │ ├── nested_fields_spec.rb │ │ │ ├── similar_spec.rb │ │ │ ├── vector_search_spec.rb │ │ │ ├── sort_spec.rb │ │ │ ├── filter_spec.rb │ │ │ ├── attributes_to_crop_spec.rb │ │ │ ├── facets_distribution_spec.rb │ │ │ ├── attributes_to_retrieve_spec.rb │ │ │ ├── facet_search_spec.rb │ │ │ ├── multi_params_spec.rb │ │ │ └── q_spec.rb │ │ ├── stats_spec.rb │ │ └── base_spec.rb │ ├── utils_spec.rb │ └── models │ │ └── task_spec.rb ├── support │ ├── keys_helpers.rb │ ├── indexes_helpers.rb │ ├── default_shared_context.rb │ ├── experimental_feature_helpers.rb │ ├── exceptions_helpers.rb │ └── books_contexts.rb ├── meilisearch_spec.rb └── spec_helper.rb ├── .editorconfig ├── Gemfile ├── docker-compose.yml ├── .rubocop.yml ├── meilisearch.gemspec ├── .gitignore ├── LICENSE ├── .rubocop_todo.yml ├── CONTRIBUTING.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.7 -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/meilisearch/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Meilisearch 4 | VERSION = '0.32.0' 5 | 6 | def self.qualified_version 7 | "Meilisearch Ruby (v#{VERSION})" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | extends: default 2 | ignore: | 3 | node_modules 4 | rules: 5 | comments-indentation: disable 6 | line-length: disable 7 | document-start: disable 8 | brackets: disable 9 | truthy: disable 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Support questions & other 4 | url: https://discord.meilisearch.com/ 5 | about: Support is not handled here but on our Discord 6 | -------------------------------------------------------------------------------- /spec/meilisearch/client/dumps_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Dumps' do 4 | it 'creates a new dump' do 5 | expect(client.create_dump.await).to be_succeeded 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see http://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /spec/support/keys_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module KeysHelpers 4 | def clear_all_keys(client) 5 | client.keys['results'] 6 | .map { |h| h['uid'] } 7 | .each { |uid| client.delete_key(uid) } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/indexes_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module IndexesHelpers 4 | def clear_all_indexes(client) 5 | indexes = client.indexes 6 | uids = indexes['results'].map(&:uid) 7 | uids.each do |uid| 8 | client.delete_index(uid) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v6 13 | with: 14 | config-name: release-draft-template.yml 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.RELEASE_DRAFTER_TOKEN }} 17 | -------------------------------------------------------------------------------- /spec/meilisearch/client/snapshots_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Snapshots' do 4 | it 'creates a new snapshot' do 5 | response = client.create_snapshot 6 | expect(response).to be_a(Hash) 7 | expect(response['taskUid']).to_not be_nil 8 | expect(response['status']).to_not be_nil 9 | expect(response['status']).to eq('enqueued') 10 | expect(response['type']).to eq('snapshotCreation') 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | labels: 8 | - 'dependencies' 9 | rebase-strategy: disabled 10 | 11 | - package-ecosystem: bundler 12 | directory: "/" 13 | schedule: 14 | interval: "monthly" 15 | time: "04:00" 16 | open-pull-requests-limit: 10 17 | labels: 18 | - dependencies 19 | rebase-strategy: disabled 20 | -------------------------------------------------------------------------------- /.github/scripts/check-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Checking if current tag matches the package version 4 | current_tag=$(echo $GITHUB_REF | cut -d '/' -f 3 | sed -r 's/^v//') 5 | file_tag=$(grep 'VERSION = ' lib/meilisearch/version.rb | cut -d '=' -f 2- | tr -d ' ' | tr -d \') 6 | if [ "$current_tag" != "$file_tag" ]; then 7 | echo "Error: the current tag does not match the version in package file(s)." 8 | echo "$current_tag vs $file_tag" 9 | exit 1 10 | fi 11 | 12 | echo 'OK' 13 | exit 0 14 | -------------------------------------------------------------------------------- /spec/meilisearch/client/stats_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Stats' do 4 | it 'gets version' do 5 | response = client.version 6 | expect(response).to be_a(Hash) 7 | expect(response).to have_key('commitSha') 8 | expect(response).to have_key('commitDate') 9 | expect(response).to have_key('pkgVersion') 10 | end 11 | 12 | it 'gets stats' do 13 | response = client.stats 14 | expect(response).to have_key('databaseSize') 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # meilisearchsearch.gemspec dependencies 8 | gemspec 9 | 10 | group :development, :test do 11 | gem 'byebug' 12 | gem 'rspec', '~> 3.0' 13 | gem 'simplecov' 14 | gem 'simplecov-cobertura' 15 | 16 | # Used only for testing, none of the classes are exposed to the public API. 17 | gem 'jwt' 18 | end 19 | 20 | group :development do 21 | gem 'rubocop', '~> 1.75.1', require: false 22 | end 23 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/ranking_score_threshold_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with rankingScoreThreshold' do 4 | include_context 'search books with genre' 5 | 6 | it 'does a custom search with rankingScoreThreshold' do 7 | response = index.search('harry potter and the prisoner of azkaban', { rankingScoreThreshold: 0.9 }) 8 | expect(response['hits'].count).to be(0) 9 | 10 | response = index.search('harry potter and the', { rankingScoreThreshold: 0.3 }) 11 | expect(response['hits'].count).to be(2) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request & Enhancement 💡 3 | about: Suggest a new idea for the project. 4 | title: '' 5 | labels: ["enhancement"] 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | ### Description 12 | 13 | 14 | ### Basic example 15 | 19 | 20 | ### Other 21 | 22 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/distinct_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Distinct search' do 4 | include_context 'search books with genre' 5 | 6 | before do 7 | index.update_filterable_attributes(['genre']).await 8 | end 9 | 10 | it 'does a search without distinct' do 11 | response = index.search('harry potter') 12 | expect(response['hits'].count).to eq(2) 13 | end 14 | 15 | it 'does a custom search with distinct' do 16 | response = index.search('harry potter', { distinct: 'genre' }) 17 | expect(response['hits'].count).to eq(1) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/meilisearch/client/requests_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client requests' do 4 | let(:key) { SecureRandom.uuid } 5 | 6 | before do 7 | expect(Meilisearch::Client).to receive(:post) 8 | .with(kind_of(String), hash_including(body: "{\"primaryKey\":\"#{key}\",\"uid\":\"#{key}\"}")) 9 | .and_call_original 10 | end 11 | 12 | it 'parses options when they are in a snake_case' do 13 | client.create_index(key, primary_key: key).await 14 | 15 | index = client.fetch_index(key) 16 | expect(index.uid).to eq(key) 17 | expect(index.primary_key).to eq(key) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/show_ranking_score_details_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with ranking score details' do 4 | include_context 'search books with genre' 5 | 6 | it 'shows the ranking score details' do 7 | response = index.search('hobbit', { show_ranking_score_details: true }) 8 | expect(response['hits'][0]).to have_key('_rankingScoreDetails') 9 | end 10 | 11 | it 'hides the ranking score details when showRankingScoreDetails is not set' do 12 | response = index.search('hobbit') 13 | expect(response['hits'][0]).not_to have_key('_rankingScoreDetails') 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/meilisearch/client/health_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Health' do 4 | let(:client) { Meilisearch::Client.new(URL, MASTER_KEY) } 5 | let(:wrong_client) { Meilisearch::Client.new('bad_url') } 6 | 7 | it 'is healthy when the url is valid' do 8 | expect(client.healthy?).to be true 9 | end 10 | 11 | it 'is unhealthy when the url is invalid' do 12 | expect(wrong_client.healthy?).to be false 13 | end 14 | 15 | it 'returns the health information' do 16 | response = client.health 17 | expect(response).to be_a(Hash) 18 | expect(response).to have_key('status') 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/support/default_shared_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'test defaults' do 4 | let(:client) { Meilisearch::Client.new(URL, MASTER_KEY, { timeout: 2, max_retries: 1 }) } 5 | 6 | before do 7 | clear_all_indexes(client) 8 | clear_all_keys(client) 9 | end 10 | 11 | def random_uid 12 | SecureRandom.hex(4) 13 | end 14 | 15 | def snake_case_word(camel_cased_word) 16 | return camel_cased_word unless /[A-Z]/.match?(camel_cased_word) 17 | 18 | camel_cased_word.gsub(/(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-z\d])(?=[A-Z])/, '_') 19 | .tr('-', '_') 20 | .downcase 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | volumes: 2 | bundle: 3 | 4 | services: 5 | package: 6 | image: ruby:3.3 7 | tty: true 8 | stdin_open: true 9 | working_dir: /home/package 10 | environment: 11 | - MEILISEARCH_URL=meilisearch 12 | - MEILISEARCH_PORT=7700 13 | - BUNDLE_PATH=/vendor/bundle 14 | depends_on: 15 | - meilisearch 16 | links: 17 | - meilisearch 18 | volumes: 19 | - ./:/home/package 20 | - bundle:/vendor/bundle 21 | 22 | meilisearch: 23 | image: getmeili/meilisearch-enterprise:latest 24 | ports: 25 | - "7700" 26 | environment: 27 | - MEILI_MASTER_KEY=masterKey 28 | - MEILI_NO_ANALYTICS=true 29 | -------------------------------------------------------------------------------- /spec/meilisearch/index/stats_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Stats' do 4 | include_context 'search books with genre' 5 | 6 | it 'returns stats of the index' do 7 | response = index.stats 8 | expect(response).to be_a(Hash) 9 | expect(response).not_to be_empty 10 | end 11 | 12 | it 'gets the number of documents' do 13 | response = index.number_of_documents 14 | expect(response).to eq(documents.count) 15 | end 16 | 17 | it 'gets the distribution of fields' do 18 | response = index.field_distribution 19 | expect(response).to be_a(Hash) 20 | end 21 | 22 | it 'knows when it is indexing' do 23 | expect(index.indexing?).to be_falsy 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/meilisearch/multi_search.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Meilisearch 4 | module MultiSearch 5 | # Performs search on one or more indexes 6 | # 7 | # @param [Hash] federation_options 8 | # - `limit`: number of results in the merged list 9 | # - `offset`: number of results to skip in the merged list 10 | def multi_search(data = nil, queries: [], federation: nil) 11 | Utils.soft_deprecate('multi_search([])', 'multi_search(queries: [])') if data 12 | 13 | queries += data if data 14 | 15 | queries = Utils.transform_attributes(queries) 16 | federation = Utils.transform_attributes(federation) 17 | 18 | http_post '/multi-search', queries: queries, federation: federation 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/show_ranking_score_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with ranking score' do 4 | include_context 'search books with genre' 5 | 6 | it 'shows the ranking score when showRankingScore is true' do 7 | response = index.search('hobbit', { show_ranking_score: true }) 8 | expect(response['hits'][0]).to have_key('_rankingScore') 9 | end 10 | 11 | it 'hides the ranking score when showRankingScore is false' do 12 | response = index.search('hobbit', { show_ranking_score: false }) 13 | expect(response['hits'][0]).not_to have_key('_rankingScore') 14 | end 15 | 16 | it 'hides the ranking score when showRankingScore is not set' do 17 | response = index.search('hobbit') 18 | expect(response['hits'][0]).not_to have_key('_rankingScore') 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/matching_strategy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with matching_strategy' do 4 | include_context 'search books with nested fields' 5 | 6 | it 'does a custom search with a matching strategy ALL' do 7 | response = index.search('best book again', matching_strategy: 'all') 8 | 9 | expect(response['hits'].count).to eq(1) 10 | end 11 | 12 | it 'does a custom search with a matching strategy LAST' do 13 | response = index.search('best book again', matching_strategy: 'last') 14 | 15 | expect(response['hits'].count).to eq(2) 16 | end 17 | 18 | it 'does a custom search with a matching strategy frequency' do 19 | response = index.search('best book again', matching_strategy: 'frequency') 20 | 21 | expect(response['hits'].count).to eq(1) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.github/release-draft-template.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION 💎' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | exclude-labels: 4 | - 'skip-changelog' 5 | version-resolver: 6 | minor: 7 | labels: 8 | - 'breaking-change' 9 | default: patch 10 | categories: 11 | - title: '⚠️ Breaking changes' 12 | label: 'breaking-change' 13 | - title: '🚀 Enhancements' 14 | label: 'enhancement' 15 | - title: '🐛 Bug Fixes' 16 | label: 'bug' 17 | - title: '🔒 Security' 18 | label: 'security' 19 | - title: '⚙️ Maintenance/misc' 20 | label: 21 | - 'dependencies' 22 | - 'maintenance' 23 | - 'documentation' 24 | template: | 25 | $CHANGES 26 | 27 | Thanks again to $CONTRIBUTORS! 🎉 28 | no-changes-template: 'Changes are coming soon 😎' 29 | sort-direction: 'ascending' 30 | replacers: 31 | - search: '/(?:and )?@meili-bot,?/g' 32 | replace: '' 33 | -------------------------------------------------------------------------------- /.github/workflows/gempush.yml: -------------------------------------------------------------------------------- 1 | name: Ruby Gem 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | name: Build + Publish 11 | runs-on: ubuntu-22.04 12 | 13 | steps: 14 | - uses: actions/checkout@v6 15 | - name: Set up Ruby 3.0 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: '3.0' 19 | - name: Check release validity 20 | run: sh .github/scripts/check-release.sh 21 | - name: Publish to RubyGems 22 | run: | 23 | mkdir -p $HOME/.gem 24 | touch $HOME/.gem/credentials 25 | chmod 0600 $HOME/.gem/credentials 26 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 27 | gem build meilisearch.gemspec 28 | gem push *.gem 29 | env: 30 | GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} 31 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | # The behavior of RuboCop can be controlled via the .rubocop.yml 4 | # configuration file. It makes it possible to enable/disable 5 | # certain cops (checks) and to alter their behavior if they accept 6 | # any parameters. The file can be placed either in your home 7 | # directory or in some project directory. 8 | # 9 | # RuboCop will start looking for the configuration file in the directory 10 | # where the inspected file is and continue its way up to the root directory. 11 | # 12 | # See https://github.com/rubocop-hq/rubocop/blob/master/manual/configuration.md 13 | 14 | AllCops: 15 | NewCops: enable 16 | TargetRubyVersion: 3.0 17 | 18 | Style/SymbolArray: 19 | EnforcedStyle: brackets 20 | 21 | Style/WordArray: 22 | EnforcedStyle: brackets 23 | 24 | Style/Documentation: 25 | Enabled: false 26 | 27 | Metrics/BlockLength: 28 | Exclude: 29 | - 'spec/**/*.rb' 30 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/matches_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with showMatchesPosition' do 4 | include_context 'search books with genre' 5 | 6 | it 'does a custom search with showMatchesPosition' do 7 | response = index.search('the', show_matches_position: true) 8 | expect(response).to be_a(Hash) 9 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 10 | expect(response['hits'].first).to have_key('_matchesPosition') 11 | expect(response['hits'].first['_matchesPosition']).to have_key('title') 12 | end 13 | 14 | it 'does a placeholder search with showMatchesPosition' do 15 | response = index.search('', show_matches_position: true) 16 | expect(response).to be_a(Hash) 17 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 18 | expect(response['hits'].first).to have_key('_matchesPosition') 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /meilisearch.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.join(File.dirname(__FILE__), 'lib', 'meilisearch', 'version') 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'meilisearch' 7 | s.version = Meilisearch::VERSION 8 | s.authors = ['Meili'] 9 | s.email = 'bonjour@meilisearch.com' 10 | s.summary = 'An easy-to-use ruby client for Meilisearch API' 11 | s.description = 'An easy-to-use ruby client for Meilisearch API. See https://github.com/meilisearch/meilisearch' 12 | s.homepage = 'https://github.com/meilisearch/meilisearch-ruby' 13 | s.licenses = ['MIT'] 14 | 15 | s.files = Dir['{lib}/**/*', 'LICENSE', 'README.md'] 16 | 17 | s.required_ruby_version = '>= 3.0.0' 18 | s.add_dependency 'httparty', '~> 0.22' 19 | 20 | s.metadata['rubygems_mfa_required'] = 'true' 21 | s.metadata['source_code_uri'] = 'https://github.com/meilisearch/meilisearch-ruby' 22 | s.metadata['bug_tracker_uri'] = 'https://github.com/meilisearch/meilisearch-ruby/issues' 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/experimental_feature_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/http' 4 | require 'json' 5 | 6 | module ExperimentalFeatureHelpers 7 | def enable_edit_documents_by_function(toggle) 8 | configure_feature('editDocumentsByFunction', toggle) 9 | end 10 | 11 | def enable_composite_embedders(toggle) 12 | configure_feature('compositeEmbedders', toggle) 13 | end 14 | 15 | private 16 | 17 | # @param [String] attribute_to_toggle 18 | # @param [Boolean] toggle 19 | def configure_feature(attribute_to_toggle, toggle) 20 | uri = URI("http://#{ENV.fetch('MEILISEARCH_URL', 'localhost')}") 21 | uri.path = '/experimental-features' 22 | uri.port = ENV.fetch('MEILISEARCH_PORT', '7700') 23 | 24 | req = Net::HTTP::Patch.new(uri) 25 | req.body = { attribute_to_toggle => toggle }.to_json 26 | req.content_type = 'application/json' 27 | req['Authorization'] = "Bearer #{MASTER_KEY}" 28 | 29 | Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Documentation cache and generated files: 20 | /.yardoc/ 21 | /_yardoc/ 22 | /doc/ 23 | /rdoc/ 24 | 25 | ## Environment normalization: 26 | /.bundle/ 27 | /vendor/bundle 28 | /lib/bundler/man/ 29 | 30 | # for a library or gem, you might want to ignore these files since the code is 31 | # intended to run in multiple environments; otherwise, check them in: 32 | # Gemfile.lock 33 | # .ruby-version 34 | # .ruby-gemset 35 | 36 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 37 | .rvmrc 38 | 39 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 40 | .rubocop-https?--* 41 | 42 | # MacOS 43 | .DS_Store 44 | 45 | # lock 46 | Gemfile.lock 47 | 48 | # Local editor configurations 49 | .vscode/ 50 | .idea/ 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2025 Meili SAS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/limit_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with limit' do 4 | include_context 'search books with genre' 5 | 6 | it 'does a custom search with limit' do 7 | response = index.search('the', limit: 1) 8 | expect(response).to be_a(Hash) 9 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 10 | expect(response['limit']).to be(1) 11 | expect(response['hits'].count).to eq(1) 12 | expect(response['hits'].first).not_to have_key('_formatted') 13 | end 14 | 15 | it 'does a placeholder search with limit' do 16 | response = index.search('', limit: 2) 17 | expect(response['limit']).to be(2) 18 | expect(response['hits'].count).to be(2) 19 | expect(response['hits'].first).not_to have_key('_formatted') 20 | end 21 | 22 | it 'does a placeholder search with bigger limit than the nb of docs' do 23 | response = index.search('', limit: 20) 24 | expect(response['hits'].count).to be(documents.count) 25 | expect(response['limit']).to be(20) 26 | expect(response['hits'].first).not_to have_key('_formatted') 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/support/exceptions_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ExceptionsHelpers 4 | def raise_meilisearch_api_error_with(http_code, ms_code, ms_type) 5 | raise_exception(an_instance_of(Meilisearch::ApiError) 6 | .and(having_attributes( 7 | http_code: http_code, 8 | ms_code: ms_code, 9 | ms_type: ms_type 10 | ))) 11 | end 12 | 13 | def raise_bad_request_meilisearch_api_error 14 | raise_meilisearch_api_error_with( 15 | 400, 16 | 'bad_request', 17 | 'invalid_request' 18 | ) 19 | end 20 | 21 | def raise_index_not_found_meilisearch_api_error 22 | raise_meilisearch_api_error_with( 23 | 404, 24 | 'index_not_found', 25 | 'invalid_request' 26 | ) 27 | end 28 | 29 | def raise_document_not_found_meilisearch_api_error 30 | raise_meilisearch_api_error_with( 31 | 404, 32 | 'document_not_found', 33 | 'invalid_request' 34 | ) 35 | end 36 | 37 | def raise_missing_primary_key_meilisearch_api_error 38 | raise_meilisearch_api_error_with( 39 | 400, 40 | 'primary_key_inference_failed', 41 | 'invalid_request' 42 | ) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/offset_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with offset' do 4 | include_context 'search books with genre' 5 | 6 | it 'does a custom search with an offset set to 1' do 7 | response = index.search('prince') 8 | response_with_offset = index.search('prince', offset: 1) 9 | expect(response['hits'][1]).to eq(response_with_offset['hits'][0]) 10 | end 11 | 12 | it 'does a placeholder search with an offset set to 3' do 13 | response = index.search('') 14 | response_with_offset = index.search('', offset: 3) 15 | expect(response['hits'][3]).to eq(response_with_offset['hits'][0]) 16 | end 17 | 18 | it 'does a placeholder search with an offset set to 3 and custom ranking rules' do 19 | index.update_ranking_rules(['objectId:asc']).await 20 | response = index.search('') 21 | response_with_offset = index.search('', offset: 3) 22 | expect(response['hits'].first['objectId']).to eq(1) 23 | expect(response['hits'][3]).to eq(response_with_offset['hits'][0]) 24 | expect(response['hits'].last['objectId']).to eq(1344) 25 | expect(response_with_offset['hits'].last['objectId']).to eq(1344) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.github/workflows/pre-release-tests.yml: -------------------------------------------------------------------------------- 1 | # Testing the code base against the Meilisearch pre-releases 2 | name: Pre-Release Tests 3 | 4 | # Will only run for PRs and pushes to bump-meilisearch-v* 5 | on: 6 | push: 7 | branches: bump-meilisearch-v* 8 | pull_request: 9 | branches: bump-meilisearch-v* 10 | 11 | jobs: 12 | integration_tests: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | ruby-version: ['3.1', '3.2', '3.3'] 17 | name: integration-tests-against-rc (ruby ${{ matrix.ruby-version }}) 18 | runs-on: ubuntu-22.04 19 | steps: 20 | - uses: actions/checkout@v6 21 | - name: Set up Ruby ${{ matrix.ruby-version }} 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby-version }} 25 | - name: Install ruby dependencies 26 | run: bundle install --with test 27 | - name: Get the latest Meilisearch RC 28 | run: echo "MEILISEARCH_VERSION=$(curl https://raw.githubusercontent.com/meilisearch/integration-guides/main/scripts/get-latest-meilisearch-rc.sh | bash)" >> $GITHUB_ENV 29 | - name: Meilisearch (${{ env.MEILISEARCH_VERSION }}) setup with Docker 30 | run: docker run -d -p 7700:7700 getmeili/meilisearch-enterprise:${{ env.MEILISEARCH_VERSION }} meilisearch --master-key=masterKey --no-analytics 31 | - name: Run test suite 32 | run: bundle exec rspec 33 | -------------------------------------------------------------------------------- /lib/meilisearch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | 5 | require 'meilisearch/version' 6 | require 'meilisearch/utils' 7 | require 'meilisearch/models/task' 8 | require 'meilisearch/http_request' 9 | require 'meilisearch/multi_search' 10 | require 'meilisearch/tenant_token' 11 | require 'meilisearch/task' 12 | require 'meilisearch/client' 13 | require 'meilisearch/index' 14 | 15 | module Meilisearch 16 | end 17 | 18 | # Softly deprecate the old spelling of the top level module 19 | # from MeiliSearch to Meilisearch 20 | module MeiliSearch 21 | class << self 22 | def const_missing(const_name) 23 | _warn_about_deprecation 24 | 25 | Meilisearch.const_get(const_name) 26 | end 27 | 28 | def method_missing(method_name, *args, **kwargs) 29 | _warn_about_deprecation 30 | 31 | Meilisearch.send(method_name, *args, **kwargs) 32 | end 33 | 34 | def respond_to_missing?(method_name, *) 35 | Meilisearch.respond_to?(method_name) || super 36 | end 37 | 38 | private 39 | 40 | def _warn_about_deprecation 41 | return if @warned 42 | 43 | Meilisearch::Utils.logger.warn <<~RENAMED_MODULE_WARNING 44 | [meilisearch-ruby] The top-level module of Meilisearch has been renamed. 45 | [meilisearch-ruby] Please update "MeiliSearch" to "Meilisearch". 46 | RENAMED_MODULE_WARNING 47 | 48 | @warned = true 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/attributes_to_highlight_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with highlight' do 4 | include_context 'search books with genre' 5 | 6 | it 'does a custom search with highlight' do 7 | response = index.search('the', attributes_to_highlight: ['title']) 8 | expect(response).to be_a(Hash) 9 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 10 | expect(response['hits'].count).to eq(4) 11 | expect(response['hits'].first).to have_key('_formatted') 12 | expect(response['hits'].first['_formatted']['title']).to eq('The Hobbit') 13 | end 14 | 15 | it 'does a placeholder search with attributes to highlight' do 16 | response = index.search('', attributes_to_highlight: ['*']) 17 | expect(response).to be_a(Hash) 18 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 19 | expect(response['hits'].count).to eq(8) 20 | expect(response['hits'].first).to have_key('_formatted') 21 | end 22 | 23 | it 'does a placeholder search (nil) with attributes to highlight' do 24 | response = index.search(nil, attributes_to_highlight: ['*']) 25 | expect(response).to be_a(Hash) 26 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 27 | expect(response['hits'].count).to eq(documents.count) 28 | expect(response['hits'].first).to have_key('_formatted') 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/nested_fields_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - nested fields search' do 4 | include_context 'search books with nested fields' 5 | 6 | it 'searches without params' do 7 | response = index.search('an awesome') 8 | 9 | expect(response['hits'].count).to eq(1) 10 | expect(response.dig('hits', 0, 'info', 'comment')).to eq('An awesome book') 11 | expect(response.dig('hits', 0, 'info', 'reviewNb')).to eq(900) 12 | end 13 | 14 | it 'searches within index with searchableAttributes setting' do 15 | index.update_searchable_attributes(['title', 'info.comment']).await 16 | index.add_documents(documents).await 17 | 18 | response = index.search('An awesome') 19 | 20 | expect(response['hits'].count).to eq(1) 21 | expect(response.dig('hits', 0, 'info', 'comment')).to eq('An awesome book') 22 | expect(response.dig('hits', 0, 'info', 'reviewNb')).to eq(900) 23 | end 24 | 25 | it 'searches within index with searchableAttributes and sortableAttributes settings' do 26 | index.update_searchable_attributes(['title', 'info.comment']).await 27 | index.update_sortable_attributes(['info.reviewNb']).await 28 | index.add_documents(documents).await 29 | 30 | response = index.search('An awesome') 31 | 32 | expect(response['hits'].count).to eq(1) 33 | expect(response.dig('hits', 0, 'info', 'comment')).to eq('An awesome book') 34 | expect(response.dig('hits', 0, 'info', 'reviewNb')).to eq(900) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/similar_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search for similar documents' do 4 | let(:new_index) { client.index('similar_test_search') } 5 | 6 | before do 7 | client.create_index('similar_test_search').await 8 | end 9 | 10 | it 'requires document_id parameter' do 11 | expect { new_index.search_similar_documents }.to raise_error ArgumentError 12 | end 13 | 14 | it 'does a search for similar documents' do 15 | documents = [ 16 | { 17 | title: 'Shazam!', 18 | release_year: 2019, 19 | id: '287947', 20 | _vectors: { 'manual' => [0.8, 0.4, -0.5] } 21 | }, 22 | { 23 | title: 'Captain Marvel', 24 | release_year: 2019, 25 | id: '299537', 26 | _vectors: { 'manual' => [0.6, 0.8, -0.2] } 27 | }, 28 | { 29 | title: 'How to Train Your Dragon: The Hidden World', 30 | release_year: 2019, 31 | id: '166428', 32 | _vectors: { 'manual' => [0.7, 0.7, -0.4] } 33 | } 34 | ] 35 | 36 | new_index.update_settings( 37 | embedders: { 38 | 'manual' => { 39 | source: 'userProvided', 40 | dimensions: 3 41 | } 42 | } 43 | ).await 44 | 45 | new_index.add_documents(documents).await 46 | 47 | response = new_index.search_similar_documents('287947', embedder: 'manual') 48 | 49 | expect(response['hits']).not_to be_empty 50 | expect(response['estimatedTotalHits']).not_to be_nil 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2025-04-12 16:19:21 UTC using RuboCop version 1.75.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. 11 | Metrics/AbcSize: 12 | Max: 24 13 | 14 | # Offense count: 4 15 | # Configuration parameters: CountComments, CountAsOne. 16 | Metrics/ClassLength: 17 | Max: 537 18 | 19 | # Offense count: 4 20 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 21 | Metrics/MethodLength: 22 | Max: 18 23 | 24 | # Offense count: 3 25 | # Configuration parameters: Max, CountKeywordArgs. 26 | Metrics/ParameterLists: 27 | MaxOptionalParameters: 4 28 | 29 | # Offense count: 1 30 | Naming/AccessorMethodName: 31 | Exclude: 32 | - 'lib/meilisearch/index.rb' 33 | 34 | # Offense count: 1 35 | # This cop supports safe autocorrection (--autocorrect). 36 | Style/IfUnlessModifier: 37 | Exclude: 38 | - 'lib/meilisearch/index.rb' 39 | 40 | # Offense count: 2 41 | # This cop supports safe autocorrection (--autocorrect). 42 | # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. 43 | # URISchemes: http, https 44 | Layout/LineLength: 45 | Max: 160 46 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/vector_search_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Vector search' do 4 | it 'does a basic search' do 5 | documents = [ 6 | { objectId: 0, _vectors: { custom: [0, 0.8, -0.2] }, title: 'Across The Universe' }, 7 | { objectId: 1, _vectors: { custom: [1, -0.2, 0] }, title: 'All Things Must Pass' }, 8 | { objectId: 2, _vectors: { custom: [0.5, 3, 1] }, title: 'And Your Bird Can Sing' } 9 | ] 10 | settings = { 11 | embedders: { 12 | custom: { 13 | source: 'userProvided', 14 | dimensions: 3 15 | } 16 | } 17 | } 18 | 19 | client.create_index('vector_test_search').await 20 | new_index = client.index('vector_test_search') 21 | new_index.update_settings(settings).await 22 | new_index.add_documents(documents).await 23 | 24 | expect(new_index.search('', 25 | { vector: [9, 9, 9], 26 | hybrid: { embedder: 'custom', semanticRatio: 1.0 } })['hits']).not_to be_empty 27 | expect(new_index.search('', 28 | { vector: [9, 9, 9], 29 | hybrid: { embedder: 'custom', semanticRatio: 1.0 } })['semanticHitCount']).to be 3 30 | expect(new_index.search('All Things Must Pass', 31 | { vector: [9, 9, 9], 32 | hybrid: { embedder: 'custom', semanticRatio: 0.1 } })['semanticHitCount']).to be 2 33 | expect(new_index.search('All Things Must Pass')['hits']).not_to be_empty 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/sort_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Sorted search' do 4 | include_context 'search books with author, genre, year' 5 | before do 6 | sortable_update = index.update_sortable_attributes(['year', 'author']) 7 | 8 | index.update_ranking_rules([ 9 | 'sort', 10 | 'words', 11 | 'typo', 12 | 'proximity', 13 | 'attribute', 14 | 'exactness' 15 | ]).await 16 | sortable_update.await 17 | end 18 | 19 | it 'does a custom search with one sort' do 20 | response = index.search('prince', { sort: ['year:desc'] }) 21 | expect(response['hits'].count).to eq(2) 22 | expect(response['hits'].first['objectId']).to eq(4) 23 | end 24 | 25 | it 'does a custom search by sorting on strings' do 26 | response = index.search('prince', { sort: ['author:asc'] }) 27 | expect(response['hits'].count).to eq(2) 28 | expect(response['hits'].first['objectId']).to eq(456) 29 | end 30 | 31 | it 'does a custom search with multiple sort' do 32 | response = index.search('pr', { sort: ['year:desc', 'author:asc'] }) 33 | expect(response['hits'].count).to eq(3) 34 | expect(response['hits'].first['objectId']).to eq(4) 35 | end 36 | 37 | it 'does a placeholder search with multiple sort' do 38 | response = index.search('', { sort: ['year:desc', 'author:asc'] }) 39 | expect(response['hits'].count).to eq(documents.count) 40 | expect(response['hits'].first['objectId']).to eq(2056) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/meilisearch/tenant_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Meilisearch 4 | module TenantToken 5 | HEADER = { 6 | typ: 'JWT', 7 | alg: 'HS256' 8 | }.freeze 9 | 10 | def generate_tenant_token(api_key_uid, search_rules, api_key: nil, expires_at: nil) 11 | signature = retrieve_valid_key!(api_key, @api_key) 12 | expiration = validate_expires_at!(expires_at) 13 | rules = validate_search_rules!(search_rules) 14 | unsigned_data = build_payload(expiration, rules, api_key_uid) 15 | 16 | combine(unsigned_data, to_base64(sign_data(signature, unsigned_data))) 17 | end 18 | 19 | private 20 | 21 | def build_payload(expiration, rules, api_key_uid) 22 | payload = { 23 | searchRules: rules, 24 | apiKeyUid: api_key_uid, 25 | exp: expiration 26 | } 27 | 28 | combine(encode(HEADER), encode(payload)) 29 | end 30 | 31 | def validate_expires_at!(expires_at) 32 | return unless expires_at 33 | return expires_at.to_i if expires_at.utc? && expires_at > Time.now.utc 34 | 35 | raise 36 | rescue StandardError 37 | raise ExpireOrInvalidSignature 38 | end 39 | 40 | def validate_search_rules!(data) 41 | return data if data 42 | 43 | raise InvalidSearchRules 44 | end 45 | 46 | def retrieve_valid_key!(*keys) 47 | key = keys.compact.find { |k| !k.empty? } 48 | 49 | raise InvalidApiKey if key.nil? 50 | 51 | key 52 | end 53 | 54 | def sign_data(key, msg) 55 | OpenSSL::HMAC.digest('SHA256', key, msg) 56 | end 57 | 58 | def to_base64(data) 59 | Base64.urlsafe_encode64(data, padding: false) 60 | end 61 | 62 | def encode(data) 63 | to_base64(JSON.generate(data)) 64 | end 65 | 66 | def combine(*parts) 67 | parts.join('.') 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 🐞 3 | about: Create a report to help us improve. 4 | title: '' 5 | labels: ["bug"] 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | ### Description 12 | 13 | 14 | ### Expected behavior 15 | 16 | 17 | ### Current behavior 18 | 19 | 20 | ### Screenshots or logs 21 | 22 | 23 | ### Environment 24 | **Operating System** [e.g. Debian GNU/Linux] (`cat /etc/*-release | head -n1`): 25 | 26 | **Meilisearch version** (`./meilisearch --version`): 27 | 28 | **meilisearch-ruby version** (`bundle info meilisearch` or `gem list meilisearch$`): 29 | 30 | ### Reproduction script: 31 | 32 | 33 | 34 | 64 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | merge_group: 9 | 10 | jobs: 11 | integration_tests: 12 | # Will not run if the event is a PR to bump-meilisearch-v* (so a pre-release PR) 13 | # Will still run for each push to bump-meilisearch-v* 14 | if: github.event_name != 'pull_request' || !startsWith(github.base_ref, 'bump-meilisearch-v') 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby-version: ['3.1', '3.2', '3.3'] 19 | name: integration-tests (ruby ${{ matrix.ruby-version }}) 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v6 23 | - name: Set up Ruby ${{ matrix.ruby-version }} 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby-version }} 27 | - name: Install ruby dependencies 28 | run: bundle install --with test 29 | - name: Meilisearch (latest) setup with Docker 30 | run: docker run -d -p 7700:7700 getmeili/meilisearch-enterprise:latest meilisearch --master-key=masterKey --no-analytics 31 | - name: Run test suite 32 | run: bundle exec rspec 33 | - name: Upload coverage reports to Codecov 34 | uses: codecov/codecov-action@v5 35 | if: matrix.ruby-version == '3.3' 36 | with: 37 | token: ${{ secrets.CODECOV_TOKEN }} 38 | 39 | linter_check: 40 | name: linter-check 41 | runs-on: ubuntu-22.04 42 | steps: 43 | - uses: actions/checkout@v6 44 | - name: Set up Ruby 45 | uses: ruby/setup-ruby@v1 46 | with: 47 | ruby-version: 3.0 48 | - name: Install ruby dependencies 49 | run: bundle install --with test 50 | - name: Run linter 51 | run: bundle exec rubocop lib/ spec/ 52 | 53 | yaml-lint: 54 | name: Yaml linting check 55 | runs-on: ubuntu-latest 56 | steps: 57 | - uses: actions/checkout@v6 58 | - name: Yaml lint check 59 | uses: ibiqlik/action-yamllint@v3 60 | with: 61 | config_file: .yamllint.yml 62 | -------------------------------------------------------------------------------- /spec/meilisearch/client/experimental_features_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Experimental features' do 4 | describe '#experimental_features' do 5 | it 'returns the available experimental features' do 6 | expect(client.experimental_features).to be_kind_of(Hash) 7 | end 8 | end 9 | 10 | context '#update_experimental_features' do 11 | context 'when given one key' do 12 | it 'updates that one key' do 13 | feat, status = client.experimental_features.find { |_, v| [true, false].include?(v) } 14 | 15 | pending('This test requires Meilisearch to have a true/false experimental feature') unless feat 16 | 17 | feat_snaked = snake_case_word(feat) 18 | 19 | client.update_experimental_features(feat_snaked => status) 20 | expect(client.experimental_features).to include(feat => status) 21 | 22 | client.update_experimental_features(feat_snaked => !status) 23 | expect(client.experimental_features).to include(feat => !status) 24 | end 25 | 26 | it 'does not change others' do 27 | prev_features = client.experimental_features 28 | 29 | client.update_experimental_features(metrics: true) 30 | expect(client.experimental_features).to include(**prev_features.except('metrics')) 31 | end 32 | end 33 | 34 | context 'when given all of the keys' do 35 | it 'sets all keys' do 36 | edited_features = client.experimental_features.to_h do |feature, val| 37 | val = !val if [true, false].include?(val) 38 | 39 | [snake_case_word(feature).to_sym, val] 40 | end 41 | 42 | client.update_experimental_features(edited_features) 43 | expect(client.experimental_features).to eq( 44 | Meilisearch::Utils.transform_attributes(edited_features) 45 | ) 46 | end 47 | end 48 | 49 | context 'when given an invalid feature' do 50 | it 'raises an error' do 51 | expect do 52 | client.update_experimental_features(penguins: true) 53 | end.to raise_error(Meilisearch::ApiError) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/meilisearch/client/batches_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Batches' do 4 | let(:index) { client.index(random_uid) } 5 | 6 | def new_task 7 | index.add_documents({ id: 1 }) 8 | end 9 | 10 | describe '#batches' do 11 | it 'includes the most recent batch' do 12 | new_task.await 13 | 14 | expect(client.batches).to match( 15 | 'results' => array_including( 16 | a_hash_including( 17 | 'uid' => anything, 18 | 'details' => a_hash_including('receivedDocuments' => 1), 19 | 'progress' => anything, 20 | 'stats' => a_hash_including( 21 | 'totalNbTasks' => 1, 22 | 'types' => { 'documentAdditionOrUpdate' => 1 }, 23 | 'progressTrace' => anything, 24 | 'writeChannelCongestion' => anything, 25 | 'internalDatabaseSizes' => anything 26 | ), 27 | 'duration' => anything, 28 | 'startedAt' => anything, 29 | 'finishedAt' => anything 30 | ) 31 | ), 32 | 'total' => anything, 33 | 'limit' => 20, 34 | 'next' => anything, 35 | 'from' => anything 36 | ) 37 | end 38 | 39 | it 'accepts options such as limit' do 40 | new_task.await 41 | new_task.await 42 | 43 | batches = client.batches(limit: 1) 44 | unlimited_batches = client.batches 45 | 46 | expect(batches['results']).to be_one 47 | expect(unlimited_batches['results'].count).to be > 1 48 | end 49 | 50 | it 'allows searching by task uids' do 51 | new_tasks = Array.new(3) { new_task } 52 | 53 | new_tasks.last.await # give time for meilisearch to batch new tasks 54 | batches = client.batches(uids: new_tasks.map(&:uid).join(',')) 55 | 56 | task_count = batches['results'].sum do |batch| 57 | batch['stats']['totalNbTasks'].to_i 58 | end 59 | 60 | expect(task_count).to eq(3) 61 | end 62 | end 63 | 64 | context '#batch' do 65 | it 'shows details of a single batch' do 66 | new_task.await 67 | 68 | first_batch = client.batches['results'].first 69 | expect(client.batch(first_batch['uid'])).to eq(first_batch) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/meilisearch/task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'meilisearch/http_request' 4 | require 'timeout' 5 | 6 | module Meilisearch 7 | class Task < HTTPRequest 8 | ALLOWED_PARAMS = [ 9 | :limit, :from, :index_uids, :types, :statuses, :uids, :canceled_by, 10 | :before_enqueued_at, :after_enqueued_at, :before_started_at, :after_started_at, 11 | :before_finished_at, :after_finished_at, :reverse 12 | ].freeze 13 | ALLOWED_CANCELATION_PARAMS = (ALLOWED_PARAMS - [:limit, :from]).freeze 14 | 15 | def task_list(options = {}) 16 | http_get '/tasks/', Utils.parse_query(options, ALLOWED_PARAMS) 17 | end 18 | 19 | def task(task_uid) 20 | http_get "/tasks/#{task_uid}" 21 | end 22 | 23 | def index_tasks(index_uid) 24 | http_get '/tasks', { indexUids: [index_uid].flatten.join(',') } 25 | end 26 | 27 | def index_task(task_uid) 28 | http_get "/tasks/#{task_uid}" 29 | end 30 | 31 | def cancel_tasks(options) 32 | http_post '/tasks/cancel', nil, Utils.parse_query(options, ALLOWED_CANCELATION_PARAMS) 33 | end 34 | 35 | def delete_tasks(options) 36 | http_delete '/tasks', Utils.parse_query(options, ALLOWED_CANCELATION_PARAMS) 37 | end 38 | 39 | # Wait for a task with a busy loop. 40 | # 41 | # Not recommended, try to avoid interacting with Meilisearch synchronously. 42 | # @param task_uid [String] uid of the task to wait on 43 | # @param timeout_in_ms [Integer] the maximum amount of time to wait for a task 44 | # in milliseconds 45 | # @param interval_in_ms [Integer] how long to stay parked in the busy loop 46 | # in milliseconds 47 | def wait_for_task( 48 | task_uid, 49 | timeout_in_ms = Models::Task.default_timeout_ms, 50 | interval_in_ms = Models::Task.default_interval_ms 51 | ) 52 | Timeout.timeout(timeout_in_ms.to_f / 1000) do 53 | loop do 54 | task = task(task_uid) 55 | return task if achieved_task?(task) 56 | 57 | sleep interval_in_ms.to_f / 1000 58 | end 59 | end 60 | rescue Timeout::Error 61 | raise Meilisearch::TimeoutError 62 | end 63 | 64 | private 65 | 66 | def achieved_task?(task) 67 | task['status'] != 'enqueued' && task['status'] != 'processing' 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/meilisearch/client/errors_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Errors' do 4 | describe 'Meilisearch::Error' do 5 | it 'catches all other errors' do 6 | expect(Meilisearch::TimeoutError.ancestors).to include(Meilisearch::Error) 7 | expect(Meilisearch::CommunicationError.ancestors).to include(Meilisearch::Error) 8 | expect(Meilisearch::ApiError.ancestors).to include(Meilisearch::Error) 9 | expect(Meilisearch::TenantToken::InvalidApiKey.ancestors).to include(Meilisearch::Error) 10 | expect(Meilisearch::TenantToken::InvalidSearchRules.ancestors).to include(Meilisearch::Error) 11 | expect(Meilisearch::TenantToken::ExpireOrInvalidSignature.ancestors).to include(Meilisearch::Error) 12 | end 13 | end 14 | 15 | context 'when request takes to long to answer' do 16 | it 'raises Meilisearch::TimeoutError' do 17 | timed_client = Meilisearch::Client.new(URL, MASTER_KEY, { timeout: 0.000001 }) 18 | 19 | expect do 20 | timed_client.version 21 | end.to raise_error(Meilisearch::TimeoutError) 22 | end 23 | end 24 | 25 | context 'when body is too large' do 26 | let(:index) { client.index('movies') } 27 | 28 | it 'raises Meilisearch::CommunicationError' do 29 | allow(index.class).to receive(:post).and_raise(Errno::EPIPE) 30 | 31 | expect do 32 | index.add_documents([{ id: 1, text: 'my_text' }]) 33 | end.to raise_error(Meilisearch::CommunicationError) 34 | end 35 | end 36 | 37 | context 'when document id is invalid' do 38 | it 'raises Meilisearch::InvalidDocumentId' do 39 | expect do 40 | client.index('movies').delete_document(nil) 41 | end.to raise_error(Meilisearch::InvalidDocumentId) 42 | end 43 | end 44 | 45 | context 'when url is missing protocol' do 46 | it 'throws a CommunicationError with a useful message' do 47 | expect do 48 | c = Meilisearch::Client.new('localhost:7700') 49 | c.health 50 | end.to raise_error(Meilisearch::CommunicationError).with_message(/protocol/) 51 | end 52 | end 53 | 54 | context 'when url is malformed' do 55 | it 'throws a CommunicationError' do 56 | expect do 57 | c = Meilisearch::Client.new('http://localh ost:7700') 58 | c.health 59 | end.to raise_error(Meilisearch::CommunicationError) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Filtered search' do 4 | include_context 'search books with author, genre, year' 5 | 6 | before { index.update_filterable_attributes(['genre', 'year', 'author']).await } 7 | 8 | it 'does a custom search with one filter' do 9 | response = index.search('le', { filter: 'genre = romance' }) 10 | expect(response['hits'].count).to eq(1) 11 | expect(response['hits'].first['objectId']).to eq(2) 12 | end 13 | 14 | it 'does a custom search with a numerical value filter' do 15 | response = index.search('potter', { filter: 'year = 2007' }) 16 | expect(response['hits'].count).to eq(1) 17 | expect(response['hits'].first['objectId']).to eq(2056) 18 | end 19 | 20 | it 'does a custom search with multiple filter' do 21 | response = index.search('prince', { filter: 'year > 1930 AND author = "Antoine de Saint-Exupéry"' }) 22 | expect(response['hits'].count).to eq(1) 23 | expect(response['hits'].first['objectId']).to eq(456) 24 | end 25 | 26 | it 'does a placeholder search with multiple filter' do 27 | response = index.search('', { filter: 'author = "J. K. Rowling" OR author = "George R. R. Martin"' }) 28 | expect(response['hits'].count).to eq(3) 29 | end 30 | 31 | it 'does a placeholder search with numerical values filter' do 32 | response = index.search('', { filter: 'year < 2000 AND year > 1990' }) 33 | expect(response['hits'].count).to eq(1) 34 | expect(response['hits'].first['year']).to eq(1996) 35 | end 36 | 37 | it 'does a placeholder search with multiple filter and different type of values' do 38 | response = index.search('', { filter: 'author = "J. K. Rowling" AND year > 2006' }) 39 | expect(response['hits'].count).to eq(1) 40 | expect(response['hits'].first['objectId']).to eq(2056) 41 | end 42 | 43 | it 'does a custom search with filter and array syntax' do 44 | response = index.search('prinec', filter: ['genre = fantasy']) 45 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 46 | expect(response['estimatedTotalHits']).to eq(1) 47 | expect(response['hits'][0]['objectId']).to eq(4) 48 | end 49 | 50 | it 'does a custom search with multiple filter and array syntax' do 51 | response = index.search('potter', filter: ['genre = fantasy', ['year = 2005']]) 52 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 53 | expect(response['estimatedTotalHits']).to eq(1) 54 | expect(response['hits'][0]['objectId']).to eq(4) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/attributes_to_crop_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Cropped search' do 4 | let(:index) { client.index('books') } 5 | let(:document) do 6 | { 7 | objectId: 42, 8 | title: 'The Hitchhiker\'s Guide to the Galaxy', 9 | description: 'The Hitchhiker\'s Guide to the Galaxy is a comedy science fiction series by Douglas Adams.' 10 | } 11 | end 12 | 13 | before { index.add_documents(document).await } 14 | 15 | it 'searches with default cropping params' do 16 | response = index.search('galaxy', attributes_to_crop: ['*'], crop_length: 6) 17 | 18 | expect(response.dig('hits', 0, '_formatted', 'description')).to eq('…Guide to the Galaxy is a…') 19 | end 20 | 21 | it 'searches with custom crop markers' do 22 | response = index.search('galaxy', attributes_to_crop: ['*'], crop_length: 6, crop_marker: '(ꈍᴗꈍ)') 23 | 24 | expect(response.dig('hits', 0, '_formatted', 'description')).to eq('(ꈍᴗꈍ)Guide to the Galaxy is a(ꈍᴗꈍ)') 25 | end 26 | 27 | it 'searches with mixed highlight and crop config' do 28 | response = index.search( 29 | 'galaxy', 30 | attributes_to_highlight: ['*'], 31 | attributes_to_crop: ['*'], 32 | highlight_pre_tag: '' 33 | ) 34 | 35 | expect(response.dig('hits', 0, '_formatted', 'description')).to \ 36 | eq("…Hitchhiker's Guide to the Galaxy is a comedy science…") 37 | end 38 | 39 | it 'searches with highlight tags' do 40 | response = index.search( 41 | 'galaxy', 42 | attributes_to_highlight: ['*'], 43 | highlight_pre_tag: '', 44 | highlight_post_tag: '' 45 | ) 46 | 47 | expect(response.dig('hits', 0, '_formatted', 'description')).to include('Galaxy') 48 | end 49 | 50 | it 'does a custom search with attributes to crop' do 51 | response = index.search('galaxy', { attributes_to_crop: ['description'], crop_length: 6 }) 52 | expect(response['hits'].first).to have_key('_formatted') 53 | expect(response['hits'].first['_formatted']['description']).to eq('…Guide to the Galaxy is a…') 54 | end 55 | 56 | it 'does a placeholder search with attributes to crop' do 57 | response = index.search('', { attributes_to_crop: ['description'], crop_length: 5 }) 58 | expect(response['hits'].first).to have_key('_formatted') 59 | expect(response['hits'].first['description']).to eq(document[:description]) 60 | expect(response['hits'].first['_formatted']['description']).to eq("The Hitchhiker's Guide to…") 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/facets_distribution_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with facets' do 4 | include_context 'search books with author, genre, year' 5 | 6 | before do 7 | index.update_filterable_attributes(['genre', 'year', 'author']).await 8 | end 9 | 10 | it 'does a custom search with facets' do 11 | response = index.search('prinec', facets: ['genre', 'author']) 12 | expect(response.keys).to include( 13 | *DEFAULT_SEARCH_RESPONSE_KEYS, 14 | 'facetDistribution', 15 | 'facetStats' 16 | ) 17 | expect(response['estimatedTotalHits']).to eq(2) 18 | expect(response['facetDistribution'].keys).to contain_exactly('genre', 'author') 19 | expect(response['facetDistribution']['genre'].keys).to contain_exactly('adventure', 'fantasy') 20 | expect(response['facetDistribution']['genre']['adventure']).to eq(1) 21 | expect(response['facetDistribution']['genre']['fantasy']).to eq(1) 22 | expect(response['facetDistribution']['author']['J. K. Rowling']).to eq(1) 23 | expect(response['facetDistribution']['author']['Antoine de Saint-Exupéry']).to eq(1) 24 | end 25 | 26 | it 'does a placeholder search with facets' do 27 | response = index.search('', facets: ['genre', 'author']) 28 | expect(response.keys).to include( 29 | *DEFAULT_SEARCH_RESPONSE_KEYS, 30 | 'facetDistribution', 31 | 'facetStats' 32 | ) 33 | expect(response['estimatedTotalHits']).to eq(documents.count) 34 | expect(response['facetDistribution'].keys).to contain_exactly('genre', 'author') 35 | expect(response['facetDistribution']['genre'].keys).to contain_exactly('romance', 'adventure', 'fantasy') 36 | expect(response['facetDistribution']['genre']['romance']).to eq(2) 37 | expect(response['facetDistribution']['genre']['adventure']).to eq(3) 38 | expect(response['facetDistribution']['genre']['fantasy']).to eq(3) 39 | expect(response['facetDistribution']['author']['J. K. Rowling']).to eq(2) 40 | end 41 | 42 | it 'does a placeholder search with facets on number' do 43 | response = index.search('', facets: ['year']) 44 | expect(response.keys).to include( 45 | *DEFAULT_SEARCH_RESPONSE_KEYS, 46 | 'facetDistribution', 47 | 'facetStats' 48 | ) 49 | expect(response['estimatedTotalHits']).to eq(documents.count) 50 | expect(response['facetDistribution'].keys).to contain_exactly('year') 51 | expect(response['facetDistribution']['year'].keys).to contain_exactly(*documents.map { |o| o[:year].to_s }) 52 | expect(response['facetDistribution']['year']['1943']).to eq(1) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/meilisearch_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Meilisearch do 4 | it 'has a version number' do 5 | expect(Meilisearch::VERSION).not_to be_nil 6 | end 7 | 8 | it 'has a qualified version number' do 9 | expect(described_class.qualified_version).to eq("Meilisearch Ruby (v#{Meilisearch::VERSION})") 10 | end 11 | 12 | it 'raises an exception when it is impossible to connect' do 13 | new_client = Meilisearch::Client.new('http://127.0.0.1:8800', 'masterKey') 14 | expect do 15 | new_client.indexes 16 | end.to raise_error(Meilisearch::CommunicationError) 17 | end 18 | 19 | it 'allows to set a custom timeout and max_retries' do 20 | new_client = Meilisearch::Client.new(URL, MASTER_KEY, timeout: 20, max_retries: 2) 21 | expect(new_client.healthy?).to be true 22 | end 23 | 24 | it 'raises a timeout error when setting the timeout option' do 25 | new_client = Meilisearch::Client.new(URL, MASTER_KEY, timeout: 0.00001) 26 | 27 | expect do 28 | new_client.indexes 29 | end.to raise_error(Meilisearch::TimeoutError) 30 | end 31 | 32 | it 'has a pre-defined header with current version' do 33 | new_client = Meilisearch::Client.new(URL, MASTER_KEY) 34 | 35 | expect(new_client.headers).to have_key('User-Agent') 36 | expect(new_client.headers['User-Agent']).to eq(described_class.qualified_version) 37 | end 38 | 39 | it 'retries the request when the request is retryable' do 40 | allow(Meilisearch::HTTPRequest).to receive(:get).and_raise(Net::ReadTimeout) 41 | 42 | begin 43 | new_client = Meilisearch::Client.new(URL, MASTER_KEY, max_retries: 3, retry_multiplier: 0.1) 44 | new_client.indexes 45 | rescue Meilisearch::TimeoutError 46 | nil 47 | end 48 | 49 | expect(Meilisearch::HTTPRequest).to have_received(:get).exactly(4).times 50 | end 51 | 52 | it 'does not retry the request when the request is not retryable' do 53 | allow(Meilisearch::HTTPRequest).to receive(:get).and_raise(Errno::ECONNREFUSED) 54 | 55 | begin 56 | new_client = Meilisearch::Client.new(URL, MASTER_KEY, max_retries: 10) 57 | new_client.indexes 58 | rescue Meilisearch::CommunicationError 59 | nil 60 | end 61 | 62 | expect(Meilisearch::HTTPRequest).to have_received(:get).once 63 | end 64 | end 65 | 66 | RSpec.describe MeiliSearch do 67 | it 'relays constants & messages, warns about deprecation only once' do 68 | logger = instance_double(Logger, warn: nil) 69 | Meilisearch::Utils.logger = logger 70 | 71 | expect(MeiliSearch::Index).to equal(Meilisearch::Index) 72 | expect(MeiliSearch::Task).to equal(Meilisearch::Task) 73 | expect(MeiliSearch).to respond_to(:qualified_version) 74 | expect(MeiliSearch.qualified_version).to eq(Meilisearch.qualified_version) 75 | 76 | expect(logger).to have_received(:warn) 77 | .with(a_string_including('The top-level module of Meilisearch has been renamed.')) 78 | .once 79 | 80 | Meilisearch::Utils.logger = nil 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/meilisearch/client/multi_search_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Multiple Index Search' do 4 | before do 5 | client.create_index('books') 6 | client.create_index('movies').await 7 | end 8 | 9 | it 'does a custom search with two different indexes' do 10 | response = client.multi_search(queries: [ 11 | { index_uid: 'books', q: 'prince' }, 12 | { index_uid: 'movies', q: 'prince' } 13 | ]) 14 | 15 | expect(response['results'].count).to eq(2) 16 | expect(response['results'][0]['estimatedTotalHits']).to eq(0) 17 | expect(response['results'][1]['estimatedTotalHits']).to eq(0) 18 | end 19 | 20 | context 'when passed a positional argument' do 21 | before { allow(Meilisearch::Utils).to receive(:soft_deprecate).and_return(nil) } 22 | 23 | it 'does a custom search with two different indexes' do 24 | response = client.multi_search([ 25 | { index_uid: 'books', q: 'prince' }, 26 | { index_uid: 'movies', q: 'prince' } 27 | ]) 28 | 29 | expect(response['results'].count).to eq(2) 30 | expect(response['results'][0]['estimatedTotalHits']).to eq(0) 31 | expect(response['results'][1]['estimatedTotalHits']).to eq(0) 32 | end 33 | 34 | it 'warns about deprecation' do 35 | client.multi_search([ 36 | { index_uid: 'books', q: 'prince' }, 37 | { index_uid: 'movies', q: 'prince' } 38 | ]) 39 | 40 | expect(Meilisearch::Utils).to have_received(:soft_deprecate) 41 | .with('multi_search([])', a_string_including('queries')) 42 | end 43 | end 44 | 45 | it 'does a federated search with two different indexes' do 46 | client.index('books').add_documents( 47 | [ 48 | { id: 1, title: 'Harry Potter and the Philosophers Stone' }, 49 | { id: 2, title: 'War and Peace' }, 50 | { id: 5, title: 'Harry Potter and the Deathly Hallows' } 51 | ] 52 | ) 53 | 54 | client.index('movies').add_documents( 55 | [ 56 | { id: 1, title: 'Harry Potter and the Philosophers Stone' }, 57 | { id: 2, title: 'Lord of the Rings' }, 58 | { id: 4, title: 'Harry Potter and the Order of the Phoenix' } 59 | ] 60 | ).await 61 | 62 | response = client.multi_search(queries: [ 63 | { index_uid: 'books', q: 'Harry Potter' }, 64 | { index_uid: 'movies', q: 'Harry Potter' } 65 | ], 66 | federation: { 67 | limit: 3, 68 | offset: 1 69 | }) 70 | 71 | expect(response['limit']).to eq(3) 72 | expect(response['offset']).to eq(1) 73 | 74 | hits = response['hits'] 75 | expect(hits.size).to be 3 76 | expect(hits.first).to have_key('_federation') 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/meilisearch/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Meilisearch 4 | class Error < StandardError 5 | end 6 | 7 | class ApiError < Error 8 | # :http_code # e.g. 400, 404... 9 | # :http_message # e.g. Bad Request, Not Found... 10 | # :http_body # The response body received from the Meilisearch API 11 | # :ms_code # The error code given by the Meilisearch API 12 | # :ms_type # The error type given by the Meilisearch API 13 | # :ms_link # The documentation link given by the Meilisearch API 14 | # :ms_message # The error message given by the Meilisearch API 15 | # :message # The detailed error message of this error class 16 | 17 | attr_reader :http_code, :http_message, :http_body, :ms_code, :ms_type, :ms_link, :ms_message, :message 18 | 19 | alias code ms_code 20 | alias type ms_type 21 | alias link ms_link 22 | 23 | def initialize(http_code, http_message, http_body) 24 | @http_code = http_code 25 | @http_message = http_message 26 | @http_body = parse_body(http_body) 27 | @ms_code = @http_body['code'] 28 | @ms_type = @http_body['type'] 29 | @ms_message = @http_body.fetch('message', 'Meilisearch API has not returned any error message') 30 | @ms_link = @http_body.fetch('link', '') 31 | @message = "#{http_code} #{http_message} - #{@ms_message}. See #{ms_link}." 32 | super(details) 33 | end 34 | 35 | def parse_body(http_body) 36 | if http_body.respond_to?(:to_hash) 37 | http_body.to_hash 38 | elsif http_body.respond_to?(:to_str) 39 | JSON.parse(http_body.to_str) 40 | else 41 | {} 42 | end 43 | rescue JSON::ParserError 44 | # We might receive a JSON::ParserError when, for example, Meilisearch is running behind 45 | # some proxy (ELB or Nginx, for example), and the request timeouts, returning us 46 | # a raw HTML body instead of a JSON as we were expecting 47 | { 'message' => "The server has not returned a valid JSON HTTP body: #{http_body}" } 48 | end 49 | 50 | def details 51 | "Meilisearch::ApiError - code: #{@ms_code} - type: #{ms_type} - message: #{@ms_message} - link: #{ms_link}" 52 | end 53 | end 54 | 55 | class CommunicationError < Error 56 | attr_reader :message 57 | 58 | def initialize(message) 59 | @message = "An error occurred while trying to connect to the Meilisearch instance: #{message}" 60 | super(@message) 61 | end 62 | end 63 | 64 | class TimeoutError < Error 65 | attr_reader :message 66 | 67 | def initialize(message = nil) 68 | @message = "The request was not processed in the expected time. #{message}" 69 | super(@message) 70 | end 71 | end 72 | 73 | class InvalidDocumentId < Error 74 | attr_reader :message 75 | 76 | def initialize(message = nil) 77 | @message = "The document id is invalid. #{message}" 78 | super(@message) 79 | end 80 | end 81 | 82 | module TenantToken 83 | class ExpireOrInvalidSignature < Meilisearch::Error; end 84 | class InvalidApiKey < Meilisearch::Error; end 85 | class InvalidSearchRules < Meilisearch::Error; end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/attributes_to_retrieve_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Search with attributes to retrieve' do 4 | include_context 'search books with genre' 5 | 6 | it 'does a custom search with one attributes_to_retrieve' do 7 | response = index.search('the', attributes_to_retrieve: ['title']) 8 | expect(response).to be_a(Hash) 9 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 10 | expect(response['hits'].count).to eq(4) 11 | expect(response['hits'].first).to have_key('title') 12 | expect(response['hits'].first).not_to have_key('objectId') 13 | expect(response['hits'].first).not_to have_key('genre') 14 | end 15 | 16 | it 'does a custom search with multiple attributes_to_retrieve' do 17 | response = index.search('the', attributes_to_retrieve: ['title', 'genre']) 18 | expect(response).to be_a(Hash) 19 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 20 | expect(response['hits'].count).to eq(4) 21 | expect(response['hits'].first).to have_key('title') 22 | expect(response['hits'].first).not_to have_key('objectId') 23 | expect(response['hits'].first).to have_key('genre') 24 | end 25 | 26 | it 'does a custom search with all attributes_to_retrieve' do 27 | response = index.search('the', attributes_to_retrieve: ['*']) 28 | expect(response).to be_a(Hash) 29 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 30 | expect(response['hits'].count).to eq(4) 31 | expect(response['hits'].first).to have_key('objectId') 32 | expect(response['hits'].first).to have_key('title') 33 | expect(response['hits'].first).to have_key('genre') 34 | end 35 | 36 | it 'does a placeholder search with one attributes_to_retrieve' do 37 | response = index.search('', attributes_to_retrieve: ['title']) 38 | expect(response).to be_a(Hash) 39 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 40 | expect(response['hits'].count).to eq(documents.count) 41 | expect(response['hits'].first).to have_key('title') 42 | expect(response['hits'].first).not_to have_key('objectId') 43 | expect(response['hits'].first).not_to have_key('genre') 44 | end 45 | 46 | it 'does a placeholder search with multiple attributes_to_retrieve' do 47 | response = index.search('', attributes_to_retrieve: ['title', 'genre']) 48 | expect(response).to be_a(Hash) 49 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 50 | expect(response['hits'].count).to eq(documents.count) 51 | expect(response['hits'].first).to have_key('title') 52 | expect(response['hits'].first).not_to have_key('objectId') 53 | expect(response['hits'].first).to have_key('genre') 54 | end 55 | 56 | it 'does a placeholder search with all attributes_to_retrieve' do 57 | response = index.search('', attributes_to_retrieve: ['*']) 58 | expect(response).to be_a(Hash) 59 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 60 | expect(response['hits'].count).to eq(documents.count) 61 | expect(response['hits'].first).to have_key('title') 62 | expect(response['hits'].first).to have_key('objectId') 63 | expect(response['hits'].first).to have_key('genre') 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file was generated by the `rspec --init` command. Conventionally, all 4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 5 | # The generated `.rspec` file contains `--require spec_helper` which will cause 6 | # this file to always be loaded, without a need to explicitly require it in any 7 | # files. 8 | # 9 | # Given that it is always loaded, you are encouraged to keep this file as 10 | # light-weight as possible. Requiring heavyweight dependencies from this file 11 | # will add to the boot time of your test suite on EVERY test run, even for an 12 | # individual file that may not need all of that loaded. Instead, consider making 13 | # a separate helper file that requires the additional dependencies and performs 14 | # the additional setup, and require it from the spec files that actually need 15 | # it. 16 | # 17 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 18 | 19 | # NOTE: If SimpleCov starts after your application code is already loaded (via require), 20 | # it won't be able to track your files and their coverage! 21 | # The SimpleCov.start must be issued before any of your application code is required! 22 | 23 | unless ENV.fetch('DISABLE_COVERAGE', false) 24 | require 'simplecov' 25 | 26 | SimpleCov.start do 27 | add_filter %r{^/spec/} 28 | minimum_coverage 99 29 | 30 | if ENV['CI'] 31 | require 'simplecov-cobertura' 32 | 33 | formatter SimpleCov::Formatter::CoberturaFormatter 34 | end 35 | end 36 | end 37 | 38 | require 'meilisearch' 39 | require 'byebug' 40 | require 'time' 41 | 42 | # Globals for all tests 43 | URL = format('http://%s:%s', 44 | host: ENV.fetch('MEILISEARCH_URL', 'localhost'), port: ENV.fetch('MEILISEARCH_PORT', '7700')) 45 | MASTER_KEY = 'masterKey' 46 | DEFAULT_SEARCH_RESPONSE_KEYS = [ 47 | 'hits', 48 | 'offset', 49 | 'limit', 50 | 'estimatedTotalHits', 51 | 'processingTimeMs', 52 | 'query', 53 | 'nbHits' 54 | ].freeze 55 | 56 | FINITE_PAGINATED_SEARCH_RESPONSE_KEYS = [ 57 | 'hits', 58 | 'query', 59 | 'processingTimeMs', 60 | 'hitsPerPage', 61 | 'page', 62 | 'totalPages', 63 | 'totalHits' 64 | ].freeze 65 | 66 | Dir["#{Dir.pwd}/spec/support/**/*.rb"].each { |file| require file } 67 | 68 | # Reduce interval during testing to improve the test speeds 69 | # There are no long-running meilisearch operations during testing so this should be fine 70 | Meilisearch::Models::Task.default_interval_ms = 5 71 | 72 | RSpec.configure do |config| 73 | config.filter_run_when_matching :focus 74 | config.example_status_persistence_file_path = 'spec/examples.txt' 75 | config.order = :random 76 | 77 | config.include_context 'test defaults' 78 | 79 | config.include IndexesHelpers 80 | config.include ExceptionsHelpers 81 | config.include ExperimentalFeatureHelpers 82 | config.include KeysHelpers 83 | 84 | # New RSpec 4 defaults, remove when updated to RSpec 4 85 | config.shared_context_metadata_behavior = :apply_to_host_groups 86 | 87 | config.expect_with :rspec do |expectations| 88 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 89 | end 90 | 91 | config.mock_with :rspec do |mocks| 92 | mocks.verify_partial_doubles = true 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/meilisearch/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logger' 4 | 5 | module Meilisearch 6 | module Utils 7 | SNAKE_CASE = /[^a-zA-Z0-9]+(.)/ 8 | 9 | class << self 10 | attr_writer :logger 11 | 12 | def logger 13 | @logger ||= Logger.new($stdout) 14 | end 15 | 16 | def soft_deprecate(subject, replacement) 17 | logger.warn("[meilisearch-ruby] #{subject} is DEPRECATED, please use #{replacement} instead.") 18 | end 19 | 20 | def warn_on_unfinished_task(task_uid) 21 | message = <<~UNFINISHED_TASK_WARNING 22 | [meilisearch-ruby] Task #{task_uid}'s finished state (succeeded?/failed?/cancelled?) is being checked before finishing. 23 | [meilisearch-ruby] Tasks in meilisearch are processed in the background asynchronously. 24 | [meilisearch-ruby] Please use the #finished? method to check if the task is finished or the #await method to wait for the task to finish. 25 | UNFINISHED_TASK_WARNING 26 | 27 | message.lines.each do |line| 28 | logger.warn(line) 29 | end 30 | end 31 | 32 | def transform_attributes(body) 33 | case body 34 | when Array 35 | body.map { |item| transform_attributes(item) } 36 | when Hash 37 | warn_on_non_conforming_attribute_names(body) 38 | parse(body) 39 | else 40 | body 41 | end 42 | end 43 | 44 | def filter(original_options, allowed_params = []) 45 | original_options.transform_keys(&:to_sym).slice(*allowed_params) 46 | end 47 | 48 | def parse_query(original_options, allowed_params = []) 49 | only_allowed_params = filter(original_options, allowed_params) 50 | 51 | Utils.transform_attributes(only_allowed_params).then do |body| 52 | body.transform_values do |v| 53 | v.respond_to?(:join) ? v.join(',') : v.to_s 54 | end 55 | end 56 | end 57 | 58 | def version_error_handler(method_name) 59 | yield if block_given? 60 | rescue Meilisearch::ApiError => e 61 | message = message_builder(e.http_message, method_name) 62 | 63 | raise Meilisearch::ApiError.new(e.http_code, message, e.http_body) 64 | rescue StandardError => e 65 | raise e.class, message_builder(e.message, method_name) 66 | end 67 | 68 | def warn_on_non_conforming_attribute_names(body) 69 | return if body.nil? 70 | 71 | non_snake_case = body.keys.grep_v(/^[a-z0-9_]+$/) 72 | return if non_snake_case.empty? 73 | 74 | message = <<~MSG 75 | [meilisearch-ruby] Attributes will be expected to be snake_case in future versions. 76 | [meilisearch-ruby] Non-conforming attributes: #{non_snake_case.join(', ')} 77 | MSG 78 | 79 | logger.warn(message) 80 | end 81 | 82 | private 83 | 84 | def parse(body) 85 | body 86 | .transform_keys(&:to_s) 87 | .transform_keys do |key| 88 | key.include?('_') ? key.downcase.gsub(SNAKE_CASE, &:upcase).gsub('_', '') : key 89 | end 90 | end 91 | 92 | def message_builder(current_message, method_name) 93 | "#{current_message}\nHint: It might not be working because maybe you're not up " \ 94 | "to date with the Meilisearch version that `#{method_name}` call requires." 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/facet_search_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Facet search' do 4 | include_context 'search books with author, genre, year' 5 | 6 | before do 7 | response = index.update_filterable_attributes(['genre', 'year', 'author']) 8 | index.wait_for_task(response['taskUid']) 9 | end 10 | 11 | it 'requires facet name parameter' do 12 | expect { index.facet_search }.to raise_error ArgumentError 13 | end 14 | 15 | context 'without query parameter' do 16 | let(:results) { index.facet_search 'genre' } 17 | 18 | it 'returns all genres' do 19 | expect(results).to include( 20 | 'facetHits' => a_collection_including( 21 | a_hash_including('value' => 'fantasy'), 22 | a_hash_including('value' => 'adventure'), 23 | a_hash_including('value' => 'romance') 24 | ) 25 | ) 26 | end 27 | 28 | it 'returns all genre counts' do 29 | expect(results).to include( 30 | 'facetHits' => a_collection_including( 31 | a_hash_including('count' => 3), 32 | a_hash_including('count' => 3), 33 | a_hash_including('count' => 2) 34 | ) 35 | ) 36 | end 37 | 38 | it 'filters correctly' do 39 | results = index.facet_search 'genre', filter: 'year < 1940' 40 | 41 | expect(results['facetHits']).to contain_exactly( 42 | { 43 | 'value' => 'adventure', 44 | 'count' => 2 45 | }, 46 | { 47 | 'value' => 'romance', 48 | 'count' => 2 49 | } 50 | ) 51 | end 52 | end 53 | 54 | context 'with facet_query argument' do 55 | let(:results) { index.facet_search 'genre', 'fan' } 56 | 57 | it 'returns only matching genres' do 58 | expect(results).to include( 59 | 'facetHits' => a_collection_containing_exactly( 60 | 'value' => 'fantasy', 61 | 'count' => 3 62 | ) 63 | ) 64 | end 65 | 66 | it 'filters correctly' do 67 | results = index.facet_search 'genre', 'fantasy', filter: 'year < 2006' 68 | 69 | expect(results['facetHits']).to contain_exactly( 70 | 'value' => 'fantasy', 71 | 'count' => 2 72 | ) 73 | end 74 | end 75 | 76 | context 'with q parameter' do 77 | it 'applies matching_strategy "all"' do 78 | results = index.facet_search 'author', 'J. K. Rowling', q: 'Potter Stories', matching_strategy: 'all' 79 | 80 | expect(results['facetHits']).to be_empty 81 | end 82 | 83 | it 'applies matching_strategy "last"' do 84 | results = index.facet_search 'author', 'J. K. Rowling', q: 'Potter Stories', matching_strategy: 'last' 85 | 86 | expect(results).to include( 87 | 'facetHits' => a_collection_containing_exactly( 88 | 'value' => 'J. K. Rowling', 89 | 'count' => 2 90 | ) 91 | ) 92 | end 93 | 94 | it 'applies filter parameter' do 95 | results = index.facet_search 'author', 'J. K. Rowling', q: 'Potter', filter: 'year < 2007' 96 | 97 | expect(results).to include( 98 | 'facetHits' => a_collection_containing_exactly( 99 | 'value' => 'J. K. Rowling', 100 | 'count' => 1 101 | ) 102 | ) 103 | end 104 | 105 | it 'applies attributes_to_search_on parameter' do 106 | results = index.facet_search 'author', 'J. K. Rowling', q: 'Potter', attributes_to_search_on: ['year'] 107 | 108 | expect(results['facetHits']).to be_empty 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/multi_params_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Multi-paramaters search' do 4 | include_context 'search books with genre' 5 | 6 | before { index.update_filterable_attributes(['genre']).await } 7 | 8 | it 'does a custom search with attributes to crop, filter and attributes to highlight' do 9 | response = index.search('prince', 10 | { 11 | attributes_to_crop: ['title'], 12 | crop_length: 2, 13 | filter: 'genre = adventure', 14 | attributes_to_highlight: ['title'] 15 | }) 16 | expect(response['hits'].count).to be(1) 17 | expect(response['hits'].first).to have_key('_formatted') 18 | expect(response['hits'].first['_formatted']['title']).to eq('…Petit Prince') 19 | end 20 | 21 | it 'does a custom search with attributes_to_retrieve and a limit' do 22 | response = index.search('the', attributes_to_retrieve: ['title', 'genre'], limit: 2) 23 | expect(response).to be_a(Hash) 24 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 25 | expect(response['hits'].count).to eq(2) 26 | expect(response['hits'].first).to have_key('title') 27 | expect(response['hits'].first).not_to have_key('objectId') 28 | expect(response['hits'].first).to have_key('genre') 29 | end 30 | 31 | it 'does a placeholder search with filter and offset' do 32 | response = index.search('', { filter: 'genre = adventure', offset: 2 }) 33 | expect(response['hits'].count).to eq(1) 34 | end 35 | 36 | it 'does a custom search with limit and attributes to highlight' do 37 | response = index.search('the', { limit: 1, attributes_to_highlight: ['*'] }) 38 | expect(response).to be_a(Hash) 39 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 40 | expect(response['hits'].count).to eq(1) 41 | expect(response['hits'].first).to have_key('_formatted') 42 | end 43 | 44 | it 'does a custom search with filter, attributes_to_retrieve and attributes_to_highlight' do 45 | index.update_filterable_attributes(['genre']).await 46 | response = index.search('prinec', 47 | { 48 | filter: ['genre = fantasy'], 49 | attributes_to_retrieve: ['title'], 50 | attributes_to_highlight: ['*'] 51 | }) 52 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 53 | expect(response['estimatedTotalHits']).to eq(1) 54 | expect(response['hits'].first).to have_key('_formatted') 55 | expect(response['hits'].first).not_to have_key('objectId') 56 | expect(response['hits'].first).not_to have_key('genre') 57 | expect(response['hits'].first).to have_key('title') 58 | expect(response['hits'].first['_formatted']['title']).to eq('Harry Potter and the Half-Blood Prince') 59 | end 60 | 61 | it 'does a custom search with facets and limit' do 62 | index.update_filterable_attributes(['genre']).await 63 | response = index.search('prinec', facets: ['genre'], limit: 1) 64 | 65 | expect(response.keys).to include( 66 | *DEFAULT_SEARCH_RESPONSE_KEYS, 67 | 'facetDistribution', 68 | 'facetStats' 69 | ) 70 | expect(response['estimatedTotalHits']).to eq(2) 71 | expect(response['hits'].count).to eq(1) 72 | expect(response['facetDistribution'].keys).to contain_exactly('genre') 73 | expect(response['facetDistribution']['genre'].keys).to contain_exactly('adventure', 'fantasy') 74 | expect(response['facetDistribution']['genre']['adventure']).to eq(1) 75 | expect(response['facetDistribution']['genre']['fantasy']).to eq(1) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/meilisearch/client/keys_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Keys' do 4 | context 'When managing keys' do 5 | let(:uuid_v4) { 'c483e150-cff1-4a45-ac26-bb8eb8e01d36' } 6 | let(:delete_docs_key_options) do 7 | { 8 | description: 'A new key to delete docs', 9 | actions: ['documents.delete'], 10 | indexes: ['*'], 11 | expires_at: nil 12 | } 13 | end 14 | let(:add_docs_key_options) do 15 | { 16 | description: 'A new key to add docs', 17 | actions: ['documents.add'], 18 | indexes: ['*'], 19 | expires_at: nil 20 | } 21 | end 22 | 23 | it 'creates a key' do 24 | new_key = client.create_key(add_docs_key_options) 25 | 26 | expect(new_key['expiresAt']).to be_nil 27 | expect(new_key['key']).to be_a(String) 28 | expect(new_key['createdAt']).to be_a(String) 29 | expect(new_key['updatedAt']).to be_a(String) 30 | expect(new_key['indexes']).to eq(['*']) 31 | expect(new_key['description']).to eq('A new key to add docs') 32 | end 33 | 34 | it 'creates a key with wildcarded action' do 35 | new_key = client.create_key(add_docs_key_options.merge(actions: ['documents.*'])) 36 | 37 | expect(new_key['actions']).to eq(['documents.*']) 38 | end 39 | 40 | it 'creates a key with setting uid' do 41 | new_key = client.create_key(add_docs_key_options.merge(uid: uuid_v4)) 42 | 43 | expect(new_key['expiresAt']).to be_nil 44 | expect(new_key['name']).to be_nil 45 | expect(new_key['uid']).to eq(uuid_v4) 46 | expect(new_key['key']).to be_a(String) 47 | expect(new_key['createdAt']).to be_a(String) 48 | expect(new_key['updatedAt']).to be_a(String) 49 | expect(new_key['indexes']).to eq(['*']) 50 | expect(new_key['description']).to eq('A new key to add docs') 51 | end 52 | 53 | it 'gets a key with their key data' do 54 | new_key = client.create_key(delete_docs_key_options) 55 | 56 | expect(client.key(new_key['key'])['description']).to eq('A new key to delete docs') 57 | 58 | key = client.key(new_key['key']) 59 | 60 | expect(key['expiresAt']).to be_nil 61 | expect(key['key']).to be_a(String) 62 | expect(key['createdAt']).to be_a(String) 63 | expect(key['updatedAt']).to be_a(String) 64 | expect(key['indexes']).to eq(['*']) 65 | expect(key['description']).to eq('A new key to delete docs') 66 | end 67 | 68 | it 'retrieves a list of keys' do 69 | new_key = client.create_key(add_docs_key_options) 70 | 71 | list = client.keys 72 | 73 | expect(list.keys).to contain_exactly('limit', 'offset', 'results', 'total') 74 | expect(list['results']).to eq([new_key]) 75 | expect(list['total']).to eq(1) 76 | end 77 | 78 | it 'paginates keys list with limit/offset' do 79 | client.create_key(add_docs_key_options) 80 | 81 | expect(client.keys(limit: 0, offset: 20)['results']).to be_empty 82 | expect(client.keys(limit: 5, offset: 199)['results']).to be_empty 83 | end 84 | 85 | it 'gets a key with their uid' do 86 | new_key = client.create_key(delete_docs_key_options.merge(uid: uuid_v4)) 87 | 88 | key = client.key(uuid_v4) 89 | 90 | expect(key).to eq(new_key) 91 | end 92 | 93 | it 'updates a key with their key data' do 94 | new_key = client.create_key(delete_docs_key_options) 95 | new_updated_key = client.update_key(new_key['key'], indexes: ['coco'], description: 'no coco') 96 | 97 | expect(new_updated_key['key']).to eq(new_key['key']) 98 | expect(new_updated_key['description']).to eq('no coco') 99 | # remain untouched since v0.28.0 Meilisearch just support updating name/description. 100 | expect(new_updated_key['indexes']).to eq(['*']) 101 | end 102 | 103 | it 'updates a key with their uid data' do 104 | client.create_key(delete_docs_key_options.merge(uid: uuid_v4)) 105 | new_updated_key = client.update_key(uuid_v4, name: 'coco') 106 | 107 | expect(new_updated_key['name']).to eq('coco') 108 | end 109 | 110 | it 'deletes a key' do 111 | new_key = client.create_key(add_docs_key_options) 112 | client.delete_key(new_key['key']) 113 | 114 | expect do 115 | client.key(new_key['key']) 116 | end.to raise_error(Meilisearch::ApiError) 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/meilisearch/models/task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | 5 | module Meilisearch 6 | module Models 7 | class Task 8 | DEFAULT_TIMEOUT_MS = 5000 9 | DEFAULT_INTERVAL_MS = 50 10 | 11 | class << self 12 | attr_writer :default_timeout_ms, :default_interval_ms 13 | 14 | def default_timeout_ms 15 | @default_timeout_ms || DEFAULT_TIMEOUT_MS 16 | end 17 | 18 | def default_interval_ms 19 | @default_interval_ms || DEFAULT_INTERVAL_MS 20 | end 21 | end 22 | 23 | extend Forwardable 24 | 25 | # Maintain backwards compatibility with task hash return type 26 | def_delegators :metadata, :[], :dig, :keys, :key?, :has_key? 27 | 28 | attr_reader :metadata 29 | 30 | def initialize(metadata_hash, task_endpoint) 31 | self.metadata = metadata_hash 32 | validate_required_fields! metadata 33 | 34 | @task_endpoint = task_endpoint 35 | end 36 | 37 | def uid 38 | @metadata['taskUid'] 39 | end 40 | 41 | def type 42 | @metadata['type'] 43 | end 44 | 45 | def status 46 | @metadata['status'] 47 | end 48 | 49 | def enqueued? 50 | refresh if status_enqueued? 51 | 52 | status_enqueued? 53 | end 54 | 55 | def processing? 56 | refresh if status_processing? || status_enqueued? 57 | 58 | status_processing? 59 | end 60 | 61 | def unfinished? 62 | refresh if status_processing? || status_enqueued? 63 | 64 | status_processing? || status_enqueued? 65 | end 66 | alias waiting? unfinished? 67 | 68 | def finished? 69 | !unfinished? 70 | end 71 | 72 | def succeeded? 73 | Utils.warn_on_unfinished_task(self) if unfinished? 74 | 75 | status == 'succeeded' 76 | end 77 | alias has_succeeded? succeeded? 78 | 79 | def failed? 80 | Utils.warn_on_unfinished_task(self) if unfinished? 81 | 82 | status == 'failed' 83 | end 84 | alias has_failed? failed? 85 | 86 | def cancelled? 87 | Utils.warn_on_unfinished_task(self) if unfinished? 88 | 89 | status_cancelled? 90 | end 91 | 92 | def deleted? 93 | refresh unless @deleted 94 | 95 | !!@deleted 96 | end 97 | 98 | def error 99 | @metadata['error'] 100 | end 101 | 102 | def refresh(with: nil) 103 | self.metadata = with || @task_endpoint.task(uid) 104 | 105 | self 106 | rescue Meilisearch::ApiError => e 107 | raise e unless e.http_code == 404 108 | 109 | @deleted = true 110 | 111 | self 112 | end 113 | 114 | def await( 115 | timeout_in_ms = self.class.default_timeout_ms, 116 | interval_in_ms = self.class.default_interval_ms 117 | ) 118 | refresh with: @task_endpoint.wait_for_task(uid, timeout_in_ms, interval_in_ms) unless finished? 119 | 120 | self 121 | end 122 | 123 | def cancel 124 | return true if status_cancelled? 125 | return false if status_finished? 126 | 127 | @task_endpoint.cancel_tasks(uids: [uid]).await 128 | 129 | cancelled? 130 | end 131 | 132 | def delete 133 | return false unless status_finished? 134 | 135 | @task_endpoint.delete_tasks(uids: [uid]).await 136 | 137 | deleted? 138 | end 139 | 140 | def to_h 141 | @metadata 142 | end 143 | alias to_hash to_h 144 | 145 | private 146 | 147 | def validate_required_fields!(task_hash) 148 | raise ArgumentError, 'Cannot instantiate a task without an ID' unless task_hash['taskUid'] 149 | raise ArgumentError, 'Cannot instantiate a task without a type' unless task_hash['type'] 150 | raise ArgumentError, 'Cannot instantiate a task without a status' unless task_hash['status'] 151 | end 152 | 153 | def status_enqueued? 154 | status == 'enqueued' 155 | end 156 | 157 | def status_processing? 158 | status == 'processing' 159 | end 160 | 161 | def status_finished? 162 | ['succeeded', 'failed', 'cancelled'].include? status 163 | end 164 | 165 | def status_cancelled? 166 | status == 'cancelled' 167 | end 168 | 169 | def metadata=(metadata) 170 | @metadata = metadata 171 | 172 | uid = @metadata['taskUid'] || @metadata['uid'] 173 | @metadata['uid'] = uid 174 | @metadata['taskUid'] = uid 175 | end 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /spec/support/books_contexts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'search books with genre' do 4 | let(:index) { client.index('books') } 5 | let(:documents) do 6 | [ 7 | { objectId: 123, title: 'Pride and Prejudice', genre: 'romance' }, 8 | { objectId: 456, title: 'Le Petit Prince', genre: 'adventure' }, 9 | { objectId: 1, title: 'Alice In Wonderland', genre: 'adventure' }, 10 | { objectId: 2, title: 'Le Rouge et le Noir', genre: 'romance' }, 11 | { objectId: 1344, title: 'The Hobbit', genre: 'adventure' }, 12 | { objectId: 4, title: 'Harry Potter and the Half-Blood Prince', genre: 'fantasy' }, 13 | { objectId: 7, title: 'Harry Potter and the Chamber of Secrets', genre: 'fantasy' }, 14 | { objectId: 42, title: 'The Hitchhiker\'s Guide to the Galaxy' } 15 | ] 16 | end 17 | 18 | before { index.add_documents(documents).await } 19 | end 20 | 21 | RSpec.shared_context 'search books with author, genre, year' do 22 | let(:index) { client.index('books') } 23 | let(:documents) do 24 | [ 25 | { 26 | objectId: 123, 27 | title: 'Pride and Prejudice', 28 | year: 1813, 29 | author: 'Jane Austen', 30 | genre: 'romance' 31 | }, 32 | { 33 | objectId: 456, 34 | title: 'Le Petit Prince', 35 | year: 1943, 36 | author: 'Antoine de Saint-Exupéry', 37 | genre: 'adventure' 38 | }, 39 | { 40 | objectId: 1, 41 | title: 'Alice In Wonderland', 42 | year: 1865, 43 | author: 'Lewis Carroll', 44 | genre: 'adventure' 45 | }, 46 | { 47 | objectId: 2, 48 | title: 'Le Rouge et le Noir', 49 | year: 1830, 50 | author: 'Stendhal', 51 | genre: 'romance' 52 | }, 53 | { 54 | objectId: 1344, 55 | title: 'The Hobbit', 56 | year: 1937, 57 | author: 'J. R. R. Tolkien', 58 | genre: 'adventure' 59 | }, 60 | { 61 | objectId: 4, 62 | title: 'Harry Potter and the Half-Blood Prince', 63 | year: 2005, 64 | author: 'J. K. Rowling', 65 | genre: 'fantasy' 66 | }, 67 | { 68 | objectId: 2056, 69 | title: 'Harry Potter and the Deathly Hallows', 70 | year: 2007, 71 | author: 'J. K. Rowling', 72 | genre: 'fantasy' 73 | }, 74 | { 75 | objectId: 42, 76 | title: 'The Hitchhiker\'s Guide to the Galaxy', 77 | year: 1978, 78 | author: 'Douglas Adams' 79 | }, 80 | { 81 | objectId: 190, 82 | title: 'A Game of Thrones', 83 | year: 1996, 84 | author: 'George R. R. Martin', 85 | genre: 'fantasy' 86 | } 87 | ] 88 | end 89 | 90 | before { index.add_documents(documents).await } 91 | end 92 | 93 | RSpec.shared_context 'search books with nested fields' do 94 | let(:index) { client.index('books') } 95 | let(:documents) do 96 | [ 97 | { 98 | id: 1, 99 | title: 'Pride and Prejudice', 100 | info: { 101 | comment: 'A great book', 102 | reviewNb: 50 103 | } 104 | }, 105 | { 106 | id: 2, 107 | title: 'Le Petit Prince', 108 | info: { 109 | comment: 'A french book', 110 | reviewNb: 600 111 | } 112 | }, 113 | { 114 | id: 3, 115 | title: 'Le Rouge et le Noir', 116 | info: { 117 | comment: 'Another french book', 118 | reviewNb: 700 119 | } 120 | }, 121 | { 122 | id: 4, 123 | title: 'Alice In Wonderland', 124 | info: { 125 | comment: 'A weird book', 126 | reviewNb: 800 127 | } 128 | }, 129 | { 130 | id: 5, 131 | title: 'The Hobbit', 132 | info: { 133 | comment: 'An awesome book', 134 | reviewNb: 900 135 | } 136 | }, 137 | { 138 | id: 6, 139 | title: 'Harry Potter and the Half-Blood Prince', 140 | info: { 141 | comment: 'The best book', 142 | reviewNb: 1000 143 | } 144 | }, 145 | { 146 | id: 7, 147 | title: 'The Hitchhiker\'s Guide to the Galaxy' 148 | }, 149 | { 150 | id: 8, 151 | title: 'Harry Potter and the Deathly Hallows', 152 | info: { 153 | comment: 'The best book again', 154 | reviewNb: 1000 155 | } 156 | } 157 | ] 158 | end 159 | 160 | before { index.add_documents(documents).await } 161 | end 162 | -------------------------------------------------------------------------------- /spec/meilisearch/index/search/q_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Index - Basic search' do 4 | include_context 'search books with genre' 5 | 6 | it 'does a basic search in index' do 7 | response = index.search('prince') 8 | expect(response).to be_a(Hash) 9 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 10 | expect(response['hits']).not_to be_empty 11 | expect(response['hits'].first).not_to have_key('_formatted') 12 | end 13 | 14 | it 'does a basic search with an empty query' do 15 | response = index.search('') 16 | expect(response).to be_a(Hash) 17 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 18 | expect(response['hits'].count).to eq(documents.count) 19 | end 20 | 21 | it 'does a basic search with a nil query' do 22 | response = index.search(nil) 23 | expect(response).to be_a(Hash) 24 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 25 | expect(response['hits'].count).to eq(documents.count) 26 | expect(response['hits'].first).not_to have_key('_formatted') 27 | end 28 | 29 | it 'has nbHits to maintain backward compatibility' do 30 | response = index.search('') 31 | 32 | expect(response).to be_a(Hash) 33 | expect(response).to have_key('nbHits') 34 | expect(response['nbHits']).to eq(response['estimatedTotalHits']) 35 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 36 | expect(response['hits'].count).to eq(documents.count) 37 | expect(response['hits'].first).not_to have_key('_formatted') 38 | end 39 | 40 | it 'does a basic search with an empty query and a custom ranking rule' do 41 | index.update_ranking_rules([ 42 | 'words', 43 | 'typo', 44 | 'sort', 45 | 'proximity', 46 | 'attribute', 47 | 'exactness', 48 | 'objectId:asc' 49 | ]).await 50 | response = index.search('') 51 | expect(response['estimatedTotalHits']).to eq(documents.count) 52 | expect(response['hits'].first['objectId']).to eq(1) 53 | end 54 | 55 | it 'does a basic search with an integer query' do 56 | response = index.search(1) 57 | expect(response).to be_a(Hash) 58 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 59 | expect(response['hits'].count).to eq(3) 60 | expect(response['hits'].first).not_to have_key('_formatted') 61 | end 62 | 63 | it 'does a phrase search' do 64 | response = index.search('coco "harry"') 65 | expect(response).to be_a(Hash) 66 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 67 | expect(response['hits'].count).to eq(2) 68 | expect(response['hits'].first['objectId']).to eq(4) 69 | expect(response['hits'].first).not_to have_key('_formatted') 70 | end 71 | 72 | context 'with finite pagination params' do 73 | it 'responds with specialized fields' do 74 | response = index.search('coco', { page: 2, hits_per_page: 2 }) 75 | expect(response.keys).to include(*FINITE_PAGINATED_SEARCH_RESPONSE_KEYS) 76 | 77 | response = index.search('coco', { page: 2, hitsPerPage: 2 }) 78 | expect(response.keys).to include(*FINITE_PAGINATED_SEARCH_RESPONSE_KEYS) 79 | end 80 | end 81 | 82 | context 'with attributes_to_search_on params' do 83 | it 'responds with empty attributes_to_search_on' do 84 | response = index.search('prince', { attributes_to_search_on: [] }) 85 | expect(response).to be_a(Hash) 86 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 87 | expect(response['hits']).to be_empty 88 | end 89 | 90 | it 'responds with nil attributes_to_search_on' do 91 | response = index.search('prince', { attributes_to_search_on: nil }) 92 | expect(response).to be_a(Hash) 93 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 94 | expect(response['hits']).not_to be_empty 95 | end 96 | 97 | it 'responds with title attributes_to_search_on' do 98 | response = index.search('prince', { attributes_to_search_on: ['title'] }) 99 | expect(response).to be_a(Hash) 100 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 101 | expect(response['hits']).not_to be_empty 102 | end 103 | 104 | it 'responds with genre attributes_to_search_on' do 105 | response = index.search('prince', { attributes_to_search_on: ['genry'] }) 106 | expect(response).to be_a(Hash) 107 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 108 | expect(response['hits']).to be_empty 109 | end 110 | 111 | it 'responds with nil attributes_to_search_on and empty query' do 112 | response = index.search('', { attributes_to_search_on: nil }) 113 | expect(response).to be_a(Hash) 114 | expect(response.keys).to include(*DEFAULT_SEARCH_RESPONSE_KEYS) 115 | expect(response['hits'].count).to eq(documents.count) 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/meilisearch/http_request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'httparty' 4 | require 'meilisearch/error' 5 | 6 | module Meilisearch 7 | class HTTPRequest 8 | include HTTParty 9 | 10 | attr_reader :options, :headers 11 | 12 | DEFAULT_OPTIONS = { 13 | timeout: 10, 14 | max_retries: 2, 15 | retry_multiplier: 1.2, 16 | convert_body?: true 17 | }.freeze 18 | 19 | def initialize(url, api_key = nil, options = {}) 20 | @base_url = url 21 | @api_key = api_key 22 | @options = DEFAULT_OPTIONS.merge(options) 23 | @headers = build_default_options_headers 24 | end 25 | 26 | def http_get(relative_path = '', query_params = {}, options = {}) 27 | send_request( 28 | proc { |path, config| self.class.get(path, config) }, 29 | relative_path, 30 | config: { 31 | query_params: query_params, 32 | headers: remove_headers(@headers.dup.merge(options[:headers] || {}), 'Content-Type'), 33 | options: @options.merge(options), 34 | method_type: :get 35 | } 36 | ) 37 | end 38 | 39 | def http_post(relative_path = '', body = nil, query_params = nil, options = {}) 40 | send_request( 41 | proc { |path, config| self.class.post(path, config) }, 42 | relative_path, 43 | config: { 44 | query_params: query_params, 45 | body: body, 46 | headers: @headers.dup.merge(options[:headers] || {}), 47 | options: @options.merge(options), 48 | method_type: :post 49 | } 50 | ) 51 | end 52 | 53 | def http_put(relative_path = '', body = nil, query_params = nil, options = {}) 54 | send_request( 55 | proc { |path, config| self.class.put(path, config) }, 56 | relative_path, 57 | config: { 58 | query_params: query_params, 59 | body: body, 60 | headers: @headers.dup.merge(options[:headers] || {}), 61 | options: @options.merge(options), 62 | method_type: :put 63 | } 64 | ) 65 | end 66 | 67 | def http_patch(relative_path = '', body = nil, query_params = nil, options = {}) 68 | send_request( 69 | proc { |path, config| self.class.patch(path, config) }, 70 | relative_path, 71 | config: { 72 | query_params: query_params, 73 | body: body, 74 | headers: @headers.dup.merge(options[:headers] || {}), 75 | options: @options.merge(options), 76 | method_type: :patch 77 | } 78 | ) 79 | end 80 | 81 | def http_delete(relative_path = '', query_params = nil, options = {}) 82 | send_request( 83 | proc { |path, config| self.class.delete(path, config) }, 84 | relative_path, 85 | config: { 86 | query_params: query_params, 87 | headers: remove_headers(@headers.dup.merge(options[:headers] || {}), 'Content-Type'), 88 | options: @options.merge(options), 89 | method_type: :delete 90 | } 91 | ) 92 | end 93 | 94 | private 95 | 96 | def build_default_options_headers 97 | { 98 | 'Content-Type' => 'application/json', 99 | 'Authorization' => ("Bearer #{@api_key}" unless @api_key.nil?), 100 | 'User-Agent' => [ 101 | @options.fetch(:client_agents, []), 102 | Meilisearch.qualified_version 103 | ].flatten.join(';') 104 | }.compact 105 | end 106 | 107 | def remove_headers(data, *keys) 108 | data.delete_if { |k| keys.include?(k) } 109 | end 110 | 111 | def send_request(http_method, relative_path, config:) 112 | attempts = 0 113 | retry_multiplier = config.dig(:options, :retry_multiplier) 114 | max_retries = config.dig(:options, :max_retries) 115 | request_config = http_config(config[:query_params], config[:body], config[:options], config[:headers]) 116 | 117 | begin 118 | response = http_method.call(@base_url + relative_path, request_config) 119 | rescue Errno::ECONNREFUSED, Errno::EPIPE => e 120 | raise CommunicationError, e.message 121 | rescue URI::InvalidURIError => e 122 | raise CommunicationError, "Client URL missing scheme/protocol. Did you mean https://#{@base_url}" unless @base_url =~ %r{^\w+://} 123 | 124 | raise CommunicationError, e 125 | rescue Net::OpenTimeout, Net::ReadTimeout => e 126 | attempts += 1 127 | raise TimeoutError, e.message unless attempts <= max_retries && safe_to_retry?(config[:method_type], e) 128 | 129 | sleep(retry_multiplier**attempts) 130 | 131 | retry 132 | end 133 | 134 | validate(response) 135 | end 136 | 137 | def http_config(query_params, body, options, headers) 138 | body = body.to_json if options[:convert_body?] == true 139 | { 140 | headers: headers, 141 | query: query_params, 142 | body: body, 143 | timeout: options[:timeout], 144 | max_retries: options[:max_retries] 145 | }.compact 146 | end 147 | 148 | def validate(response) 149 | raise ApiError.new(response.code, response.message, response.body) unless response.success? 150 | 151 | response.parsed_response 152 | end 153 | 154 | # Ensures the only retryable error is a timeout didn't reached the server 155 | def safe_to_retry?(method_type, error) 156 | method_type == :get || ([:post, :put, :patch, :delete].include?(method_type) && error.is_a?(Net::OpenTimeout)) 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /spec/meilisearch/client/token_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jwt' 4 | 5 | VERIFY_OPTIONS = { 6 | required_claims: ['exp', 'apiKeyUid', 'searchRules'], 7 | algorithm: 'HS256' 8 | }.freeze 9 | 10 | RSpec.describe Meilisearch::TenantToken do 11 | let(:instance) { dummy_class.new(client_key) } 12 | let(:dummy_class) do 13 | Class.new do 14 | include Meilisearch::TenantToken 15 | 16 | def initialize(api_key) 17 | @api_key = api_key 18 | end 19 | end 20 | end 21 | 22 | let(:search_rules) { {} } 23 | let(:api_key) { SecureRandom.hex(24) } 24 | let(:client_key) { SecureRandom.hex(24) } 25 | let(:expires_at) { Time.now.utc + 10_000 } 26 | 27 | it 'responds to #generate_tenant_token' do 28 | expect(instance).to respond_to(:generate_tenant_token) 29 | end 30 | 31 | describe '#generate_tenant_token' do 32 | subject(:token) do 33 | instance.generate_tenant_token('uid', search_rules, api_key: api_key, expires_at: expires_at) 34 | end 35 | 36 | context 'with api_key param' do 37 | it 'decodes successfully using api_key from param' do 38 | expect do 39 | JWT.decode token, api_key, true, VERIFY_OPTIONS 40 | end.to_not raise_error 41 | end 42 | 43 | it 'tries to decode without the right signature raises a error' do 44 | expect do 45 | JWT.decode token, client_key, true, VERIFY_OPTIONS 46 | end.to raise_error(JWT::DecodeError) 47 | end 48 | end 49 | 50 | context 'without api_key param' do 51 | let(:api_key) { nil } 52 | 53 | it 'decodes successfully using @api_key from instance' do 54 | expect do 55 | JWT.decode token, client_key, true, VERIFY_OPTIONS 56 | end.not_to raise_error 57 | end 58 | 59 | it 'tries to decode without the right signature raises a error' do 60 | expect do 61 | JWT.decode token, api_key, true, VERIFY_OPTIONS 62 | end.to raise_error(JWT::DecodeError) 63 | end 64 | 65 | it 'raises error when both api_key are nil' do 66 | client = dummy_class.new(nil) 67 | 68 | expect do 69 | client.generate_tenant_token('uid', search_rules) 70 | end.to raise_error(described_class::InvalidApiKey) 71 | end 72 | 73 | it 'raises error when both api_key are empty' do 74 | client = dummy_class.new('') 75 | 76 | expect do 77 | client.generate_tenant_token('uid', search_rules, api_key: '') 78 | end.to raise_error(described_class::InvalidApiKey) 79 | end 80 | end 81 | 82 | context 'with expires_at' do 83 | it 'raises error when expires_at is in the past' do 84 | expect do 85 | instance.generate_tenant_token('uid', search_rules, expires_at: Time.now.utc - 10) 86 | end.to raise_error(described_class::ExpireOrInvalidSignature) 87 | end 88 | 89 | it 'allows generate token with a nil expires_at' do 90 | expect do 91 | instance.generate_tenant_token('uid', search_rules, expires_at: nil) 92 | end.not_to raise_error 93 | end 94 | 95 | it 'decodes successfully the expires_at param' do 96 | decoded = JWT.decode token, api_key, false 97 | 98 | expect(decoded.dig(0, 'exp')).to eq(expires_at.to_i) 99 | end 100 | 101 | it 'raises error when expires_at has a invalid type' do 102 | ['2042-01-01', 78_126_717_684, []].each do |exp| 103 | expect do 104 | instance.generate_tenant_token('uid', search_rules, expires_at: exp) 105 | end.to raise_error(described_class::ExpireOrInvalidSignature) 106 | end 107 | end 108 | 109 | it 'raises error when expires_at is not a UTC' do 110 | expect do 111 | instance.generate_tenant_token('uid', search_rules, expires_at: Time.now + 10) 112 | end.to raise_error(described_class::ExpireOrInvalidSignature) 113 | end 114 | end 115 | 116 | context 'without expires_at param' do 117 | it 'allows generate token without expires_at' do 118 | expect do 119 | instance.generate_tenant_token('uid', search_rules) 120 | end.not_to raise_error 121 | end 122 | end 123 | 124 | context 'with search_rules definitions' do 125 | include_context 'search books with genre' 126 | 127 | before { index.update_filterable_attributes(['genre', 'objectId']).await } 128 | 129 | let(:adm_client) { Meilisearch::Client.new(URL, adm_key['key']) } 130 | let(:adm_key) do 131 | client.create_key( 132 | description: 'tenants test', 133 | actions: ['*'], 134 | indexes: ['*'], 135 | expires_at: '2042-04-02T00:42:42Z' 136 | ) 137 | end 138 | let(:rules) do 139 | [ 140 | { '*': {} }, 141 | { '*': nil }, 142 | ['*'], 143 | { '*': { filter: 'genre = comedy' } }, 144 | { books: {} }, 145 | { books: nil }, 146 | ['books'], 147 | { books: { filter: 'genre = comedy AND objectId = 1' } } 148 | ] 149 | end 150 | 151 | it 'accepts the token in the search request' do 152 | rules.each do |data| 153 | token = adm_client.generate_tenant_token(adm_key['uid'], data) 154 | custom = Meilisearch::Client.new(URL, token) 155 | 156 | expect(custom.index('books').search('')).to have_key('hits') 157 | end 158 | end 159 | 160 | it 'requires a non-nil payload in the search_rules' do 161 | expect do 162 | client.generate_tenant_token('uid', nil) 163 | end.to raise_error(described_class::InvalidSearchRules) 164 | end 165 | end 166 | 167 | it 'has apiKeyUid with the uid of the key' do 168 | decoded = JWT.decode(token, api_key, true, VERIFY_OPTIONS).dig(0, 'apiKeyUid') 169 | 170 | expect(decoded).to eq('uid') 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /spec/meilisearch/utils_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Meilisearch::Utils do 4 | let(:logger) { instance_double(Logger, warn: nil) } 5 | 6 | describe '.soft_deprecate' do 7 | before { described_class.logger = logger } 8 | after { described_class.logger = nil } 9 | 10 | it 'outputs a warning' do 11 | described_class.soft_deprecate('footballs', 'snowballs') 12 | expect(logger).to have_received(:warn) 13 | end 14 | 15 | it 'does not throw an error' do 16 | expect do 17 | described_class.soft_deprecate('footballs', 'snowballs') 18 | end.not_to raise_error 19 | end 20 | 21 | it 'includes relevant information' do 22 | described_class.soft_deprecate('footballs', 'snowballs') 23 | expect(logger).to have_received(:warn).with(a_string_including('footballs', 'snowballs')) 24 | end 25 | end 26 | 27 | describe '.parse_query' do 28 | it 'transforms arrays into strings' do 29 | data = described_class.parse_query({ array: [1, 2, 3], other: 'string' }, [:array, :other]) 30 | 31 | expect(data).to eq({ 'array' => '1,2,3', 'other' => 'string' }) 32 | end 33 | 34 | it 'cleans list based on another list' do 35 | data = described_class.parse_query({ array: [1, 2, 3], ignore: 'string' }, [:array]) 36 | 37 | expect(data).to eq({ 'array' => '1,2,3' }) 38 | end 39 | 40 | it 'transforms dates into strings' do 41 | data = described_class.parse_query({ date: DateTime.new(2012, 12, 21, 19, 5) }, [:date]) 42 | 43 | expect(data).to eq({ 'date' => '2012-12-21T19:05:00+00:00' }) 44 | end 45 | end 46 | 47 | describe '.transform_attributes' do 48 | before { described_class.logger = logger } 49 | after { described_class.logger = nil } 50 | 51 | it 'transforms snake_case into camelCased keys' do 52 | data = described_class.transform_attributes({ 53 | index_name: 'books', 54 | my_UID: '123' 55 | }) 56 | 57 | expect(data).to eq({ 'indexName' => 'books', 'myUid' => '123' }) 58 | end 59 | 60 | it 'transforms snake_case into camel cased keys from array' do 61 | data = described_class 62 | .transform_attributes([ 63 | { index_uid: 'books', q: 'prince' }, 64 | { index_uid: 'movies', q: 'prince' } 65 | ]) 66 | 67 | expect(data).to eq( 68 | [ 69 | { 'indexUid' => 'books', 'q' => 'prince' }, 70 | { 'indexUid' => 'movies', 'q' => 'prince' } 71 | ] 72 | ) 73 | end 74 | 75 | it 'warns when using camelCase' do 76 | attrs = { distinctAttribute: 'title' } 77 | 78 | described_class.transform_attributes(attrs) 79 | 80 | expect(logger).to have_received(:warn) 81 | .with(a_string_including('Attributes will be expected to be snake_case', 'distinctAttribute')) 82 | end 83 | 84 | it 'warns when using camelCase in an array' do 85 | attrs = [ 86 | { 'index_uid' => 'movies', 'q' => 'prince' }, 87 | { 'indexUid' => 'books', 'q' => 'prince' } 88 | ] 89 | 90 | described_class.transform_attributes(attrs) 91 | 92 | expect(logger).to have_received(:warn) 93 | .with(a_string_including('Attributes will be expected to be snake_case', 'indexUid')) 94 | end 95 | end 96 | 97 | describe '.version_error_handler' do 98 | let(:http_body) do 99 | { 'message' => 'Was expecting an operation', 100 | 'code' => 'invalid_document_filter', 101 | 'type' => 'invalid_request', 102 | 'link' => 'https://docs.meilisearch.com/errors#invalid_document_filter' } 103 | end 104 | 105 | it 'spawns same error message' do 106 | expect do 107 | described_class.version_error_handler(:my_method) do 108 | raise Meilisearch::ApiError.new(405, 'I came from Meili server', http_body) 109 | end 110 | end.to raise_error(Meilisearch::ApiError, /I came from Meili server/) 111 | end 112 | 113 | it 'spawns same error message with html body' do 114 | expect do 115 | described_class.version_error_handler(:my_method) do 116 | raise Meilisearch::ApiError.new(405, 'I came from Meili server', '

405 Error

') 117 | end 118 | end.to raise_error(Meilisearch::ApiError, /I came from Meili server/) 119 | end 120 | 121 | it 'spawns same error message with no body' do 122 | expect do 123 | described_class.version_error_handler(:my_method) do 124 | raise Meilisearch::ApiError.new(405, 'I came from Meili server', nil) 125 | end 126 | end.to raise_error(Meilisearch::ApiError, /I came from Meili server/) 127 | end 128 | 129 | it 'spawns message with version hint' do 130 | expect do 131 | described_class.version_error_handler(:my_method) do 132 | raise Meilisearch::ApiError.new(405, 'I came from Meili server', http_body) 133 | end 134 | end.to raise_error(Meilisearch::ApiError, /that `my_method` call requires/) 135 | end 136 | 137 | it 'adds hints to all error types' do 138 | expect do 139 | described_class.version_error_handler(:my_method) do 140 | raise Meilisearch::CommunicationError, 'I am an error' 141 | end 142 | end.to raise_error(Meilisearch::CommunicationError, /that `my_method` call requires/) 143 | end 144 | 145 | describe '.warn_on_non_conforming_attribute_names' do 146 | before { described_class.logger = logger } 147 | after { described_class.logger = nil } 148 | 149 | it 'warns when using camelCase attributes' do 150 | attrs = { attributesToHighlight: ['field'] } 151 | described_class.warn_on_non_conforming_attribute_names(attrs) 152 | 153 | expect(logger).to have_received(:warn) 154 | .with(a_string_including('Attributes will be expected to be snake_case', 'attributesToHighlight')) 155 | end 156 | 157 | it 'warns when using a mixed case' do 158 | attrs = { distinct_ATTribute: 'title' } 159 | described_class.warn_on_non_conforming_attribute_names(attrs) 160 | 161 | expect(logger).to have_received(:warn) 162 | .with(a_string_including('Attributes will be expected to be snake_case', 'distinct_ATTribute')) 163 | end 164 | 165 | it 'does not warn when using snake_case' do 166 | attrs = { q: 'query', attributes_to_highlight: ['field'] } 167 | described_class.warn_on_non_conforming_attribute_names(attrs) 168 | 169 | expect(logger).not_to have_received(:warn) 170 | end 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, thank you for contributing to Meilisearch! The goal of this document is to provide everything you need to know in order to contribute to Meilisearch and its different integrations. 4 | 5 | 6 | 7 | - [Assumptions](#assumptions) 8 | - [How to Contribute](#how-to-contribute) 9 | - [Development Workflow](#development-workflow) 10 | - [Git Guidelines](#git-guidelines) 11 | - [Release Process (for internal team only)](#release-process-for-internal-team-only) 12 | 13 | 14 | 15 | ## Assumptions 16 | 17 | 1. **You're familiar with [GitHub](https://github.com) and the [Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests)(PR) workflow.** 18 | 2. **You've read the Meilisearch [documentation](https://docs.meilisearch.com) and the [README](/README.md).** 19 | 3. **You know about the [Meilisearch community](https://discord.meilisearch.com). Please use this for help.** 20 | 21 | ## How to Contribute 22 | 23 | 1. Make sure that the contribution you want to make is explained or detailed in a GitHub issue! Find an [existing issue](https://github.com/meilisearch/meilisearch-ruby/issues/) or [open a new one](https://github.com/meilisearch/meilisearch-ruby/issues/new). 24 | 2. Once done, [fork the meilisearch-ruby repository](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) in your own GitHub account. Ask a maintainer if you want your issue to be checked before making a PR. 25 | 3. [Create a new Git branch](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-and-deleting-branches-within-your-repository). 26 | 4. Review the [Development Workflow](#development-workflow) section that describes the steps to maintain the repository. 27 | 5. Make the changes on your branch. 28 | 6. [Submit the branch as a PR](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) pointing to the `main` branch of the main meilisearch-ruby repository. A maintainer should comment and/or review your Pull Request within a few days. Although depending on the circumstances, it may take longer.
29 | We do not enforce a naming convention for the PRs, but **please use something descriptive of your changes**, having in mind that the title of your PR will be automatically added to the next [release changelog](https://github.com/meilisearch/meilisearch-ruby/releases/). 30 | 31 | ## Development Workflow 32 | 33 | ### Setup 34 | 35 | You can set up your local environment natively or using `docker`, check out the [`docker-compose.yml`](/docker-compose.yml). 36 | 37 | Example of running all the checks with docker: 38 | ```bash 39 | docker-compose run --rm package bash -c "bundle install && bundle exec rspec && bundle exec rubocop" 40 | ``` 41 | 42 | To install dependencies: 43 | ```bash 44 | bundle install 45 | ``` 46 | 47 | ### Tests 48 | 49 | Each PR should pass the tests to be accepted. 50 | 51 | ```bash 52 | # Tests 53 | curl -L https://install.meilisearch.com | sh # download Meilisearch 54 | ./meilisearch --master-key=masterKey --no-analytics # run Meilisearch 55 | bundle exec rspec 56 | ``` 57 | 58 | To launch a specific folder or file: 59 | 60 | ```bash 61 | bundle exec rspec spec/meilisearch/index/base_spec.rb 62 | ``` 63 | 64 | To launch a single test in a specific file: 65 | 66 | ```bash 67 | bundle exec rspec spec/meilisearch/index/search_spec.rb -e 'does a basic search in index' 68 | ``` 69 | 70 | ### Linter 71 | 72 | Each PR should pass the linter to be accepted. 73 | 74 | ```bash 75 | # Check the linter errors 76 | bundle exec rubocop lib/ spec/ 77 | # Auto-correct the linter errors 78 | bundle exec rubocop -a lib/ spec/ 79 | ``` 80 | 81 | If you think the remaining linter errors are acceptable, do not add any `rubocop` in-line comments in the code.
82 | This project uses a `rubocop_todo.yml` file that is generated. Do not modify this file manually.
83 | To update it, run the following command: 84 | 85 | ```bash 86 | bundle exec rubocop --auto-gen-config 87 | ``` 88 | 89 | ### Want to debug? 90 | 91 | You can use the [`byebug` gem](https://github.com/deivid-rodriguez/byebug). 92 | 93 | To create a breakpoint, just add this line in you code: 94 | 95 | ```ruby 96 | ... 97 | byebug 98 | ... 99 | ``` 100 | 101 | The `byebug` gem is already imported in all the spec files.
102 | But if you want to use it in the source files you need to add this line at the top of the file: 103 | 104 | ```ruby 105 | require 'byebug' 106 | ``` 107 | 108 | ## Git Guidelines 109 | 110 | ### Git Branches 111 | 112 | All changes must be made in a branch and submitted as PR. 113 | We do not enforce any branch naming style, but please use something descriptive of your changes. 114 | 115 | ### Git Commits 116 | 117 | As minimal requirements, your commit message should: 118 | - be capitalized 119 | - not finish by a dot or any other punctuation character (!,?) 120 | - start with a verb so that we can read your commit message this way: "This commit will ...", where "..." is the commit message. 121 | e.g.: "Fix the home page button" or "Add more tests for create_index method" 122 | 123 | We don't follow any other convention, but if you want to use one, we recommend [this one](https://chris.beams.io/posts/git-commit/). 124 | 125 | ### GitHub Pull Requests 126 | 127 | Some notes on GitHub PRs: 128 | 129 | - [Convert your PR as a draft](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/changing-the-stage-of-a-pull-request) if your changes are a work in progress: no one will review it until you pass your PR as ready for review.
130 | The draft PR can be very useful if you want to show that you are working on something and make your work visible. 131 | - All PRs must be reviewed and approved by at least one maintainer. 132 | - The PR title should be accurate and descriptive of the changes. The title of the PR will be indeed automatically added to the next [release changelogs](https://github.com/meilisearch/meilisearch-ruby/releases/). 133 | 134 | ## Release Process (for the internal team only) 135 | 136 | Meilisearch tools follow the [Semantic Versioning Convention](https://semver.org/). 137 | 138 | ### Automated Changelogs 139 | 140 | This project integrates a tool to create automated changelogs.
141 | _[Read more about this](https://github.com/meilisearch/integration-guides/blob/main/resources/release-drafter.md)._ 142 | 143 | ### How to Publish the Release 144 | 145 | ⚠️ Before doing anything, make sure you got through the guide about [Releasing an Integration](https://github.com/meilisearch/integration-guides/blob/main/resources/integration-release.md). 146 | 147 | Make a PR modifying the file [`lib/meilisearch/version.rb`](/lib/meilisearch/version.rb) with the right version. 148 | 149 | ```ruby 150 | VERSION = 'X.X.X' 151 | ``` 152 | 153 | Once the changes are merged on `main`, you can publish the current draft release via the [GitHub interface](https://github.com/meilisearch/meilisearch-ruby/releases): on this page, click on `Edit` (related to the draft release) > update the description (be sure you apply [these recommendations](https://github.com/meilisearch/integration-guides/blob/main/resources/integration-release.md#writting-the-release-description)) > when you are ready, click on `Publish release`. 154 | 155 | GitHub Actions will be triggered and push the package to [RubyGems](https://rubygems.org/gems/meilisearch). 156 | 157 |
158 | 159 | Thank you again for reading this through. We can not wait to begin to work with you if you make your way through this contributing guide ❤️ 160 | -------------------------------------------------------------------------------- /spec/meilisearch/index/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Meilisearch::Index do 4 | it 'fetch the info of the index' do 5 | client.create_index('books').await 6 | 7 | index = client.fetch_index('books') 8 | expect(index).to be_a(described_class) 9 | expect(index.uid).to eq('books') 10 | expect(index.created_at).to be_a(Time) 11 | expect(index.created_at).to be_within(60).of(Time.now) 12 | expect(index.updated_at).to be_a(Time) 13 | expect(index.created_at).to be_within(60).of(Time.now) 14 | expect(index.primary_key).to be_nil 15 | end 16 | 17 | it 'fetch the raw Hash info of the index' do 18 | client.create_index('books', primary_key: 'reference_number').await 19 | 20 | raw_index = client.fetch_raw_index('books') 21 | 22 | expect(raw_index).to be_a(Hash) 23 | expect(raw_index['uid']).to eq('books') 24 | expect(raw_index['primaryKey']).to eq('reference_number') 25 | expect(Time.parse(raw_index['createdAt'])).to be_a(Time) 26 | expect(Time.parse(raw_index['createdAt'])).to be_within(60).of(Time.now) 27 | expect(Time.parse(raw_index['updatedAt'])).to be_a(Time) 28 | expect(Time.parse(raw_index['updatedAt'])).to be_within(60).of(Time.now) 29 | end 30 | 31 | it 'get primary-key of index if null' do 32 | client.create_index('index_without_primary_key').await 33 | 34 | index = client.fetch_index('index_without_primary_key') 35 | expect(index.primary_key).to be_nil 36 | expect(index.fetch_primary_key).to be_nil 37 | end 38 | 39 | it 'get primary-key of index if it exists' do 40 | client.create_index('index_with_prirmary_key', primary_key: 'primary_key').await 41 | 42 | index = client.fetch_index('index_with_prirmary_key') 43 | expect(index.primary_key).to eq('primary_key') 44 | expect(index.fetch_primary_key).to eq('primary_key') 45 | end 46 | 47 | it 'get uid of index' do 48 | client.create_index('uid').await 49 | 50 | index = client.fetch_index('uid') 51 | expect(index.uid).to eq('uid') 52 | end 53 | 54 | it 'updates primary-key of index if not defined before' do 55 | client.create_index('uid').await 56 | 57 | task = client.index('uid').update(primary_key: 'new_primary_key') 58 | expect(task.type).to eq('indexUpdate') 59 | task.await 60 | 61 | index = client.fetch_index('uid') 62 | expect(index).to be_a(described_class) 63 | expect(index.uid).to eq('uid') 64 | expect(index.primary_key).to eq('new_primary_key') 65 | expect(index.fetch_primary_key).to eq('new_primary_key') 66 | expect(index.created_at).to be_a(Time) 67 | expect(index.created_at).to be_within(60).of(Time.now) 68 | expect(index.updated_at).to be_a(Time) 69 | expect(index.updated_at).to be_within(60).of(Time.now) 70 | end 71 | 72 | it 'updates primary-key of index if has been defined before but there is not docs' do 73 | client.create_index('books', primary_key: 'reference_number').await 74 | 75 | task = client.index('books').update(primary_key: 'international_standard_book_number') 76 | expect(task.type).to eq('indexUpdate') 77 | task.await 78 | 79 | index = client.fetch_index('books') 80 | expect(index).to be_a(described_class) 81 | expect(index.uid).to eq('books') 82 | expect(index.primary_key).to eq('international_standard_book_number') 83 | expect(index.fetch_primary_key).to eq('international_standard_book_number') 84 | expect(index.created_at).to be_a(Time) 85 | expect(index.created_at).to be_within(60).of(Time.now) 86 | expect(index.updated_at).to be_a(Time) 87 | expect(index.updated_at).to be_within(60).of(Time.now) 88 | end 89 | 90 | it 'returns a failing task if primary-key is already defined' do 91 | index = client.index('uid') 92 | index.add_documents({ id: 1, title: 'My Title' }).await 93 | 94 | task = index.update(primary_key: 'new_primary_key') 95 | expect(task.type).to eq('indexUpdate') 96 | 97 | task.await 98 | expect(task).to be_failed 99 | expect(task.error['code']).to eq('index_primary_key_already_exists') 100 | end 101 | 102 | it 'supports options' do 103 | options = { timeout: 2, retry_multiplier: 1.2, max_retries: 1 } 104 | expected_headers = { 105 | 'Authorization' => "Bearer #{MASTER_KEY}", 106 | 'User-Agent' => Meilisearch.qualified_version 107 | } 108 | 109 | new_client = Meilisearch::Client.new(URL, MASTER_KEY, options) 110 | new_client.create_index('books').await 111 | index = new_client.fetch_index('books') 112 | expect(index.options).to eq({ max_retries: 1, retry_multiplier: 1.2, timeout: 2, convert_body?: true }) 113 | 114 | expect(described_class).to receive(:get).with( 115 | "#{URL}/indexes/books", 116 | { 117 | headers: expected_headers, 118 | body: 'null', 119 | query: {}, 120 | max_retries: 1, 121 | timeout: 2 122 | } 123 | ).and_return(double(success?: true, 124 | parsed_response: { 'createdAt' => '2021-10-16T14:57:35Z', 125 | 'updatedAt' => '2021-10-16T14:57:35Z' })) 126 | index.fetch_info 127 | end 128 | 129 | it 'supports client_agents' do 130 | custom_agent = 'Meilisearch Rails (v0.0.1)' 131 | options = { timeout: 2, retry_multiplier: 1.2, max_retries: 1, client_agents: [custom_agent] } 132 | expected_headers = { 133 | 'Authorization' => "Bearer #{MASTER_KEY}", 134 | 'User-Agent' => "#{custom_agent};#{Meilisearch.qualified_version}" 135 | } 136 | 137 | new_client = Meilisearch::Client.new(URL, MASTER_KEY, options) 138 | new_client.create_index('books').await 139 | index = new_client.fetch_index('books') 140 | expect(index.options).to eq(options.merge({ convert_body?: true })) 141 | 142 | expect(described_class).to receive(:get).with( 143 | "#{URL}/indexes/books", 144 | { 145 | headers: expected_headers, 146 | body: 'null', 147 | query: {}, 148 | max_retries: 1, 149 | timeout: 2 150 | } 151 | ).and_return(double(success?: true, 152 | parsed_response: { 'createdAt' => '2021-10-16T14:57:35Z', 153 | 'updatedAt' => '2021-10-16T14:57:35Z' })) 154 | index.fetch_info 155 | end 156 | 157 | it 'deletes index' do 158 | client.create_index('uid').await 159 | 160 | task = client.index('uid').delete 161 | expect(task.type).to eq('indexDeletion') 162 | task.await 163 | expect(task).to be_succeeded 164 | expect { client.fetch_index('uid') }.to raise_index_not_found_meilisearch_api_error 165 | end 166 | 167 | it 'fails to manipulate index object after deletion' do 168 | client.create_index('uid').await 169 | 170 | task = client.index('uid').delete 171 | expect(task.type).to eq('indexDeletion') 172 | task.await 173 | 174 | index = client.index('uid') 175 | expect { index.fetch_primary_key }.to raise_index_not_found_meilisearch_api_error 176 | expect { index.fetch_info }.to raise_index_not_found_meilisearch_api_error 177 | end 178 | 179 | it 'works with method aliases' do 180 | client.create_index('uid', primary_key: 'primary_key').await 181 | 182 | index = client.fetch_index('uid') 183 | expect(index.method(:fetch_primary_key) == index.method(:get_primary_key)).to be_truthy 184 | expect(index.method(:update) == index.method(:update_index)).to be_truthy 185 | expect(index.method(:delete) == index.method(:delete_index)).to be_truthy 186 | end 187 | 188 | context 'with snake_case options' do 189 | it 'does the request with camelCase attributes' do 190 | client.create_index('uid').await 191 | 192 | task = client.index('uid').update(primary_key: 'new_primary_key') 193 | expect(task.type).to eq('indexUpdate') 194 | task.await 195 | 196 | index = client.fetch_index('uid') 197 | expect(index).to be_a(described_class) 198 | expect(index.uid).to eq('uid') 199 | expect(index.primary_key).to eq('new_primary_key') 200 | expect(index.fetch_primary_key).to eq('new_primary_key') 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /spec/meilisearch/client/tasks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Tasks' do 4 | include_context 'search books with genre' 5 | 6 | let(:enqueued_task_keys) { ['uid', 'indexUid', 'status', 'type', 'enqueuedAt'] } 7 | let(:succeeded_task_keys) { [*enqueued_task_keys, 'duration', 'startedAt', 'finishedAt'] } 8 | let!(:doc_addition_task) { index.add_documents(documents).await } 9 | let(:task_uid) { doc_addition_task['uid'] } 10 | 11 | it 'gets a task of an index' do 12 | task = index.task(task_uid) 13 | 14 | expect(task.keys).to include(*succeeded_task_keys) 15 | end 16 | 17 | it 'gets all the tasks of an index' do 18 | tasks = index.tasks 19 | 20 | expect(tasks['results']).to be_a(Array) 21 | expect(tasks['total']).to be > 0 22 | 23 | last_task = tasks['results'].first 24 | 25 | expect(last_task.keys).to include(*succeeded_task_keys) 26 | end 27 | 28 | it 'allows for returning tasks in reverse' do 29 | tasks = client.tasks 30 | rev_tasks = client.tasks(reverse: true) 31 | 32 | expect(tasks['results']).not_to eq(rev_tasks['results']) 33 | end 34 | 35 | it 'gets a task of the Meilisearch instance' do 36 | task = client.task(0) 37 | 38 | expect(task).to be_a(Hash) 39 | expect(task['uid']).to eq(0) 40 | expect(task.keys).to include(*succeeded_task_keys) 41 | end 42 | 43 | it 'gets tasks of the Meilisearch instance' do 44 | tasks = client.tasks 45 | 46 | expect(tasks['results']).to be_a(Array) 47 | expect(tasks['total']).to be > 0 48 | 49 | last_task = tasks['results'].first 50 | 51 | expect(last_task.keys).to include(*succeeded_task_keys) 52 | end 53 | 54 | it 'paginates tasks with limit/from/next' do 55 | tasks = client.tasks(limit: 2) 56 | 57 | expect(tasks['results'].count).to be <= 2 58 | expect(tasks['from']).to be_a(Integer) 59 | expect(tasks['next']).to be_a(Integer) 60 | expect(tasks['total']).to be > 0 61 | end 62 | 63 | it 'filters tasks with index_uids/types/statuses' do 64 | tasks = client.tasks(index_uids: ['a-cool-index-name']) 65 | 66 | expect(tasks['results'].count).to eq(0) 67 | expect(tasks['total']).to eq(0) 68 | 69 | tasks = client.tasks(index_uids: ['books'], types: ['documentAdditionOrUpdate'], statuses: ['succeeded']) 70 | 71 | expect(tasks['results'].count).to be > 1 72 | expect(tasks['total']).to be > 1 73 | end 74 | 75 | it 'ensures supports to all available filters' do 76 | allow(Meilisearch::Utils).to receive(:transform_attributes).and_call_original 77 | 78 | client.tasks( 79 | canceled_by: [1, 2], uids: [2], foo: 'bar', 80 | before_enqueued_at: '2022-01-20', after_enqueued_at: '2022-01-20', 81 | before_started_at: '2022-01-20', after_started_at: '2022-01-20', 82 | before_finished_at: '2022-01-20', after_finished_at: '2022-01-20' 83 | ) 84 | 85 | expect(Meilisearch::Utils).to have_received(:transform_attributes) 86 | .with( 87 | canceled_by: [1, 2], uids: [2], 88 | before_enqueued_at: '2022-01-20', after_enqueued_at: '2022-01-20', 89 | before_started_at: '2022-01-20', after_started_at: '2022-01-20', 90 | before_finished_at: '2022-01-20', after_finished_at: '2022-01-20' 91 | ) 92 | end 93 | 94 | describe '#index.wait_for_task' do 95 | it 'waits for task with default values' do 96 | task = index.add_documents(documents) 97 | task = index.wait_for_task(task['taskUid']) 98 | 99 | expect(task).to be_a(Hash) 100 | expect(task['status']).not_to eq('enqueued') 101 | end 102 | 103 | it 'waits for task with default values after several updates' do 104 | 5.times { index.add_documents(documents) } 105 | task = index.add_documents(documents) 106 | status = index.wait_for_task(task['taskUid']) 107 | 108 | expect(status).to be_a(Hash) 109 | expect(status['status']).not_to eq('enqueued') 110 | end 111 | 112 | it 'waits for task with custom timeout_in_ms and raises MeilisearchTimeoutError' do 113 | index.add_documents(documents) 114 | task = index.add_documents(documents) 115 | expect do 116 | index.wait_for_task(task['taskUid'], 1) 117 | end.to raise_error(Meilisearch::TimeoutError) 118 | end 119 | 120 | it 'waits for task with custom interval_in_ms and raises Timeout::Error' do 121 | index.add_documents(documents) 122 | task = index.add_documents(documents) 123 | expect do 124 | Timeout.timeout(0.1) do 125 | index.wait_for_task(task['taskUid'], 5000, 200) 126 | end 127 | end.to raise_error(Timeout::Error) 128 | end 129 | end 130 | 131 | describe '#client.wait_for_task' do 132 | it 'waits for task with default values' do 133 | task = index.add_documents(documents).await 134 | task = client.wait_for_task(task['taskUid']) 135 | 136 | expect(task).to be_a(Hash) 137 | expect(task['status']).not_to eq('enqueued') 138 | end 139 | 140 | it 'waits for task with default values after several updates' do 141 | 5.times { index.add_documents(documents) } 142 | task = index.add_documents(documents) 143 | status = client.wait_for_task(task['taskUid']) 144 | 145 | expect(status).to be_a(Hash) 146 | expect(status['status']).not_to eq('enqueued') 147 | end 148 | 149 | it 'waits for task with custom timeout_in_ms and raises MeilisearchTimeoutError' do 150 | index.add_documents(documents) 151 | task = index.add_documents(documents) 152 | expect do 153 | client.wait_for_task(task['taskUid'], 1) 154 | end.to raise_error(Meilisearch::TimeoutError) 155 | end 156 | 157 | it 'waits for task with custom interval_in_ms and raises Timeout::Error' do 158 | index.add_documents(documents) 159 | task = index.add_documents(documents) 160 | expect do 161 | Timeout.timeout(0.1) do 162 | client.wait_for_task(task['taskUid'], 5000, 200) 163 | end 164 | end.to raise_error(Timeout::Error) 165 | end 166 | end 167 | 168 | describe '#client.cancel_tasks' do 169 | it 'ensures supports to all available filters' do 170 | allow(Meilisearch::Utils).to receive(:transform_attributes).and_call_original 171 | 172 | client.cancel_tasks( 173 | canceled_by: [1, 2], uids: [2], foo: 'bar', 174 | before_enqueued_at: '2022-01-20', after_enqueued_at: '2022-01-20', 175 | before_started_at: '2022-01-20', after_started_at: '2022-01-20', 176 | before_finished_at: '2022-01-20', after_finished_at: '2022-01-20' 177 | ) 178 | 179 | expect(Meilisearch::Utils).to have_received(:transform_attributes) 180 | .with( 181 | canceled_by: [1, 2], uids: [2], 182 | before_enqueued_at: '2022-01-20', after_enqueued_at: '2022-01-20', 183 | before_started_at: '2022-01-20', after_started_at: '2022-01-20', 184 | before_finished_at: '2022-01-20', after_finished_at: '2022-01-20' 185 | ) 186 | end 187 | 188 | it 'has fields in the details field' do 189 | task = client.cancel_tasks(uids: [1, 2]) 190 | task = client.wait_for_task(task['taskUid']) 191 | 192 | expect(task['details']['originalFilter']).to eq('?uids=1%2C2') 193 | expect(task['details']['matchedTasks']).to be_a(Integer) 194 | expect(task['details']['canceledTasks']).to be_a(Integer) 195 | end 196 | end 197 | 198 | describe '#client.delete_tasks' do 199 | it 'ensures supports to all available filters' do 200 | date = DateTime.new(2022, 1, 20) 201 | 202 | allow(Meilisearch::Utils).to receive(:transform_attributes).and_call_original 203 | 204 | client.delete_tasks( 205 | canceled_by: [1, 2], uids: [2], foo: 'bar', 206 | before_enqueued_at: date, after_enqueued_at: date, 207 | before_started_at: date, after_started_at: date, 208 | before_finished_at: date, after_finished_at: date 209 | ) 210 | 211 | expect(Meilisearch::Utils).to have_received(:transform_attributes) 212 | .with( 213 | canceled_by: [1, 2], uids: [2], 214 | before_enqueued_at: date, after_enqueued_at: date, 215 | before_started_at: date, after_started_at: date, 216 | before_finished_at: date, after_finished_at: date 217 | ) 218 | end 219 | 220 | it 'has fields in the details field' do 221 | task = client.delete_tasks(uids: [1, 2]) 222 | task = client.wait_for_task(task['taskUid']) 223 | 224 | expect(task['details']['originalFilter']).to eq('?uids=1%2C2') 225 | expect(task['details']['matchedTasks']).to be_a(Integer) 226 | expect(task['details']['deletedTasks']).to be_a(Integer) 227 | end 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /spec/meilisearch/client/indexes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'Meilisearch::Client - Indexes' do 4 | describe '#create_index' do 5 | context 'without a primary key' do 6 | it 'creates an index' do 7 | task = client.create_index('books') 8 | expect(task.type).to eq('indexCreation') 9 | task.await 10 | 11 | index = client.fetch_index('books') 12 | expect(index).to be_a(Meilisearch::Index) 13 | expect(index.uid).to eq('books') 14 | expect(index.primary_key).to be_nil 15 | end 16 | 17 | context 'synchronously' do 18 | context 'using ! method' do 19 | before { allow(Meilisearch::Utils).to receive(:soft_deprecate).and_return(nil) } 20 | 21 | it 'creates an index' do 22 | task = client.create_index!('books') 23 | 24 | expect(task.type).to eq('indexCreation') 25 | expect(task).to be_succeeded 26 | 27 | index = client.fetch_index('books') 28 | 29 | expect(index).to be_a(Meilisearch::Index) 30 | expect(index.uid).to eq('books') 31 | expect(index.primary_key).to be_nil 32 | end 33 | 34 | it 'warns about deprecation' do 35 | client.create_index!('books') 36 | expect(Meilisearch::Utils) 37 | .to have_received(:soft_deprecate) 38 | .with('Client#create_index!', a_string_including('books')) 39 | end 40 | end 41 | 42 | context 'using await syntax' do 43 | it 'creates an index' do 44 | task = client.create_index('books').await 45 | 46 | expect(task['type']).to eq('indexCreation') 47 | expect(task['status']).to eq('succeeded') 48 | 49 | index = client.fetch_index('books') 50 | 51 | expect(index).to be_a(Meilisearch::Index) 52 | expect(index.uid).to eq('books') 53 | expect(index.primary_key).to be_nil 54 | end 55 | end 56 | end 57 | end 58 | 59 | context 'with a primary key' do 60 | it 'creates an index' do 61 | task = client.create_index('books', primary_key: 'reference_code') 62 | 63 | expect(task.type).to eq('indexCreation') 64 | task.await 65 | 66 | index = client.fetch_index('books') 67 | expect(index).to be_a(Meilisearch::Index) 68 | expect(index.uid).to eq('books') 69 | expect(index.primary_key).to eq('reference_code') 70 | expect(index.fetch_primary_key).to eq('reference_code') 71 | end 72 | 73 | it 'creates an index synchronously' do 74 | task = client.create_index('books', primary_key: 'reference_code').await 75 | 76 | expect(task['type']).to eq('indexCreation') 77 | expect(task['status']).to eq('succeeded') 78 | 79 | index = client.fetch_index('books') 80 | 81 | expect(index).to be_a(Meilisearch::Index) 82 | expect(index.uid).to eq('books') 83 | expect(index.primary_key).to eq('reference_code') 84 | expect(index.fetch_primary_key).to eq('reference_code') 85 | end 86 | 87 | context 'when primary key option in snake_case' do 88 | it 'creates an index' do 89 | task = client.create_index('books', primary_key: 'reference_code') 90 | expect(task.type).to eq('indexCreation') 91 | task.await 92 | 93 | index = client.fetch_index('books') 94 | expect(index).to be_a(Meilisearch::Index) 95 | expect(index.uid).to eq('books') 96 | expect(index.primary_key).to eq('reference_code') 97 | expect(index.fetch_primary_key).to eq('reference_code') 98 | end 99 | end 100 | 101 | context 'when uid is provided as an option' do 102 | it 'ignores the uid option' do 103 | task = client.create_index( 104 | 'books', 105 | primary_key: 'reference_code', 106 | uid: 'publications' 107 | ) 108 | 109 | expect(task.type).to eq('indexCreation') 110 | task.await 111 | 112 | index = client.fetch_index('books') 113 | expect(index).to be_a(Meilisearch::Index) 114 | expect(index.uid).to eq('books') 115 | expect(index.primary_key).to eq('reference_code') 116 | expect(index.fetch_primary_key).to eq('reference_code') 117 | end 118 | end 119 | end 120 | 121 | context 'when an index with a given uid already exists' do 122 | it 'returns a failing task' do 123 | initial_task = client.create_index('books').await 124 | last_task = client.create_index('books').await 125 | 126 | expect(initial_task['type']).to eq('indexCreation') 127 | expect(last_task['type']).to eq('indexCreation') 128 | expect(initial_task['status']).to eq('succeeded') 129 | expect(last_task['status']).to eq('failed') 130 | expect(last_task['error']['code']).to eq('index_already_exists') 131 | end 132 | end 133 | 134 | context 'when the uid format is invalid' do 135 | it 'raises an error' do 136 | expect do 137 | client.create_index('ancient books') 138 | end.to raise_meilisearch_api_error_with(400, 'invalid_index_uid', 'invalid_request') 139 | end 140 | end 141 | end 142 | 143 | describe '#indexes' do 144 | it 'returns Meilisearch::Index objects' do 145 | client.create_index('books').await 146 | 147 | index = client.indexes['results'].first 148 | 149 | expect(index).to be_a(Meilisearch::Index) 150 | end 151 | 152 | it 'gets a list of indexes' do 153 | ['books', 'colors', 'artists'].each { |name| client.create_index(name).await } 154 | 155 | indexes = client.indexes['results'] 156 | 157 | expect(indexes).to be_a(Array) 158 | expect(indexes.length).to eq(3) 159 | uids = indexes.map(&:uid) 160 | expect(uids).to contain_exactly('books', 'colors', 'artists') 161 | end 162 | 163 | it 'paginates indexes list with limit and offset' do 164 | ['books', 'colors', 'artists'].each { |name| client.create_index(name).await } 165 | 166 | indexes = client.indexes(limit: 1, offset: 2) 167 | 168 | expect(indexes['results']).to be_a(Array) 169 | expect(indexes['total']).to eq(3) 170 | expect(indexes['limit']).to eq(1) 171 | expect(indexes['offset']).to eq(2) 172 | expect(indexes['results'].map(&:uid)).to eq(['colors']) 173 | end 174 | end 175 | 176 | describe '#raw_indexes' do 177 | it 'returns raw indexes' do 178 | client.create_index('index').await 179 | 180 | response = client.raw_indexes['results'].first 181 | 182 | expect(response).to be_a(Hash) 183 | expect(response['uid']).to eq('index') 184 | end 185 | 186 | it 'gets a list of raw indexes' do 187 | ['books', 'colors', 'artists'].each { |name| client.create_index(name).await } 188 | 189 | indexes = client.raw_indexes['results'] 190 | 191 | expect(indexes).to be_a(Array) 192 | expect(indexes.length).to eq(3) 193 | uids = indexes.map { |elem| elem['uid'] } 194 | expect(uids).to contain_exactly('books', 'colors', 'artists') 195 | end 196 | end 197 | 198 | describe '#fetch_index' do 199 | it 'fetches index by uid' do 200 | client.create_index('books', primary_key: 'reference_code').await 201 | 202 | fetched_index = client.fetch_index('books') 203 | 204 | expect(fetched_index).to be_a(Meilisearch::Index) 205 | expect(fetched_index.uid).to eq('books') 206 | expect(fetched_index.primary_key).to eq('reference_code') 207 | expect(fetched_index.fetch_primary_key).to eq('reference_code') 208 | end 209 | end 210 | 211 | describe '#fetch_raw_index' do 212 | it 'fetch a specific index raw Hash response based on uid' do 213 | client.create_index('books', primary_key: 'reference_code').await 214 | index = client.fetch_index('books') 215 | raw_response = index.fetch_raw_info 216 | 217 | expect(raw_response).to be_a(Hash) 218 | expect(raw_response['uid']).to eq('books') 219 | expect(raw_response['primaryKey']).to eq('reference_code') 220 | expect(Time.parse(raw_response['createdAt'])).to be_a(Time) 221 | expect(Time.parse(raw_response['createdAt'])).to be_within(60).of(Time.now) 222 | expect(Time.parse(raw_response['updatedAt'])).to be_a(Time) 223 | expect(Time.parse(raw_response['updatedAt'])).to be_within(60).of(Time.now) 224 | end 225 | end 226 | 227 | describe '#index' do 228 | it 'returns an index object with the provided uid' do 229 | client.create_index('books', primary_key: 'reference_code').await 230 | # this index is in memory, without metadata from server 231 | index = client.index('books') 232 | 233 | expect(index).to be_a(Meilisearch::Index) 234 | expect(index.uid).to eq('books') 235 | expect(index.primary_key).to be_nil 236 | 237 | # fetch primary key metadata from server 238 | expect(index.fetch_primary_key).to eq('reference_code') 239 | expect(index.primary_key).to eq('reference_code') 240 | end 241 | end 242 | 243 | describe '#delete_index' do 244 | context 'when the index exists' do 245 | it 'deletes the index' do 246 | client.create_index('books').await 247 | task = client.delete_index('books') 248 | 249 | expect(task['type']).to eq('indexDeletion') 250 | 251 | task.await 252 | 253 | expect(task).to be_succeeded 254 | expect { client.fetch_index('books') }.to raise_index_not_found_meilisearch_api_error 255 | end 256 | end 257 | 258 | context 'when the index does not exist' do 259 | it 'raises an index not found error' do 260 | expect { client.fetch_index('bookss') }.to raise_index_not_found_meilisearch_api_error 261 | end 262 | end 263 | end 264 | 265 | describe '#swap_indexes' do 266 | it 'swaps two indexes' do 267 | task = client.swap_indexes(['indexA', 'indexB'], ['indexC', 'indexD']) 268 | 269 | expect(task.type).to eq('indexSwap') 270 | task.await 271 | expect(task['details']['swaps']).to include( 272 | include('indexes' => ['indexA', 'indexB']), 273 | include('indexes' => ['indexC', 'indexD']) 274 | ) 275 | end 276 | end 277 | end 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Meilisearch-Ruby 3 |

4 | 5 |

Meilisearch Ruby

6 | 7 |

8 | Meilisearch | 9 | Meilisearch Cloud | 10 | Documentation | 11 | Discord | 12 | Roadmap | 13 | Website | 14 | FAQ 15 |

16 | 17 |

18 | Latest Stable Version 19 | Test 20 | 21 | 22 | 23 | License 24 |

25 | 26 |

⚡ The Meilisearch API client written for Ruby 💎

27 | 28 | **Meilisearch Ruby** is the Meilisearch API client for Ruby developers. 29 | 30 | **Meilisearch** is an open-source search engine. [Learn more about Meilisearch.](https://github.com/meilisearch/meilisearch) 31 | 32 | ## Table of Contents 33 | 34 | - [📖 Documentation](#-documentation) 35 | - [🔧 Installation](#-installation) 36 | - [🚀 Getting started](#-getting-started) 37 | - [🤖 Compatibility with Meilisearch](#-compatibility-with-meilisearch) 38 | - [💡 Learn more](#-learn-more) 39 | - [⚙️ Contributing](#️-contributing) 40 | 41 | ## 📖 Documentation 42 | 43 | This readme contains all the documentation you need to start using this Meilisearch SDK. 44 | 45 | For general information on how to use Meilisearch—such as our API reference, tutorials, guides, and in-depth articles—refer to our [main documentation website](https://www.meilisearch.com/docs/). 46 | 47 | 48 | ## 🔧 Installation 49 | 50 | We officially support any version of Ruby that is still receiving at least [security maintenance](https://www.ruby-lang.org/en/downloads/branches/). You may, however, be fine with any Ruby version above 3.0. 51 | However, we cannot guarantee support if your version is no longer being maintained. 52 | 53 | With `gem` in command line: 54 | ```bash 55 | gem install meilisearch 56 | ``` 57 | 58 | In your `Gemfile` with [bundler](https://bundler.io/): 59 | ```ruby 60 | source 'https://rubygems.org' 61 | 62 | gem 'meilisearch' 63 | ``` 64 | 65 | ### Run Meilisearch 66 | 67 | ⚡️ **Launch, scale, and streamline in minutes with Meilisearch Cloud**—no maintenance, no commitment, cancel anytime. [Try it free now](https://cloud.meilisearch.com/login?utm_campaign=oss&utm_source=github&utm_medium=meilisearch-ruby). 68 | 69 | 🪨 Prefer to self-host? [Download and deploy](https://www.meilisearch.com/docs/learn/self_hosted/getting_started_with_self_hosted_meilisearch?utm_campaign=oss&utm_source=github&utm_medium=meilisearch-ruby) our fast, open-source search engine on your own infrastructure. 70 | 71 | ## 🚀 Getting started 72 | 73 | #### Add documents 74 | 75 | ```ruby 76 | require 'meilisearch' 77 | 78 | client = Meilisearch::Client.new('http://127.0.0.1:7700', 'masterKey') 79 | 80 | # An index is where the documents are stored. 81 | index = client.index('movies') 82 | 83 | documents = [ 84 | { id: 1, title: 'Carol', genres: ['Romance', 'Drama'] }, 85 | { id: 2, title: 'Wonder Woman', genres: ['Action', 'Adventure'] }, 86 | { id: 3, title: 'Life of Pi', genres: ['Adventure', 'Drama'] }, 87 | { id: 4, title: 'Mad Max: Fury Road', genres: ['Adventure', 'Science Fiction'] }, 88 | { id: 5, title: 'Moana', genres: ['Fantasy', 'Action']}, 89 | { id: 6, title: 'Philadelphia', genres: ['Drama'] }, 90 | ] 91 | # If the index 'movies' does not exist, Meilisearch creates it when you first add the documents. 92 | index.add_documents(documents) # => { "uid": 0 } 93 | ``` 94 | 95 | With the `uid`, you can check the status (`enqueued`, `canceled`, `processing`, `succeeded` or `failed`) of your documents addition using the [task](https://www.meilisearch.com/docs/reference/api/tasks#get-tasks). 96 | 97 | 💡 To customize the `Client`, for example, increasing the default timeout, please check out [this section](https://github.com/meilisearch/meilisearch-ruby/wiki/Client-Options) of the Wiki. 98 | 99 | #### Basic Search 100 | 101 | ``` ruby 102 | # Meilisearch is typo-tolerant: 103 | puts index.search('carlo') 104 | ``` 105 | Output: 106 | 107 | ```ruby 108 | { 109 | "hits" => [{ 110 | "id" => 1, 111 | "title" => "Carol" 112 | }], 113 | "offset" => 0, 114 | "limit" => 20, 115 | "processingTimeMs" => 1, 116 | "query" => "carlo" 117 | } 118 | ``` 119 | 120 | #### Custom search 121 | 122 | All the supported options are described in the [search parameters](https://www.meilisearch.com/docs/reference/api/search#search-parameters) section of the documentation. 123 | 124 | ```ruby 125 | index.search( 126 | 'wonder', 127 | attributes_to_highlight: ['*'] 128 | ) 129 | ``` 130 | 131 | JSON output: 132 | 133 | ```json 134 | { 135 | "hits": [ 136 | { 137 | "id": 2, 138 | "title": "Wonder Woman", 139 | "_formatted": { 140 | "id": 2, 141 | "title": "Wonder Woman" 142 | } 143 | } 144 | ], 145 | "offset": 0, 146 | "limit": 20, 147 | "processingTimeMs": 0, 148 | "query": "wonder" 149 | } 150 | ``` 151 | 152 | #### Custom Search With Filters 153 | 154 | If you want to enable filtering, you must add your attributes to the `filterableAttributes` index setting. 155 | 156 | ```ruby 157 | index.update_filterable_attributes([ 158 | 'id', 159 | 'genres' 160 | ]) 161 | ``` 162 | 163 | You only need to perform this operation once. 164 | 165 | Note that Meilisearch will rebuild your index whenever you update `filterableAttributes`. Depending on the size of your dataset, this might take time. You can track the process using the [tasks](https://www.meilisearch.com/docs/reference/api/tasks#get-tasks)). 166 | 167 | Then, you can perform the search: 168 | 169 | ```ruby 170 | index.search('wonder', { filter: ['id > 1 AND genres = Action'] }) 171 | ``` 172 | 173 | JSON output: 174 | 175 | ```json 176 | { 177 | "hits": [ 178 | { 179 | "id": 2, 180 | "title": "Wonder Woman", 181 | "genres": [ 182 | "Action", 183 | "Adventure" 184 | ] 185 | } 186 | ], 187 | "estimatedTotalHits": 1, 188 | "query": "wonder", 189 | "limit": 20, 190 | "offset": 0, 191 | "processingTimeMs": 0 192 | } 193 | ``` 194 | 195 | #### Display ranking details at search 196 | 197 | JSON output: 198 | 199 | ```json 200 | { 201 | "hits": [ 202 | { 203 | "id": 15359, 204 | "title": "Wonder Woman", 205 | "_rankingScoreDetails": { 206 | "words": { 207 | "order": 0, 208 | "matchingWords": 2, 209 | "maxMatchingWords": 2, 210 | "score": 1.0 211 | }, 212 | "typo": { 213 | "order": 1, 214 | "typoCount": 0, 215 | "maxTypoCount": 2, 216 | "score": 1.0 217 | }, 218 | "proximity": { 219 | "order": 2, 220 | "score": 1.0 221 | }, 222 | "attribute": { 223 | "order": 3, 224 | "attributeRankingOrderScore": 0.8181818181818182, 225 | "queryWordDistanceScore": 1.0, 226 | "score": 0.8181818181818182 227 | }, 228 | "exactness": { 229 | "order": 4, 230 | "matchType": "exactMatch", 231 | "score": 1.0 232 | } 233 | } 234 | } 235 | ] 236 | } 237 | ``` 238 | 239 | This feature is only available with Meilisearch v1.3 and newer (optional). 240 | 241 | #### Custom Search With attributes on at search time 242 | 243 | [Customize attributes to search on at search time](https://www.meilisearch.com/docs/reference/api/search#customize-attributes-to-search-on-at-search-time). 244 | 245 | you can perform the search : 246 | 247 | ```ruby 248 | index.search('wonder', { attributes_to_search_on: ['genres'] }) 249 | ``` 250 | 251 | 252 | JSON output: 253 | 254 | ```json 255 | { 256 | "hits":[], 257 | "query":"wonder", 258 | "processingTimeMs":0, 259 | "limit":20, 260 | "offset":0, 261 | "estimatedTotalHits":0, 262 | "nbHits":0 263 | } 264 | ``` 265 | 266 | This feature is only available with Meilisearch v1.3 and newer (optional). 267 | 268 | 269 | ## 🤖 Compatibility with Meilisearch 270 | 271 | This package guarantees compatibility with [version v1.x of Meilisearch](https://github.com/meilisearch/meilisearch/releases/latest), but some features may not be present. Please check the [issues](https://github.com/meilisearch/meilisearch-ruby/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+label%3Aenhancement) for more info. 272 | 273 | ## 💡 Learn more 274 | 275 | The following sections in our main documentation website may interest you: 276 | 277 | - **Manipulate documents**: see the [API references](https://www.meilisearch.com/docs/reference/api/documents) or read more about [documents](https://www.meilisearch.com/docs/learn/core_concepts/documents). 278 | - **Search**: see the [API references](https://www.meilisearch.com/docs/reference/api/search) or follow our guide on [search parameters](https://www.meilisearch.com/docs/reference/api/search#search-parameters). 279 | - **Manage the indexes**: see the [API references](https://www.meilisearch.com/docs/reference/api/indexes) or read more about [indexes](https://www.meilisearch.com/docs/learn/core_concepts/indexes). 280 | - **Configure the index settings**: see the [API references](https://www.meilisearch.com/docs/reference/api/settings) or follow our guide on [settings parameters](https://www.meilisearch.com/docs/reference/api/settings). 281 | 282 | 📖 Also, check out the [Wiki](https://github.com/meilisearch/meilisearch-ruby/wiki) of this repository to know what this SDK provides! 283 | 284 | ## ⚙️ Contributing 285 | 286 | Any new contribution is more than welcome in this project! 287 | 288 | If you want to know more about the development workflow or want to contribute, please visit our [contributing guidelines](/CONTRIBUTING.md) for detailed instructions! 289 | 290 |
291 | 292 | **Meilisearch** provides and maintains many **SDKs and Integration tools** like this one. We want to provide everyone with an **amazing search experience for any kind of project**. If you want to contribute, make suggestions, or just know what's going on right now, visit us in the [integration-guides](https://github.com/meilisearch/integration-guides) repository. 293 | -------------------------------------------------------------------------------- /spec/meilisearch/models/task_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Meilisearch::Models::Task do 4 | subject { described_class.new task_hash, endpoint } 5 | 6 | let(:new_index_uid) { random_uid } 7 | let(:task_hash) { client.http_post '/indexes', { 'uid' => new_index_uid } } 8 | let(:endpoint) { Meilisearch::Task.new(URL, MASTER_KEY, client.options) } 9 | 10 | let(:enqueued_endpoint) { instance_double(Meilisearch::Task, task: task_hash) } 11 | let(:enqueued_task) { described_class.new task_hash, enqueued_endpoint } 12 | 13 | let(:processing_endpoint) { instance_double(Meilisearch::Task, task: task_hash.update('status' => 'processing')) } 14 | let(:processing_task) { described_class.new task_hash, processing_endpoint } 15 | 16 | let(:logger) { instance_double(Logger, warn: nil) } 17 | 18 | before { Meilisearch::Utils.logger = logger } 19 | after { Meilisearch::Utils.logger = nil } 20 | 21 | describe '.initialize' do 22 | it 'requires a uid in the task hash' do 23 | task_hash.delete 'taskUid' 24 | 25 | expect { subject }.to raise_error(ArgumentError) 26 | end 27 | 28 | it 'requires a type in the task hash' do 29 | task_hash.delete 'type' 30 | 31 | expect { subject }.to raise_error(ArgumentError) 32 | end 33 | 34 | it 'requires a status in the task hash' do 35 | task_hash.delete 'status' 36 | 37 | expect { subject }.to raise_error(ArgumentError) 38 | end 39 | 40 | it 'sets "taskUid" key when given a "uid"' do 41 | expect(subject).to have_key('uid') 42 | end 43 | 44 | it 'sets "uid" key when given a "taskUid"' do 45 | task_hash['uid'] = task_hash.delete 'taskUid' 46 | 47 | expect(subject).to have_key('taskUid') 48 | end 49 | end 50 | 51 | describe 'forwarding' do 52 | it 'allows accessing values in the internal task hash' do 53 | subject 54 | 55 | task_hash.each do |key, value| 56 | expect(subject[key]).to eq(value) 57 | end 58 | end 59 | end 60 | 61 | describe '#enqueued?' do 62 | context 'when the task is processing' do 63 | before { task_hash['status'] = 'processing' } 64 | 65 | it { is_expected.not_to be_enqueued } 66 | 67 | it 'does not refresh the task' do 68 | allow(subject).to receive(:refresh) 69 | subject.enqueued? 70 | expect(subject).not_to have_received(:refresh) 71 | end 72 | end 73 | 74 | context 'when the task has succeeded' do 75 | before { task_hash['status'] = 'succeeded' } 76 | 77 | it { is_expected.not_to be_enqueued } 78 | 79 | it 'does not refresh the task' do 80 | allow(subject).to receive(:refresh) 81 | subject.enqueued? 82 | expect(subject).not_to have_received(:refresh) 83 | end 84 | end 85 | 86 | context 'when the task has failed' do 87 | before { task_hash['status'] = 'failed' } 88 | 89 | it { is_expected.not_to be_enqueued } 90 | 91 | it 'does not refresh the task' do 92 | allow(subject).to receive(:refresh) 93 | subject.enqueued? 94 | expect(subject).not_to have_received(:refresh) 95 | end 96 | end 97 | 98 | it 'returns true when the task is enqueued' do 99 | expect(enqueued_task).to be_enqueued 100 | end 101 | 102 | context 'when the task has succeeded but not refreshed' do 103 | let(:successful_task_hash) { task_hash.merge('status' => 'succeeded') } 104 | let(:endpoint) { instance_double(Meilisearch::Task, task: successful_task_hash) } 105 | 106 | it { is_expected.not_to be_enqueued } 107 | end 108 | end 109 | 110 | describe '#processing?' do 111 | context 'when the task has succeeded' do 112 | before { task_hash['status'] = 'succeeded' } 113 | 114 | it { is_expected.not_to be_processing } 115 | 116 | it 'does not refresh the task' do 117 | allow(subject).to receive(:refresh) 118 | subject.processing? 119 | expect(subject).not_to have_received(:refresh) 120 | end 121 | end 122 | 123 | context 'when the task has failed' do 124 | before { task_hash['status'] = 'failed' } 125 | 126 | it { is_expected.not_to be_processing } 127 | 128 | it 'does not refresh the task' do 129 | allow(subject).to receive(:refresh) 130 | subject.processing? 131 | expect(subject).not_to have_received(:refresh) 132 | end 133 | end 134 | 135 | it 'returns false when the task has not begun to process' do 136 | expect(enqueued_task).not_to be_processing 137 | end 138 | 139 | it 'returns true when the task is processing' do 140 | expect(processing_task).to be_processing 141 | end 142 | 143 | context 'when the task has begun processing but has not refreshed' do 144 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash.merge('status' => 'processing')) } 145 | 146 | it { is_expected.to be_processing } 147 | end 148 | 149 | context 'when the task has succeeded but not refreshed' do 150 | let(:successful_task_hash) { task_hash.merge('status' => 'succeeded') } 151 | let(:endpoint) { instance_double(Meilisearch::Task, task: successful_task_hash) } 152 | 153 | it 'refreshes and returns false' do 154 | expect(subject).not_to be_enqueued 155 | end 156 | end 157 | end 158 | 159 | describe '#unfinished?' do 160 | it 'returns false if the task has succeeded' do 161 | task_hash['status'] = 'succeeded' 162 | expect(subject).not_to be_unfinished 163 | end 164 | 165 | it 'returns false when the task has failed' do 166 | task_hash['status'] = 'failed' 167 | expect(subject).not_to be_unfinished 168 | end 169 | 170 | it 'returns true when the task is enqueued' do 171 | expect(enqueued_task).to be_unfinished 172 | end 173 | 174 | it 'returns true when the task is processing' do 175 | expect(processing_task).to be_unfinished 176 | end 177 | 178 | context 'when the task has succeeded but not refreshed' do 179 | let(:successful_task_hash) { task_hash.merge('status' => 'succeeded') } 180 | let(:endpoint) { instance_double(Meilisearch::Task, task: successful_task_hash) } 181 | 182 | it { is_expected.not_to be_unfinished } 183 | end 184 | end 185 | 186 | describe '#finished?' do 187 | it 'returns true when the task has succeeded' do 188 | task_hash['status'] = 'succeeded' 189 | expect(subject).to be_finished 190 | end 191 | 192 | it 'returns true when the task has failed' do 193 | task_hash['status'] = 'failed' 194 | expect(subject).to be_finished 195 | end 196 | 197 | it 'returns false when the task is enqueued' do 198 | expect(enqueued_task).not_to be_finished 199 | end 200 | 201 | it 'returns false when the task is processing' do 202 | expect(processing_task).not_to be_finished 203 | end 204 | 205 | context 'when the task has succeeded but not refreshed' do 206 | let(:successful_task_hash) { task_hash.merge('status' => 'succeeded') } 207 | let(:endpoint) { instance_double(Meilisearch::Task, task: successful_task_hash) } 208 | 209 | it { is_expected.to be_finished } 210 | end 211 | end 212 | 213 | describe '#failed?' do 214 | it 'returns false if the task has succeeded or been cancelled' do 215 | task_hash['status'] = 'succeeded' 216 | expect(subject).not_to be_failed 217 | task_hash['status'] = 'cancelled' 218 | expect(subject).not_to be_failed 219 | end 220 | 221 | it 'returns true if the task has failed' do 222 | task_hash['status'] = 'failed' 223 | expect(subject).to be_failed 224 | end 225 | 226 | context 'when the task is not finished' do 227 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash) } 228 | 229 | it { is_expected.not_to be_failed } 230 | 231 | it 'warns that the task is not finished' do 232 | subject.failed? 233 | 234 | expect(logger).to have_received(:warn).with(a_string_including('checked before finishing')) 235 | end 236 | end 237 | 238 | context 'when the task has failed but not refreshed' do 239 | let(:failed_task_hash) { task_hash.merge('status' => 'failed') } 240 | let(:endpoint) { instance_double(Meilisearch::Task, task: failed_task_hash) } 241 | 242 | it { is_expected.to be_failed } 243 | end 244 | end 245 | 246 | describe '#succeeded?' do 247 | it 'returns true if the task has succeeded' do 248 | task_hash['status'] = 'succeeded' 249 | expect(subject).to be_succeeded 250 | end 251 | 252 | it 'returns false if the task has failed or been cancelled' do 253 | task_hash['status'] = 'failed' 254 | expect(subject).not_to be_succeeded 255 | task_hash['status'] = 'cancelled' 256 | expect(subject).not_to be_succeeded 257 | end 258 | 259 | context 'when the task is not finished' do 260 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash) } 261 | 262 | it { is_expected.not_to be_succeeded } 263 | 264 | it 'warns that the task is not finished' do 265 | subject.succeeded? 266 | 267 | expect(logger).to have_received(:warn).with(a_string_including('checked before finishing')) 268 | end 269 | end 270 | 271 | context 'when the task has succeeded but not refreshed' do 272 | let(:successful_task_hash) { task_hash.merge('status' => 'succeeded') } 273 | let(:endpoint) { instance_double(Meilisearch::Task, task: successful_task_hash) } 274 | 275 | it { is_expected.to be_succeeded } 276 | end 277 | end 278 | 279 | describe '#cancelled?' do 280 | it 'returns false if the task has succeeded or failed' do 281 | task_hash['status'] = 'succeeded' 282 | expect(subject).not_to be_cancelled 283 | task_hash['status'] = 'failed' 284 | expect(subject).not_to be_cancelled 285 | end 286 | 287 | it 'returns true if the task has been cancelled' do 288 | task_hash['status'] = 'cancelled' 289 | expect(subject).to be_cancelled 290 | end 291 | 292 | context 'when the task is not finished' do 293 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash) } 294 | 295 | it { is_expected.not_to be_cancelled } 296 | 297 | it 'warns that the task is not finished' do 298 | subject.cancelled? 299 | 300 | expect(logger).to have_received(:warn).with(a_string_including('checked before finishing')) 301 | end 302 | end 303 | 304 | context 'when the task has been cancelled but not refreshed' do 305 | let(:cancelled_task_hash) { task_hash.merge('status' => 'cancelled') } 306 | let(:endpoint) { instance_double(Meilisearch::Task, task: cancelled_task_hash) } 307 | 308 | it { is_expected.to be_cancelled } 309 | end 310 | end 311 | 312 | describe '#deleted?' do 313 | let(:not_found_error) { Meilisearch::ApiError.new(404, '', '') } 314 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash) } 315 | 316 | it 'returns false when the task can be found' do 317 | expect(subject.deleted?).to be(false) # don't just return nil 318 | expect(subject).not_to be_deleted 319 | end 320 | 321 | context 'when it was deleted prior' do 322 | let(:endpoint) { instance_double(Meilisearch::Task) } 323 | 324 | before do 325 | allow(endpoint).to receive(:task) { raise not_found_error } 326 | subject.refresh 327 | end 328 | 329 | it 'does not check again' do 330 | subject.deleted? 331 | expect(endpoint).to have_received(:task).once 332 | end 333 | 334 | it { is_expected.to be_deleted } 335 | end 336 | 337 | it 'refreshes and returns true when it is no longer in instance' do 338 | allow(endpoint).to receive(:task) { raise not_found_error } 339 | expect(subject).to be_deleted 340 | end 341 | end 342 | 343 | describe '#cancel' do 344 | context 'when the task is still not finished' do 345 | let(:cancellation_task) { instance_double(described_class, await: nil) } 346 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash, cancel_tasks: cancellation_task) } 347 | 348 | it 'sends a request to cancel itself' do 349 | subject.cancel 350 | expect(endpoint).to have_received(:cancel_tasks) 351 | end 352 | 353 | it 'returns true when the cancellation succeeds' do 354 | task_hash['status'] = 'cancelled' 355 | expect(subject.cancel).to be(true) 356 | end 357 | 358 | it 'returns false when the cancellation fails' do 359 | task_hash['status'] = 'succeeded' 360 | expect(subject.cancel).to be(false) 361 | end 362 | end 363 | 364 | context 'when the task is already finished' do 365 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash, cancel_tasks: nil) } 366 | 367 | before { task_hash['status'] = 'succeeded' } 368 | 369 | it 'sends no request' do 370 | subject.cancel 371 | expect(endpoint).not_to have_received(:cancel_tasks) 372 | end 373 | 374 | it { is_expected.not_to be_cancelled } 375 | end 376 | 377 | context 'when the task is already cancelled' do 378 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash, cancel_tasks: nil) } 379 | 380 | before { task_hash['status'] = 'cancelled' } 381 | 382 | it 'sends no request' do 383 | subject.cancel 384 | expect(endpoint).not_to have_received(:cancel_tasks) 385 | end 386 | 387 | it { is_expected.to be_cancelled } 388 | end 389 | end 390 | 391 | describe '#delete' do 392 | let(:deletion_task) { instance_double(described_class, await: nil) } 393 | let(:endpoint) { instance_double(Meilisearch::Task, delete_tasks: deletion_task) } 394 | 395 | context 'when the task is unfinished' do 396 | it 'makes no request' do 397 | subject.delete 398 | expect(endpoint).not_to have_received(:delete_tasks) 399 | end 400 | 401 | it 'returns false' do 402 | expect(subject.delete).to be(false) 403 | end 404 | end 405 | 406 | context 'when the task is finished' do 407 | before do 408 | task_hash['status'] = 'failed' 409 | not_found_error = Meilisearch::ApiError.new(404, '', '') 410 | allow(endpoint).to receive(:task) { raise not_found_error } 411 | end 412 | 413 | it 'makes a deletion request' do 414 | subject.delete 415 | expect(endpoint).to have_received(:delete_tasks) 416 | end 417 | 418 | it 'returns true' do 419 | expect(subject.delete).to be(true) 420 | end 421 | end 422 | end 423 | 424 | describe '#refresh' do 425 | let(:changed_task) { task_hash.merge('status' => 'succeeded', 'error' => 'Done too well') } 426 | let(:endpoint) { instance_double(Meilisearch::Task, task: changed_task) } 427 | 428 | it 'calls endpoint to update task' do 429 | expect { subject.refresh }.to change { subject['status'] }.from('enqueued').to('succeeded') 430 | .and(change { subject['error'] }.from(nil).to('Done too well')) 431 | end 432 | end 433 | 434 | describe '#await' do 435 | let(:changed_task) { task_hash.merge('status' => 'succeeded', 'error' => 'Done too well') } 436 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash, wait_for_task: changed_task) } 437 | 438 | context 'when the task is not yet completed' do 439 | let(:endpoint) { instance_double(Meilisearch::Task, task: task_hash, wait_for_task: changed_task) } 440 | 441 | it 'waits for the task to complete' do 442 | expect { subject.await }.to change { subject['status'] }.from('enqueued').to('succeeded') 443 | .and(change { subject['error'] }.from(nil).to('Done too well')) 444 | end 445 | 446 | it 'returns itself for method chaining' do 447 | expect(subject.await).to be(subject) 448 | end 449 | end 450 | 451 | context 'when the task is already completed' do 452 | let(:endpoint) { instance_double(Meilisearch::Task, task: changed_task, wait_for_task: changed_task) } 453 | 454 | it 'does not contact the instance' do 455 | subject.refresh 456 | subject.await 457 | 458 | expect(endpoint).to have_received(:task).once 459 | expect(endpoint).not_to have_received(:wait_for_task) 460 | end 461 | end 462 | end 463 | 464 | describe '#error' do 465 | let(:error) do 466 | { 'message' => "Index `#{new_index_uid}` already exists.", 467 | 'code' => 'index_already_exists', 468 | 'type' => 'invalid_request', 469 | 'link' => 'https://docs.meilisearch.com/errors#index_already_exists' } 470 | end 471 | 472 | before { task_hash.merge!('error' => error, 'status' => 'failed') } 473 | 474 | it 'returns errors' do 475 | expect(subject.error).to match(error) 476 | end 477 | end 478 | 479 | describe '#to_h' do 480 | it 'returns the underlying task hash' do 481 | expect(subject.to_h).to be(task_hash) 482 | end 483 | 484 | it 'is aliased as #to_hash' do 485 | expect(subject.to_hash).to be(subject.to_h) 486 | end 487 | end 488 | end 489 | -------------------------------------------------------------------------------- /lib/meilisearch/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Meilisearch 4 | # Manages a connection to a Meilisearch server. 5 | # client = Meilisearch::Client.new(MEILISEARCH_URL, MASTER_KEY, options) 6 | # 7 | # @see #indexes Managing search indexes 8 | # @see #keys Managing API keys 9 | # @see #stats View usage statistics 10 | # @see #tasks Managing ongoing tasks 11 | # @see #health Health checking 12 | # @see #create_dump 13 | # @see #create_snapshot 14 | class Client < HTTPRequest 15 | include Meilisearch::TenantToken 16 | include Meilisearch::MultiSearch 17 | 18 | ### INDEXES 19 | 20 | # Fetch indexes in instance, returning the raw server response. 21 | # 22 | # Unless you have a good reason to, {#indexes} should be used instead. 23 | # 24 | # @see #indexes 25 | # @see https://www.meilisearch.com/docs/reference/api/indexes#list-all-indexes Meilisearch API reference 26 | # @param options [Hash{Symbol => Object}] limit and offset options 27 | # @return [Hash{String => Object}] 28 | # {index response object}[https://www.meilisearch.com/docs/reference/api/indexes#response] 29 | def raw_indexes(options = {}) 30 | body = Utils.transform_attributes(options.transform_keys(&:to_sym).slice(:limit, :offset)) 31 | 32 | http_get('/indexes', body) 33 | end 34 | 35 | # Swap two indexes. 36 | # 37 | # Can be used as a convenient way to rebuild an index while keeping it operational. 38 | # client.index('a_swap').add_documents({}) 39 | # client.swap_indexes(['a', 'a_swap']) 40 | # 41 | # Multiple swaps may be done with one request: 42 | # client.swap_indexes(['a', 'a_swap'], ['b', 'b_swap']) 43 | # 44 | # @see https://www.meilisearch.com/docs/reference/api/indexes#swap-indexes Meilisearch API reference 45 | # 46 | # @param options [Array] the indexes to swap 47 | # @return [Models::Task] the async task that swaps the indexes 48 | # @raise [ApiError] 49 | def swap_indexes(*options) 50 | mapped_array = options.map { |arr| { indexes: arr } } 51 | 52 | response = http_post '/swap-indexes', mapped_array 53 | Models::Task.new(response, task_endpoint) 54 | end 55 | 56 | # Fetch indexes in instance. 57 | # 58 | # @see https://www.meilisearch.com/docs/reference/api/indexes#list-all-indexes Meilisearch API reference 59 | # @param options [Hash{Symbol => Object}] limit and offset options 60 | # @return [Hash{String => Object}] 61 | # {index response object}[https://www.meilisearch.com/docs/reference/api/indexes#response] 62 | # with results mapped to instances of {Index} 63 | def indexes(options = {}) 64 | response = raw_indexes(options) 65 | 66 | response['results'].map! do |index_hash| 67 | index_object(index_hash['uid'], index_hash['primaryKey']) 68 | end 69 | 70 | response 71 | end 72 | 73 | # Create a new empty index. 74 | # 75 | # client.create_index('indexUID') 76 | # client.create_index('indexUID', primary_key: 'id') 77 | # 78 | # Indexes are also created when accessed: 79 | # 80 | # client.index('new_index').add_documents({}) 81 | # 82 | # @see #index 83 | # @see https://www.meilisearch.com/docs/reference/api/indexes#create-an-index Meilisearch API reference 84 | # 85 | # @param index_uid [String] the uid of the new index 86 | # @param options [Hash{Symbol => Object}, nil] snake_cased options of {the endpoint}[https://www.meilisearch.com/docs/reference/api/indexes#create-an-index] 87 | # 88 | # @raise [ApiError] 89 | # @return [Models::Task] the async task that creates the index 90 | def create_index(index_uid, options = {}) 91 | body = Utils.transform_attributes(options.merge(uid: index_uid)) 92 | 93 | response = http_post '/indexes', body 94 | 95 | Models::Task.new(response, task_endpoint) 96 | end 97 | 98 | # Synchronous version of {#create_index}. 99 | # 100 | # @deprecated 101 | # use {Models::Task#await} on task returned from {#create_index} 102 | # 103 | # client.create_index('foo').await 104 | # 105 | # Waits for the task to be achieved with a busy loop, be careful when using it. 106 | def create_index!(index_uid, options = {}) 107 | Utils.soft_deprecate( 108 | 'Client#create_index!', 109 | "client.create_index('#{index_uid}').await" 110 | ) 111 | 112 | create_index(index_uid, options).await 113 | end 114 | 115 | # Delete an index. 116 | # 117 | # @param index_uid [String] the uid of the index to be deleted 118 | # @return [Models::Task] the async task deleting the index 119 | def delete_index(index_uid) 120 | index_object(index_uid).delete 121 | end 122 | 123 | # Get index with given uid. 124 | # 125 | # Indexes that don't exist are lazily created by Meilisearch. 126 | # index = client.index('index_uid') 127 | # index.add_documents({}) # index is created here if it did not exist 128 | # 129 | # @see Index 130 | # @param index_uid [String] the uid of the index to get 131 | # @return [Index] 132 | def index(index_uid) 133 | index_object(index_uid) 134 | end 135 | 136 | # Shorthand for 137 | # client.index(index_uid).fetch_info 138 | # 139 | # @see Index#fetch_info 140 | # @param index_uid [String] uid of the index 141 | def fetch_index(index_uid) 142 | index_object(index_uid).fetch_info 143 | end 144 | 145 | # Shorthand for 146 | # client.index(index_uid).fetch_raw_info 147 | # 148 | # @see Index#fetch_raw_info 149 | # @param index_uid [String] uid of the index 150 | def fetch_raw_index(index_uid) 151 | index_object(index_uid).fetch_raw_info 152 | end 153 | 154 | ### KEYS 155 | 156 | # Get all API keys 157 | # 158 | # This and other key methods require that the Meilisearch instance have a 159 | # {master key}[https://www.meilisearch.com/docs/learn/security/differences_master_api_keys#master-key] 160 | # set. 161 | # 162 | # @see #create_key #create_key to create keys and set their scope 163 | # @see #key #key to fetch one key 164 | # @see https://www.meilisearch.com/docs/reference/api/keys#get-all-keys Meilisearch API reference 165 | # @param limit [String, Integer, nil] limit the number of returned keys 166 | # @param offset [String, Integer, nil] skip the first +offset+ keys, 167 | # useful for paging. 168 | # 169 | # @return [Hash{String => Object}] a {keys response}[https://www.meilisearch.com/docs/reference/api/keys#response] 170 | def keys(limit: nil, offset: nil) 171 | body = { limit: limit, offset: offset }.compact 172 | 173 | http_get '/keys', body 174 | end 175 | 176 | # Get a specific API key. 177 | # 178 | # # obviously this example uid will not correspond to a key on your server 179 | # # please replace it with your own key's uid 180 | # uid = '6062abda-a5aa-4414-ac91-ecd7944c0f8d' 181 | # client.key(uid) 182 | # 183 | # This and other key methods require that the Meilisearch instance have a 184 | # {master key}[https://www.meilisearch.com/docs/learn/security/differences_master_api_keys#master-key] 185 | # set. 186 | # 187 | # @see #keys #keys to get all keys in the instance 188 | # @see #create_key #create_key to create keys and set their scope 189 | # @see https://www.meilisearch.com/docs/reference/api/keys#get-one-key Meilisearch API reference 190 | # @param uid_or_key [String] either the uuidv4 that is the key's 191 | # {uid}[https://www.meilisearch.com/docs/reference/api/keys#uid] or 192 | # a hash of the uid and the master key that is the key's 193 | # {key}[https://www.meilisearch.com/docs/reference/api/keys#key] field 194 | # 195 | # @return [Hash{String => Object}] a {key object}[https://www.meilisearch.com/docs/reference/api/keys#key-object] 196 | def key(uid_or_key) 197 | http_get "/keys/#{uid_or_key}" 198 | end 199 | 200 | # Create a new API key. 201 | # 202 | # require 'date_core' 203 | # ten_days_later = (DateTime.now + 10).rfc3339 204 | # client.create_key(actions: ['*'], indexes: ['*'], expires_at: ten_days_later) 205 | # 206 | # This and other key methods require that the Meilisearch instance have a 207 | # {master key}[https://www.meilisearch.com/docs/learn/security/differences_master_api_keys#master-key] 208 | # set. 209 | # 210 | # @see #update_key #update_key to edit an existing key 211 | # @see #keys #keys to get all keys in the instance 212 | # @see #key #key to fetch one key 213 | # @see https://www.meilisearch.com/docs/reference/api/keys#create-a-key Meilisearch API reference 214 | # @param key_options [Hash{Symbol => Object}] the key options of which the required are 215 | # - +:actions+ +Array+ of API actions allowed for key, +["*"]+ for all 216 | # - +:indexes+ +Array+ of indexes key can act on, +["*"]+ for all 217 | # - +:expires_at+ expiration datetime in 218 | # {RFC 3339}[https://www.ietf.org/rfc/rfc3339.txt] format, nil if the key never expires 219 | # 220 | # @return [Hash{String => Object}] a {key object}[https://www.meilisearch.com/docs/reference/api/keys#key-object] 221 | def create_key(key_options) 222 | body = Utils.transform_attributes(key_options) 223 | 224 | http_post '/keys', body 225 | end 226 | 227 | # Update an existing API key. 228 | # 229 | # This and other key methods require that the Meilisearch instance have a 230 | # {master key}[https://www.meilisearch.com/docs/learn/security/differences_master_api_keys#master-key] 231 | # set. 232 | # 233 | # @see #create_key #create_key to create a new key 234 | # @see #keys #keys to get all keys in the instance 235 | # @see #key #key to fetch one key 236 | # @see https://www.meilisearch.com/docs/reference/api/keys#update-a-key Meilisearch API reference 237 | # @param key_options [Hash{Symbol => Object}] see {#create_key} 238 | # 239 | # @return [Hash{String => Object}] a {key object}[https://www.meilisearch.com/docs/reference/api/keys#key-object] 240 | def update_key(uid_or_key, key_options) 241 | body = Utils.transform_attributes(key_options) 242 | body = body.slice('description', 'name') 243 | 244 | http_patch "/keys/#{uid_or_key}", body 245 | end 246 | 247 | # Delete an API key. 248 | # 249 | # # obviously this example uid will not correspond to a key on your server 250 | # # please replace it with your own key's uid 251 | # uid = '6062abda-a5aa-4414-ac91-ecd7944c0f8d' 252 | # client.delete_key(uid) 253 | # 254 | # This and other key methods require that the Meilisearch instance have a 255 | # {master key}[https://www.meilisearch.com/docs/learn/security/differences_master_api_keys#master-key] 256 | # set. 257 | # 258 | # @see #keys #keys to get all keys in the instance 259 | # @see #create_key #create_key to create keys and set their scope 260 | # @see https://www.meilisearch.com/docs/reference/api/keys#delete-a-key Meilisearch API reference 261 | # @param uid_or_key [String] either the uuidv4 that is the key's 262 | # {uid}[https://www.meilisearch.com/docs/reference/api/keys#uid] or 263 | # a hash of the uid and the master key that is the key's 264 | # {key}[https://www.meilisearch.com/docs/reference/api/keys#key] field 265 | def delete_key(uid_or_key) 266 | http_delete "/keys/#{uid_or_key}" 267 | end 268 | 269 | ### HEALTH 270 | 271 | # Check if Meilisearch instance is healthy. 272 | # 273 | # @see #health 274 | # @return [bool] whether or not the +/health+ endpoint raises any errors 275 | def healthy? 276 | http_get '/health' 277 | true 278 | rescue StandardError 279 | false 280 | end 281 | 282 | # Check health of Meilisearch instance. 283 | # 284 | # @see https://www.meilisearch.com/docs/reference/api/health#get-health Meilisearch API reference 285 | # @return [Hash{String => Object}] the health report from the Meilisearch instance 286 | def health 287 | http_get '/health' 288 | end 289 | 290 | ### STATS 291 | 292 | # Check version of Meilisearch server 293 | # 294 | # @see https://www.meilisearch.com/docs/reference/api/version#get-version-of-meilisearch Meilisearch API reference 295 | # @return [Hash{String => String}] package version and last commit of Meilisearch server, see 296 | # {version object}[https://www.meilisearch.com/docs/reference/api/version#version-object] 297 | def version 298 | http_get '/version' 299 | end 300 | 301 | # Get stats of all indexes in instance. 302 | # 303 | # @see Index#stats 304 | # @see https://www.meilisearch.com/docs/reference/api/stats#get-stats-of-all-indexes Meilisearch API reference 305 | # @return [Hash{String => Object}] see {stats object}[https://www.meilisearch.com/docs/reference/api/stats#stats-object] 306 | def stats 307 | http_get '/stats' 308 | end 309 | 310 | ### DUMPS 311 | 312 | # Create a database dump. 313 | # 314 | # Dumps are "blueprints" which can be used to restore your database. Restoring 315 | # a dump requires reindexing all documents and is therefore inefficient. 316 | # 317 | # Dumps are created by the Meilisearch server in the directory where the server is started 318 | # under +dumps/+ by default. 319 | # 320 | # @see https://www.meilisearch.com/docs/learn/advanced/snapshots_vs_dumps 321 | # The difference between snapshots and dumps 322 | # @see https://www.meilisearch.com/docs/learn/advanced/dumps 323 | # Meilisearch documentation on how to use dumps 324 | # @see https://www.meilisearch.com/docs/reference/api/dump#create-a-dump 325 | # Meilisearch API reference 326 | # @return [Models::Task] the async task that is creating the dump 327 | def create_dump 328 | response = http_post '/dumps' 329 | Models::Task.new(response, task_endpoint) 330 | end 331 | 332 | ### SNAPSHOTS 333 | 334 | # Create a database snapshot. 335 | # 336 | # Snapshots are exact copies of the Meilisearch database. As such they are pre-indexed 337 | # and restoring one is a very efficient operation. 338 | # 339 | # Snapshots are not compatible between Meilisearch versions. Snapshot creation takes priority 340 | # over other tasks. 341 | # 342 | # Snapshots are created by the Meilisearch server in the directory where the server is started 343 | # under +snapshots/+ by default. 344 | # 345 | # @see https://www.meilisearch.com/docs/learn/advanced/snapshots_vs_dumps 346 | # The difference between snapshots and dumps 347 | # @see https://www.meilisearch.com/docs/learn/advanced/snapshots 348 | # Meilisearch documentation on how to use snapshots 349 | # @see https://www.meilisearch.com/docs/reference/api/snapshots#create-a-snapshot 350 | # Meilisearch API reference 351 | # @return [Models::Task] the async task that is creating the snapshot 352 | def create_snapshot 353 | http_post '/snapshots' 354 | end 355 | 356 | ### TASKS 357 | 358 | # Cancel tasks matching the filter. 359 | # 360 | # This route is meant to be used with options, please see the API reference. 361 | # 362 | # Operations in Meilisearch are done asynchronously using "tasks". 363 | # Tasks report their progress and status. 364 | # 365 | # Warning: This does not return instances of {Models::Task}. This is a raw 366 | # call to the Meilisearch API and the return is not modified. 367 | # 368 | # @see https://www.meilisearch.com/docs/reference/api/tasks#task-object The Task Object 369 | # @see https://www.meilisearch.com/docs/reference/api/tasks#cancel-tasks Meilisearch API reference 370 | # @param options [Hash{Symbol => Object}] task search options as snake cased symbols, see the API reference 371 | # @return [Hash{String => Object}] a Meilisearch task that is canceling other tasks 372 | def cancel_tasks(options = {}) 373 | task_endpoint.cancel_tasks(options) 374 | end 375 | 376 | # Cancel tasks matching the filter. 377 | # 378 | # This route is meant to be used with options, please see the API reference. 379 | # 380 | # Operations in Meilisearch are done asynchronously using "tasks". 381 | # Tasks report their progress and status. 382 | # 383 | # Warning: This does not return instances of {Models::Task}. This is a raw 384 | # call to the Meilisearch API and the return is not modified. 385 | # 386 | # Tasks are run in batches, see {#batches}. 387 | # 388 | # @see https://www.meilisearch.com/docs/reference/api/tasks#task-object The Task Object 389 | # @see https://www.meilisearch.com/docs/reference/api/tasks#cancel-tasks Meilisearch API reference 390 | # @param options [Hash{Symbol => Object}] task search options as snake cased symbols, see the API reference 391 | # @return [Hash{String => Object}] a Meilisearch task that is canceling other tasks 392 | def delete_tasks(options = {}) 393 | task_endpoint.delete_tasks(options) 394 | end 395 | 396 | # Get Meilisearch tasks matching the filters. 397 | # 398 | # Operations in Meilisearch are done asynchronously using "tasks". 399 | # Tasks report their progress and status. 400 | # 401 | # Warning: This does not return instances of {Models::Task}. This is a raw 402 | # call to the Meilisearch API and the return is not modified. 403 | # 404 | # @see https://www.meilisearch.com/docs/reference/api/tasks#task-object The Task Object 405 | # @see https://www.meilisearch.com/docs/reference/api/tasks#get-tasks Meilisearch API reference 406 | # @param options [Hash{Symbol => Object}] task search options as snake cased symbols, see the API reference 407 | # @return [Hash{String => Object}] results of the task search, see API reference 408 | def tasks(options = {}) 409 | task_endpoint.task_list(options) 410 | end 411 | 412 | # Get one task. 413 | # 414 | # Operations in Meilisearch are done asynchronously using "tasks". 415 | # Tasks report their progress and status. 416 | # 417 | # Warning: This does not return instances of {Models::Task}. This is a raw 418 | # call to the Meilisearch API and the return is not modified. 419 | # 420 | # @see https://www.meilisearch.com/docs/reference/api/tasks#task-object The Task Object 421 | # @see https://www.meilisearch.com/docs/reference/api/tasks#get-one-task Meilisearch API reference 422 | # @param task_uid [String] uid of the requested task 423 | # @return [Hash{String => Object}] a Meilisearch task object (see above) 424 | def task(task_uid) 425 | task_endpoint.task(task_uid) 426 | end 427 | 428 | # Wait for a task in a busy loop. 429 | # 430 | # Try to avoid using it. Wrapper around {Task#wait_for_task}. 431 | # @see Task#wait_for_task 432 | def wait_for_task( 433 | task_uid, 434 | timeout_in_ms = Models::Task.default_timeout_ms, 435 | interval_in_ms = Models::Task.default_interval_ms 436 | ) 437 | task_endpoint.wait_for_task(task_uid, timeout_in_ms, interval_in_ms) 438 | end 439 | 440 | ### BATCHES 441 | 442 | # Get Meilisearch task batches matching the filters. 443 | # 444 | # Operations in Meilisearch are done asynchronously using "tasks". 445 | # Tasks are run in batches. 446 | # 447 | # @see https://www.meilisearch.com/docs/reference/api/batches#batch-object The Batch Object 448 | # @see https://www.meilisearch.com/docs/reference/api/batches#get-batches Meilisearch API reference 449 | # @param options [Hash{Symbol => Object}] task search options as snake cased symbols, see the API reference 450 | # @return [Hash{String => Object}] results of the batches search, see API reference 451 | def batches(options = {}) 452 | http_get '/batches', options 453 | end 454 | 455 | # Get a single Meilisearch task batch matching +batch_uid+. 456 | # 457 | # Operations in Meilisearch are done asynchronously using "tasks". 458 | # Tasks are run in batches. 459 | # 460 | # @see https://www.meilisearch.com/docs/reference/api/batches#batch-object The Batch Object 461 | # @see https://www.meilisearch.com/docs/reference/api/batches#get-one-batch Meilisearch API reference 462 | # @param batch_uid [String] the uid of the request batch 463 | # @return [Hash{String => Object}] a batch object, see above 464 | def batch(batch_uid) 465 | http_get "/batches/#{batch_uid}" 466 | end 467 | 468 | ### EXPERIMENTAL FEATURES 469 | 470 | def experimental_features 471 | http_get '/experimental-features' 472 | end 473 | 474 | def update_experimental_features(expe_feat_changes) 475 | expe_feat_changes = Utils.transform_attributes(expe_feat_changes) 476 | http_patch '/experimental-features', expe_feat_changes 477 | end 478 | 479 | private 480 | 481 | def index_object(uid, primary_key = nil) 482 | Index.new(uid, @base_url, @api_key, primary_key, @options) 483 | end 484 | 485 | def task_endpoint 486 | @task_endpoint ||= Task.new(@base_url, @api_key, @options) 487 | end 488 | end 489 | end 490 | --------------------------------------------------------------------------------