├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── codecov.yml ├── examples ├── aliases.rb ├── client_initialization.rb ├── collections_and_documents.rb ├── keys.rb ├── overrides.rb ├── search.rb └── synonyms.rb ├── lib ├── typesense.rb └── typesense │ ├── alias.rb │ ├── aliases.rb │ ├── analytics.rb │ ├── analytics_rule.rb │ ├── analytics_rules.rb │ ├── api_call.rb │ ├── client.rb │ ├── collection.rb │ ├── collections.rb │ ├── configuration.rb │ ├── debug.rb │ ├── document.rb │ ├── documents.rb │ ├── error.rb │ ├── health.rb │ ├── key.rb │ ├── keys.rb │ ├── metrics.rb │ ├── multi_search.rb │ ├── operations.rb │ ├── override.rb │ ├── overrides.rb │ ├── preset.rb │ ├── presets.rb │ ├── stats.rb │ ├── stemming.rb │ ├── stemming_dictionaries.rb │ ├── stemming_dictionary.rb │ ├── synonym.rb │ ├── synonyms.rb │ └── version.rb ├── spec ├── spec_helper.rb ├── typesense │ ├── alias_spec.rb │ ├── aliases_spec.rb │ ├── analytics_rule_spec.rb │ ├── analytics_rules_spec.rb │ ├── api_call_spec.rb │ ├── client_spec.rb │ ├── collection_spec.rb │ ├── collections_spec.rb │ ├── configuration_spec.rb │ ├── debug_spec.rb │ ├── document_spec.rb │ ├── documents_spec.rb │ ├── health_spec.rb │ ├── key_spec.rb │ ├── keys_spec.rb │ ├── metrics_spec.rb │ ├── multi_search_spec.rb │ ├── operations_spec.rb │ ├── override_spec.rb │ ├── overrides_spec.rb │ ├── preset_spec.rb │ ├── presets_spec.rb │ ├── shared_configuration_context.rb │ ├── stats_spec.rb │ ├── stemming_spec.rb │ ├── synonym_spec.rb │ └── synonyms_spec.rb └── typesense_spec.rb └── typesense.gemspec /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | build_and_test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | ruby-version: ['3.0', '3.2', '3.3'] 14 | services: 15 | typesense: 16 | image: typesense/typesense:28.0 17 | ports: 18 | - 8108:8108 19 | volumes: 20 | - /tmp/typesense-data:/data 21 | - /tmp/typesense-analytics:/analytics 22 | env: 23 | TYPESENSE_API_KEY: xyz 24 | TYPESENSE_DATA_DIR: /data 25 | TYPESENSE_ENABLE_CORS: true 26 | TYPESENSE_ANALYTICS_DIR: /analytics 27 | TYPESENSE_ENABLE_SEARCH_ANALYTICS: true 28 | 29 | steps: 30 | - name: Wait for Typesense 31 | run: | 32 | timeout 20 bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' localhost:8108/health)" != "200" ]]; do sleep 1; done' || false 33 | - uses: actions/checkout@v3 34 | - uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby-version }} 37 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 38 | - run: bundle exec rubocop 39 | - run: bundle exec rspec --format documentation 40 | - uses: actions/upload-artifact@v4 41 | with: 42 | name: coverage-ruby-${{ matrix.ruby-version }} 43 | path: coverage/ 44 | retention-days: 1 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | Gemfile.lock 11 | .ruby-version 12 | .ruby-gemset 13 | 14 | typesense-server-peers 15 | 16 | # rspec failure tracking 17 | .rspec_status 18 | typesense-data 19 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --order rand 4 | --require spec_helper 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: rubocop-rspec 2 | 3 | AllCops: 4 | NewCops: enable 5 | TargetRubyVersion: 2.7 6 | 7 | Style/Documentation: 8 | Enabled: false 9 | 10 | Metrics/AbcSize: 11 | Enabled: false 12 | 13 | Metrics/CyclomaticComplexity: 14 | Enabled: false 15 | 16 | Metrics/PerceivedComplexity: 17 | Enabled: false 18 | 19 | Metrics/ClassLength: 20 | Enabled: false 21 | 22 | Metrics/ParameterLists: 23 | Enabled: false 24 | 25 | Metrics/MethodLength: 26 | Enabled: false 27 | 28 | Metrics/BlockLength: 29 | Enabled: false 30 | 31 | Metrics/BlockNesting: 32 | Enabled: false 33 | 34 | Style/FrozenStringLiteralComment: 35 | EnforcedStyle: always_true 36 | 37 | Layout/LineLength: 38 | Max: 300 39 | 40 | Lint/SuppressedException: 41 | Exclude: 42 | - examples/** 43 | 44 | RSpec/ExampleLength: 45 | Enabled: false 46 | 47 | RSpec/MultipleExpectations: 48 | Enabled: false 49 | 50 | Style/HashSyntax: 51 | Enabled: false # We still want to support older versions of Ruby 52 | -------------------------------------------------------------------------------- /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 | # Dev dependencies 8 | gem 'awesome_print', '~> 1.8' 9 | gem 'bundler', '~> 2.0' 10 | gem 'codecov', '~> 0.1' 11 | gem 'erb' 12 | gem 'guard', '~> 2.16' 13 | gem 'guard-rubocop', '~> 1.3' 14 | gem 'rake', '~> 13.0' 15 | gem 'rspec', '~> 3.9' 16 | gem 'rspec_junit_formatter', '~> 0.4' 17 | gem 'rspec-legacy_formatters', '~> 1.0' # For codecov formatter 18 | gem 'rubocop', '~> 1.12' 19 | gem 'rubocop-rspec', '~> 3.6', require: false 20 | gem 'simplecov', '~> 0.18' 21 | gem 'timecop', '~> 0.9' 22 | gem 'webmock', '~> 3.8' 23 | 24 | # Specify your gem's dependencies in typesense.gemspec 25 | gemspec 26 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A sample Guardfile 4 | # More info at https://github.com/guard/guard#readme 5 | 6 | ## Uncomment and set this to only include directories you want to watch 7 | # directories %w(app lib config test spec features) \ 8 | # .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")} 9 | 10 | ## Note: if you are using the `directories` clause above and you are not 11 | ## watching the project directory ('.'), then you will want to move 12 | ## the Guardfile to a watched dir and symlink it back, e.g. 13 | # 14 | # $ mkdir config 15 | # $ mv Guardfile config/ 16 | # $ ln -s config/Guardfile . 17 | # 18 | # and, you'll have to watch "config/Guardfile" instead of "Guardfile" 19 | 20 | guard :rubocop, cli: '-a' do 21 | watch(/.+\.rb$/) 22 | watch(%r{(?:.+/)?\.rubocop(?:_todo)?\.yml$}) { |m| File.dirname(m[0]) } 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typesense Ruby Library [![Gem Version](https://badge.fury.io/rb/typesense.svg)](https://badge.fury.io/rb/typesense) 2 | 3 | 4 | Ruby client library for accessing the [Typesense HTTP API](https://github.com/typesense/typesense). 5 | 6 | Follows the API spec [here](https://github.com/typesense/typesense-api-spec). 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | ```ruby 13 | gem 'typesense' 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install typesense 23 | 24 | ## Usage 25 | 26 | You'll find detailed documentation here: [https://typesense.org/api/](https://typesense.org/api/) 27 | 28 | Here are some examples with inline comments that walk you through how to use the Ruby client: [examples](examples) 29 | 30 | Tests are also a good place to know how the the library works internally: [spec](spec) 31 | 32 | ## Compatibility 33 | 34 | | Typesense Server | typesense-ruby | 35 | |------------------|----------------| 36 | | \>= v28.0 | \>= v3.0.0 | 37 | | \>= v0.25.0 | \>= v1.0.0 | 38 | | \>= v0.23.0 | \>= v0.14.0 | 39 | | \>= v0.21.0 | \>= v0.13.0 | 40 | | \>= v0.20.0 | \>= v0.12.0 | 41 | | \>= v0.19.0 | \>= v0.11.0 | 42 | | \>= v0.18.0 | \>= v0.10.0 | 43 | | \>= v0.17.0 | \>= v0.9.0 | 44 | | \>= v0.16.0 | \>= v0.8.0 | 45 | | \>= v0.15.0 | \>= v0.7.0 | 46 | | \>= v0.12.1 | \>= v0.5.0 | 47 | | \>= v0.12.0 | \>= v0.4.0 | 48 | | <= v0.11 | <= v0.3.0 | 49 | 50 | ## Development 51 | 52 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 53 | 54 | To install this gem onto your local machine, run `bundle exec rake install`. 55 | 56 | ### Releasing 57 | 58 | To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 59 | 60 | ## Contributing 61 | 62 | Bug reports and pull requests are welcome on GitHub at [typesense/typesense-ruby](https://github.com/typesense/typesense-ruby). 63 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'typesense' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 10% 6 | target: 90% 7 | patch: 8 | default: 9 | threshold: 10% 10 | target: 80% 11 | -------------------------------------------------------------------------------- /examples/aliases.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # These examples walk you through operations specifically related to aliases 5 | 6 | require_relative 'client_initialization' 7 | 8 | # Create a collection 9 | create_response = @typesense.collections.create( 10 | name: 'books_january', 11 | fields: [ 12 | { name: 'title', type: 'string' }, 13 | { name: 'authors', type: 'string[]' }, 14 | { name: 'authors_facet', type: 'string[]', facet: true }, 15 | { name: 'publication_year', type: 'int32' }, 16 | { name: 'publication_year_facet', type: 'string', facet: true }, 17 | { name: 'ratings_count', type: 'int32' }, 18 | { name: 'average_rating', type: 'float' }, 19 | { name: 'image_url', type: 'string' } 20 | ], 21 | default_sorting_field: 'ratings_count' 22 | ) 23 | 24 | ap create_response 25 | 26 | # Create or update an existing alias 27 | create_alias_response = @typesense.aliases.upsert('books', 28 | collection_name: 'books_january') 29 | ap create_alias_response 30 | 31 | # Add a book using the alias name `books` 32 | hunger_games_book = { 33 | id: '1', original_publication_year: 2008, authors: ['Suzanne Collins'], average_rating: 4.34, 34 | publication_year: 2008, publication_year_facet: '2008', authors_facet: ['Suzanne Collins'], 35 | title: 'The Hunger Games', 36 | image_url: 'https://images.gr-assets.com/books/1447303603m/2767052.jpg', 37 | ratings_count: 4_780_653 38 | } 39 | 40 | @typesense.collections['books'].documents.create(hunger_games_book) 41 | 42 | # Search using the alias 43 | ap @typesense.collections['books'].documents.search( 44 | q: 'hunger', 45 | query_by: 'title', 46 | sort_by: 'ratings_count:desc' 47 | ) 48 | 49 | # List all aliases 50 | ap @typesense.aliases.retrieve 51 | 52 | # Retrieve the configuration of a specific alias 53 | ap @typesense.aliases['books'].retrieve 54 | 55 | # Delete an alias 56 | ap @typesense.aliases['books'].delete 57 | -------------------------------------------------------------------------------- /examples/client_initialization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../lib/typesense' 4 | require 'awesome_print' 5 | require 'logger' 6 | 7 | AwesomePrint.defaults = { 8 | indent: -2 9 | } 10 | 11 | ## 12 | ## Setup 13 | # 14 | ### Option 1: Start a single-node cluster 15 | # $ docker run -i -p 8108:8108 -v/tmp/typesense-server-data-1b/:/data -v`pwd`/typesense-server-peers:/typesense-server-peers typesense/typesense:0.19.0 --data-dir /data --api-key=xyz --listen-port 8108 --enable-cors 16 | # 17 | ### Option 2: Start a 3-node cluster 18 | # 19 | # Create file in present working directory called typesense-server-peers (update IP Addresses appropriately to your local network): 20 | # $ echo '172.17.0.2:8107:8108,172.17.0.3:7107:7108,172.17.0.4:9107:9108' > `pwd`/typesense-server-peers 21 | # 22 | # Start node 1: 23 | # $ docker run -i -p 8108:8108 -p 8107:8107 -v/tmp/typesense-server-data-1b/:/data -v`pwd`/typesense-server-peers:/typesense-server-peers typesense/typesense:0.19.0 --data-dir /data --api-key=xyz --listen-port 8108 --peering-port 8107 --enable-cors --nodes=/typesense-server-peers 24 | # 25 | # Start node 2: 26 | # $ docker run -i -p 7108:7108 -p 7107:7107 -v/tmp/.typesense-server-data-2b/:/data -v`pwd`/typesense-server-peers:/typesense-server-peers typesense/typesense:0.19.0 --data-dir /data --api-key=xyz --listen-port 7108 --peering-port 7107 --enable-cors --nodes=/typesense-server-peers 27 | # 28 | # Start node 3: 29 | # $ docker run -i -p 9108:9108 -p 9107:9107 -v/tmp/.typesense-server-data-3b/:/data -v`pwd`/typesense-server-peers:/typesense-server-peers typesense/typesense:0.19.0 --data-dir /data --api-key=xyz --listen-port 9108 --peering-port 9107 --enable-cors --nodes=/typesense-server-peers 30 | # 31 | # Note: Be sure to add `--license-key=<>` at the end when starting a Typesense Premium server 32 | 33 | ## 34 | # Create a client 35 | @typesense = Typesense::Client.new( 36 | nodes: [ 37 | { 38 | host: 'localhost', 39 | port: 8108, 40 | protocol: 'http' 41 | } 42 | # Uncomment if starting a 3-node cluster, using Option 2 under Setup instructions above 43 | # { 44 | # host: 'localhost', 45 | # port: 7108, 46 | # protocol: 'http' 47 | # }, 48 | # { 49 | # host: 'localhost', 50 | # port: 9108, 51 | # protocol: 'http' 52 | # } 53 | ], 54 | # If this optional key is specified, requests are always sent to this node first if it is healthy 55 | # before falling back on the nodes mentioned in the `nodes` key. This is useful when running a distributed set of search clusters. 56 | # 'nearest_node': { 57 | # 'host': 'localhost', 58 | # 'port': '8108', 59 | # 'protocol': 'http' 60 | # }, 61 | api_key: 'xyz', 62 | num_retries: 10, 63 | healthcheck_interval_seconds: 1, 64 | retry_interval_seconds: 0.01, 65 | connection_timeout_seconds: 10, 66 | logger: Logger.new($stdout), 67 | log_level: Logger::INFO 68 | ) 69 | -------------------------------------------------------------------------------- /examples/collections_and_documents.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # These examples walk you through all the operations you can do on a collection and a document 5 | # Search is specifically covered in another file in the examples folder 6 | 7 | require_relative 'client_initialization' 8 | 9 | ## 10 | # Create a collection 11 | schema = { 12 | 'name' => 'companies', 13 | 'fields' => [ 14 | { 15 | 'name' => 'company_name', 16 | 'type' => 'string' 17 | }, 18 | { 19 | 'name' => 'num_employees', 20 | 'type' => 'int32' 21 | }, 22 | { 23 | 'name' => 'country', 24 | 'type' => 'string', 25 | 'facet' => true 26 | } 27 | ], 28 | 'default_sorting_field' => 'num_employees' 29 | } 30 | 31 | # Delete the collection if it already exists 32 | begin 33 | @typesense.collections['companies'].delete 34 | rescue Typesense::Error::ObjectNotFound 35 | end 36 | 37 | collection = @typesense.collections.create(schema) 38 | ap collection 39 | 40 | # { 41 | # "name" => "companies", 42 | # "fields" => [ 43 | # [0] { 44 | # "name" => "company_name", 45 | # "type" => "string" 46 | # }, 47 | # [1] { 48 | # "name" => "num_employees", 49 | # "type" => "int32" 50 | # }, 51 | # [2] { 52 | # "name" => "country", 53 | # "type" => "string", 54 | # "facet" => true 55 | # } 56 | # ], 57 | # "default_sorting_field" => "num_employees" 58 | # } 59 | 60 | ## 61 | # Retrieve a collection 62 | sleep 0.5 # Give Typesense cluster a few hundred ms to create the collection on all nodes, before reading it right after (eventually consistent) 63 | collection = @typesense.collections['companies'].retrieve 64 | ap collection 65 | 66 | # { 67 | # "default_sorting_field" => "num_employees", 68 | # "fields" => [ 69 | # [0] { 70 | # "facet" => false, 71 | # "name" => "company_name", 72 | # "type" => "string" 73 | # }, 74 | # [1] { 75 | # "facet" => false, 76 | # "name" => "num_employees", 77 | # "type" => "int32" 78 | # }, 79 | # [2] { 80 | # "facet" => true, 81 | # "name" => "country", 82 | # "type" => "string" 83 | # } 84 | # ], 85 | # "name" => "companies", 86 | # "num_documents" => 0 87 | # } 88 | 89 | ## 90 | # Retrieve all collections 91 | collections = @typesense.collections.retrieve 92 | ap collections 93 | 94 | # [ 95 | # [0] { 96 | # "default_sorting_field" => "num_employees", 97 | # "fields" => [ 98 | # [0] { 99 | # "facet" => false, 100 | # "name" => "company_name", 101 | # "type" => "string" 102 | # }, 103 | # [1] { 104 | # "facet" => false, 105 | # "name" => "num_employees", 106 | # "type" => "int32" 107 | # }, 108 | # [2] { 109 | # "facet" => true, 110 | # "name" => "country", 111 | # "type" => "string" 112 | # } 113 | # ], 114 | # "name" => "companies", 115 | # "num_documents" => 0 116 | # } 117 | # ] 118 | 119 | ## 120 | # Delete a collection 121 | # Deletion returns the schema of the collection after deletion 122 | collection = @typesense.collections['companies'].delete 123 | ap collection 124 | 125 | # { 126 | # "default_sorting_field" => "num_employees", 127 | # "fields" => [ 128 | # [0] { 129 | # "facet" => false, 130 | # "name" => "company_name", 131 | # "type" => "string" 132 | # }, 133 | # [1] { 134 | # "facet" => false, 135 | # "name" => "num_employees", 136 | # "type" => "int32" 137 | # }, 138 | # [2] { 139 | # "facet" => true, 140 | # "name" => "country", 141 | # "type" => "string" 142 | # } 143 | # ], 144 | # "name" => "companies", 145 | # "num_documents" => 0 146 | # } 147 | 148 | ### 149 | # Truncate a collection 150 | # Truncation returns the number of documents deleted 151 | collection = @typesense.collections['companies'].documents.truncate 152 | ap collection 153 | 154 | # { 155 | # "num_deleted": 125 156 | # } 157 | 158 | # Let's create the collection again for use in our remaining examples 159 | @typesense.collections.create(schema) 160 | 161 | ## 162 | # Create (index) a document 163 | document = { 164 | 'id' => '124', 165 | 'company_name' => 'Stark Industries', 166 | 'num_employees' => 5215, 167 | 'country' => 'USA' 168 | } 169 | 170 | document = @typesense.collections['companies'].documents.create(document) 171 | ap document 172 | 173 | # { 174 | # "company_name" => "Stark Industries", 175 | # "country" => "USA", 176 | # "id" => "124", 177 | # "num_employees" => 5215 178 | # } 179 | 180 | # You can also upsert a document, which will update the document if it already exists or create a new one if it doesn't exist 181 | document = @typesense.collections['companies'].documents.upsert(document) 182 | ap document 183 | 184 | ## 185 | # Retrieve a document 186 | sleep 0.5 # Give Typesense cluster a few hundred ms to create the document on all nodes, before reading it right after (eventually consistent) 187 | document = @typesense.collections['companies'].documents['124'].retrieve 188 | ap document 189 | 190 | # { 191 | # "company_name" => "Stark Industries", 192 | # "country" => "USA", 193 | # "id" => "124", 194 | # "num_employees" => 5215 195 | # } 196 | 197 | ## 198 | # Update a document. Unlike upsert, update will error out if the doc doesn't already exist. 199 | document = @typesense.collections['companies'].documents['124'].update( 200 | 'num_employees' => 5500 201 | ) 202 | ap document 203 | 204 | # { 205 | # "id" => "124", 206 | # "num_employees" => 5500 207 | # } 208 | 209 | # This should error out, since document 145 doesn't exist 210 | # document = @typesense.collections['companies'].documents['145'].update( 211 | # 'num_employees' => 5500 212 | # ) 213 | # ap document 214 | 215 | ## 216 | # Delete a document 217 | # Deleting a document, returns the document after deletion 218 | document = @typesense.collections['companies'].documents['124'].delete 219 | ap document 220 | 221 | # { 222 | # "company_name" => "Stark Industries", 223 | # "country" => "USA", 224 | # "id" => "124", 225 | # "num_employees" => 5215 226 | # } 227 | 228 | # Let's bulk create two documents again for use in our remaining examples 229 | documents = [ 230 | { 231 | 'id' => '124', 232 | 'company_name' => 'Stark Industries', 233 | 'num_employees' => 5215, 234 | 'country' => 'USA' 235 | }, 236 | { 237 | 'id' => '125', 238 | 'company_name' => 'Acme Corp', 239 | 'num_employees' => 1002, 240 | 'country' => 'France' 241 | } 242 | ] 243 | ap @typesense.collections['companies'].documents.import(documents) 244 | 245 | ## If you already have documents in JSONL format, you can also pass it directly to #import, to avoid the JSON parsing overhead: 246 | # @typesense.collections['companies'].documents.import(documents_in_jsonl_format) 247 | 248 | ## You can bulk upsert documents, by adding an upsert action option to #import 249 | documents << { 250 | 'id' => '126', 251 | 'company_name' => 'Stark Industries 2', 252 | 'num_employees' => 200, 253 | 'country' => 'USA' 254 | } 255 | ap @typesense.collections['companies'].documents.import(documents, action: :upsert) 256 | 257 | ## You can bulk update documents, by adding an update action option to #import 258 | # `action: update` will throw an error if the document doesn't already exist 259 | # This document will error out, since id: 1200 doesn't exist 260 | documents << { 261 | 'id' => '1200', 262 | 'country' => 'USA' 263 | } 264 | documents << { 265 | 'id' => '126', 266 | 'num_employees' => 300 267 | } 268 | ap @typesense.collections['companies'].documents.import(documents, action: :update) 269 | 270 | ## You can also bulk delete documents, using filter_by fields: 271 | ap @typesense.collections['companies'].documents.delete(filter_by: 'num_employees:>100') 272 | 273 | ## 274 | # Export all documents in a collection in JSON Lines format 275 | # We use JSON Lines format for performance reasons. You can choose to parse selected lines as needed, by splitting on \n. 276 | sleep 0.5 # Give Typesense cluster a few hundred ms to create the document on all nodes, before reading it right after (eventually consistent) 277 | jsonl_data = @typesense.collections['companies'].documents.export 278 | ap jsonl_data 279 | 280 | # "{\"company_name\":\"Stark Industries\",\"country\":\"USA\",\"id\":\"124\",\"num_employees\":5215}\n{\"company_name\":\"Acme Corp\",\"country\":\"France\",\"id\":\"125\",\"num_employees\":1002}" 281 | 282 | ## 283 | # Cleanup 284 | # Drop the collection 285 | @typesense.collections['companies'].delete 286 | -------------------------------------------------------------------------------- /examples/keys.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # These examples walk you through operations to manage API Keys 5 | 6 | require_relative 'client_initialization' 7 | 8 | # Let's setup some test data for this example 9 | schema = { 10 | 'name' => 'users', 11 | 'fields' => [ 12 | { 13 | 'name' => 'company_id', 14 | 'type' => 'int32', 15 | 'facet' => false 16 | }, 17 | { 18 | 'name' => 'user_name', 19 | 'type' => 'string', 20 | 'facet' => false 21 | }, 22 | { 23 | 'name' => 'login_count', 24 | 'type' => 'int32', 25 | 'facet' => false 26 | }, 27 | { 28 | 'name' => 'country', 29 | 'type' => 'string', 30 | 'facet' => true 31 | } 32 | ], 33 | 'default_sorting_field' => 'company_id' 34 | } 35 | 36 | # We have four users, belonging to two companies: 124 and 126 37 | documents = [ 38 | { 39 | 'company_id' => 124, 40 | 'user_name' => 'Hilary Bradford', 41 | 'login_count' => 10, 42 | 'country' => 'USA' 43 | }, 44 | { 45 | 'company_id' => 124, 46 | 'user_name' => 'Nile Carty', 47 | 'login_count' => 100, 48 | 'country' => 'USA' 49 | }, 50 | { 51 | 'company_id' => 126, 52 | 'user_name' => 'Tahlia Maxwell', 53 | 'login_count' => 1, 54 | 'country' => 'France' 55 | }, 56 | { 57 | 'company_id' => 126, 58 | 'user_name' => 'Karl Roy', 59 | 'login_count' => 2, 60 | 'country' => 'Germany' 61 | } 62 | ] 63 | 64 | # Delete if the collection already exists from a previous example run 65 | begin 66 | @typesense.collections['users'].delete 67 | rescue Typesense::Error::ObjectNotFound 68 | end 69 | 70 | # create a collection 71 | @typesense.collections.create(schema) 72 | 73 | # Index documents 74 | documents.each do |document| 75 | @typesense.collections['users'].documents.create(document) 76 | end 77 | 78 | # Generate an API key and restrict it to only allow searches 79 | # You want to use this API Key in the browser instead of the master API Key 80 | unscoped_search_only_api_key_response = @typesense.keys.create({ 81 | 'description' => 'Search-only key.', 82 | 'actions' => ['documents:search'], 83 | 'collections' => ['*'] 84 | }) 85 | ap unscoped_search_only_api_key_response 86 | 87 | # Save the key returned, since this will be the only time the full API Key is returned, for security purposes 88 | unscoped_search_only_api_key = unscoped_search_only_api_key_response['value'] 89 | 90 | # Side note: you can also retrieve metadata of API keys using the ID returned in the above response 91 | unscoped_search_only_api_key_response = @typesense.keys[unscoped_search_only_api_key_response['id']].retrieve 92 | ap unscoped_search_only_api_key_response 93 | 94 | # We'll now use this search-only API key to generate a scoped search API key that can only access documents that have company_id:124 95 | # This is useful when you store multi-tenant data in a single Typesense server, but you only want 96 | # a particular tenant to access their own data. You'd generate one scoped search key per tenant. 97 | # IMPORTANT: scoped search keys should only be generated *server-side*, so as to not leak the unscoped main search key to clients 98 | scoped_search_only_api_key = @typesense.keys.generate_scoped_search_key(unscoped_search_only_api_key, { filter_by: 'company_id:124' }) 99 | ap "scoped_search_only_api_key: #{scoped_search_only_api_key}" 100 | 101 | # Now let's search the data using the scoped API Key for company_id:124 102 | # You can do searches with this scoped_search_only_api_key from the server-side or client-side 103 | scoped_typesense_client = Typesense::Client.new({ 104 | nodes: [{ 105 | host: 'localhost', 106 | port: '8108', 107 | protocol: 'http' 108 | }], 109 | api_key: scoped_search_only_api_key 110 | }) 111 | 112 | search_results = scoped_typesense_client.collections['users'].documents.search({ 113 | 'q' => 'Hilary', 114 | 'query_by' => 'user_name' 115 | }) 116 | ap search_results 117 | 118 | # Search for a user that exists, but is outside the current key's scope 119 | search_results = scoped_typesense_client.collections['users'].documents.search({ 120 | q: 'Maxwell', 121 | query_by: 'user_name' 122 | }) 123 | ap search_results # Will return empty result set 124 | 125 | # Now let's delete the unscoped_search_only_api_key. You'd want to do this when you need to rotate keys for example. 126 | results = @typesense.keys[unscoped_search_only_api_key_response['id']].delete 127 | ap results 128 | -------------------------------------------------------------------------------- /examples/overrides.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # These examples walk you through operations specifically related to result overrides / curation 5 | 6 | require_relative 'client_initialization' 7 | 8 | # Delete the collection if it already exists 9 | begin 10 | @typesense.collections['companies'].delete 11 | rescue Typesense::Error::ObjectNotFound 12 | end 13 | 14 | ## 15 | # Create a collection 16 | schema = { 17 | 'name' => 'companies', 18 | 'fields' => [ 19 | { 20 | 'name' => 'company_name', 21 | 'type' => 'string' 22 | }, 23 | { 24 | 'name' => 'num_employees', 25 | 'type' => 'int32' 26 | }, 27 | { 28 | 'name' => 'country', 29 | 'type' => 'string', 30 | 'facet' => true 31 | } 32 | ], 33 | 'default_sorting_field' => 'num_employees' 34 | } 35 | 36 | @typesense.collections.create(schema) 37 | 38 | # Let's create a couple documents for us to use in our search examples 39 | @typesense.collections['companies'].documents.create( 40 | 'id' => '124', 41 | 'company_name' => 'Stark Industries', 42 | 'num_employees' => 5215, 43 | 'country' => 'USA' 44 | ) 45 | 46 | @typesense.collections['companies'].documents.create( 47 | 'id' => '127', 48 | 'company_name' => 'Stark Corp', 49 | 'num_employees' => 1031, 50 | 'country' => 'USA' 51 | ) 52 | 53 | @typesense.collections['companies'].documents.create( 54 | 'id' => '125', 55 | 'company_name' => 'Acme Corp', 56 | 'num_employees' => 1002, 57 | 'country' => 'France' 58 | ) 59 | 60 | @typesense.collections['companies'].documents.create( 61 | 'id' => '126', 62 | 'company_name' => 'Doofenshmirtz Inc', 63 | 'num_employees' => 2, 64 | 'country' => 'Tri-State Area' 65 | ) 66 | 67 | ## 68 | # Create overrides 69 | 70 | @typesense.collections['companies'].overrides.upsert( 71 | 'promote-doofenshmirtz', 72 | rule: { 73 | query: 'doofen', 74 | match: 'exact' 75 | }, 76 | includes: [{ 'id' => '126', 'position' => 1 }] 77 | ) 78 | @typesense.collections['companies'].overrides.upsert( 79 | 'promote-acme', 80 | rule: { 81 | query: 'stark', 82 | match: 'exact' 83 | }, 84 | includes: [{ 'id' => '125', 'position' => 1 }] 85 | ) 86 | 87 | ## 88 | # Search for documents 89 | results = @typesense.collections['companies'].documents.search( 90 | 'q' => 'doofen', 91 | 'query_by' => 'company_name' 92 | ) 93 | ap results 94 | 95 | results = @typesense.collections['companies'].documents.search( 96 | 'q' => 'stark', 97 | 'query_by' => 'company_name' 98 | ) 99 | ap results 100 | 101 | results = @typesense.collections['companies'].documents.search( 102 | 'q' => 'Inc', 103 | 'query_by' => 'company_name', 104 | 'filter_by' => 'num_employees:<100', 105 | 'sort_by' => 'num_employees:desc' 106 | ) 107 | ap results 108 | 109 | ## 110 | # Cleanup 111 | # Drop the collection 112 | @typesense.collections['companies'].delete 113 | -------------------------------------------------------------------------------- /examples/search.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # These examples walk you through operations specifically related to search 5 | 6 | require_relative 'client_initialization' 7 | 8 | ## 9 | # Create a collection 10 | schema = { 11 | 'name' => 'companies', 12 | 'fields' => [ 13 | { 14 | 'name' => 'company_name', 15 | 'type' => 'string' 16 | }, 17 | { 18 | 'name' => 'num_employees', 19 | 'type' => 'int32' 20 | }, 21 | { 22 | 'name' => 'country', 23 | 'type' => 'string', 24 | 'facet' => true 25 | } 26 | ], 27 | 'default_sorting_field' => 'num_employees' 28 | } 29 | 30 | # Delete the collection if it already exists 31 | begin 32 | @typesense.collections['companies'].delete 33 | rescue Typesense::Error::ObjectNotFound 34 | end 35 | 36 | # Now create the collection 37 | @typesense.collections.create(schema) 38 | 39 | # Let's create a couple documents for us to use in our search examples 40 | @typesense.collections['companies'].documents.create( 41 | 'id' => '124', 42 | 'company_name' => 'Stark Industries', 43 | 'num_employees' => 5215, 44 | 'country' => 'USA' 45 | ) 46 | 47 | @typesense.collections['companies'].documents.create( 48 | 'id' => '127', 49 | 'company_name' => 'Stark Corp', 50 | 'num_employees' => 1031, 51 | 'country' => 'USA' 52 | ) 53 | 54 | @typesense.collections['companies'].documents.create( 55 | 'id' => '125', 56 | 'company_name' => 'Acme Corp', 57 | 'num_employees' => 1002, 58 | 'country' => 'France' 59 | ) 60 | 61 | @typesense.collections['companies'].documents.create( 62 | 'id' => '126', 63 | 'company_name' => 'Doofenshmirtz Inc', 64 | 'num_employees' => 2, 65 | 'country' => 'Tri-State Area' 66 | ) 67 | 68 | ## 69 | # Search for documents 70 | results = @typesense.collections['companies'].documents.search( 71 | 'q' => 'Stark', 72 | 'query_by' => 'company_name' 73 | ) 74 | ap results 75 | 76 | ## 77 | # Search for more documents 78 | results = @typesense.collections['companies'].documents.search( 79 | 'q' => 'Inc', 80 | 'query_by' => 'company_name', 81 | 'filter_by' => 'num_employees:<100', 82 | 'sort_by' => 'num_employees:desc' 83 | ) 84 | ap results 85 | 86 | ## 87 | # Search for more multiple documents 88 | results = @typesense.multi_search.perform( 89 | { 90 | searches: [ 91 | { 92 | 'q' => 'Inc', 93 | 'filter_by' => 'num_employees:<100', 94 | 'sort_by' => 'num_employees:desc' 95 | }, 96 | { 97 | 'q' => 'Stark' 98 | } 99 | ] 100 | }, 101 | { 102 | # Parameters that are common to all searches, can be mentioned here 103 | 'collection' => 'companies', 104 | 'query_by' => 'company_name' 105 | } 106 | ) 107 | ap results 108 | 109 | ## 110 | # Search for more documents 111 | results = @typesense.collections['companies'].documents.search( 112 | 'q' => 'Non-existent', 113 | 'query_by' => 'company_name', 114 | # Optionally add a user id if you use Analytics & Query Suggestions 115 | 'x-typesense-user-id' => 42 116 | ) 117 | ap results 118 | 119 | ## 120 | # Cleanup 121 | # Drop the collection 122 | @typesense.collections['companies'].delete 123 | -------------------------------------------------------------------------------- /examples/synonyms.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | # These examples walk you through operations specifically related to synonyms 5 | 6 | require_relative 'client_initialization' 7 | 8 | # Delete the collection if it already exists 9 | begin 10 | @typesense.collections['companies'].delete 11 | rescue Typesense::Error::ObjectNotFound 12 | end 13 | 14 | ## 15 | # Create a collection 16 | schema = { 17 | 'name' => 'companies', 18 | 'fields' => [ 19 | { 20 | 'name' => 'company_name', 21 | 'type' => 'string' 22 | }, 23 | { 24 | 'name' => 'num_employees', 25 | 'type' => 'int32' 26 | }, 27 | { 28 | 'name' => 'country', 29 | 'type' => 'string', 30 | 'facet' => true 31 | } 32 | ], 33 | 'default_sorting_field' => 'num_employees' 34 | } 35 | 36 | @typesense.collections.create(schema) 37 | 38 | # Let's create a couple documents for us to use in our search examples 39 | @typesense.collections['companies'].documents.create( 40 | 'id' => '124', 41 | 'company_name' => 'Stark Industries', 42 | 'num_employees' => 5215, 43 | 'country' => 'USA' 44 | ) 45 | 46 | @typesense.collections['companies'].documents.create( 47 | 'id' => '127', 48 | 'company_name' => 'Stark Corp', 49 | 'num_employees' => 1031, 50 | 'country' => 'USA' 51 | ) 52 | 53 | @typesense.collections['companies'].documents.create( 54 | 'id' => '125', 55 | 'company_name' => 'Acme Corp', 56 | 'num_employees' => 1002, 57 | 'country' => 'France' 58 | ) 59 | 60 | @typesense.collections['companies'].documents.create( 61 | 'id' => '126', 62 | 'company_name' => 'Doofenshmirtz Inc', 63 | 'num_employees' => 2, 64 | 'country' => 'Tri-State Area' 65 | ) 66 | 67 | ## 68 | # Create synonyms 69 | 70 | ap @typesense.collections['companies'].synonyms.upsert( 71 | 'synonyms-doofenshmirtz', 72 | { 73 | 'synonyms' => %w[Doofenshmirtz Heinz Evil] 74 | } 75 | ) 76 | 77 | ## 78 | # Search for documents 79 | # Should return Doofenshmirtz Inc, since it's set as a synonym 80 | results = @typesense.collections['companies'].documents.search( 81 | 'q' => 'Heinz', 82 | 'query_by' => 'company_name' 83 | ) 84 | ap results 85 | 86 | ## 87 | # List all synonyms 88 | ap @typesense.collections['companies'].synonyms.retrieve 89 | 90 | ## 91 | # Retrieve specific synonym 92 | ap @typesense.collections['companies'].synonyms['synonyms-doofenshmirtz'].retrieve 93 | 94 | ## 95 | # Update synonym to a one-way synonym 96 | ap @typesense.collections['companies'].synonyms.upsert( 97 | 'synonyms-doofenshmirtz', 98 | { 99 | 'root' => 'Evil', 100 | 'synonyms' => %w[Doofenshmirtz Heinz] 101 | } 102 | ) 103 | 104 | ## 105 | # Search for documents 106 | # Should return Doofenshmirtz Inc, since it's set as a synonym 107 | results = @typesense.collections['companies'].documents.search( 108 | 'q' => 'Evil', 109 | 'query_by' => 'company_name' 110 | ) 111 | ap results 112 | 113 | # Should not return any results, since this is a one-way synonym 114 | results = @typesense.collections['companies'].documents.search( 115 | 'q' => 'Heinz', 116 | 'query_by' => 'company_name' 117 | ) 118 | ap results 119 | 120 | ## 121 | # Delete synonym 122 | ap @typesense.collections['companies'].synonyms['synonyms-doofenshmirtz'].delete 123 | 124 | ## 125 | # Cleanup 126 | # Drop the collection 127 | @typesense.collections['companies'].delete 128 | -------------------------------------------------------------------------------- /lib/typesense.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | end 5 | 6 | require 'uri' 7 | require_relative 'typesense/version' 8 | require_relative 'typesense/configuration' 9 | require_relative 'typesense/client' 10 | require_relative 'typesense/api_call' 11 | require_relative 'typesense/collections' 12 | require_relative 'typesense/collection' 13 | require_relative 'typesense/documents' 14 | require_relative 'typesense/document' 15 | require_relative 'typesense/overrides' 16 | require_relative 'typesense/override' 17 | require_relative 'typesense/synonyms' 18 | require_relative 'typesense/synonym' 19 | require_relative 'typesense/aliases' 20 | require_relative 'typesense/alias' 21 | require_relative 'typesense/keys' 22 | require_relative 'typesense/key' 23 | require_relative 'typesense/multi_search' 24 | require_relative 'typesense/analytics' 25 | require_relative 'typesense/analytics_rules' 26 | require_relative 'typesense/analytics_rule' 27 | require_relative 'typesense/presets' 28 | require_relative 'typesense/preset' 29 | require_relative 'typesense/debug' 30 | require_relative 'typesense/health' 31 | require_relative 'typesense/metrics' 32 | require_relative 'typesense/stats' 33 | require_relative 'typesense/operations' 34 | require_relative 'typesense/error' 35 | require_relative 'typesense/stemming' 36 | require_relative 'typesense/stemming_dictionaries' 37 | require_relative 'typesense/stemming_dictionary' 38 | -------------------------------------------------------------------------------- /lib/typesense/alias.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Alias 5 | def initialize(name, api_call) 6 | @name = name 7 | @api_call = api_call 8 | end 9 | 10 | def retrieve 11 | @api_call.get(endpoint_path) 12 | end 13 | 14 | def delete 15 | @api_call.delete(endpoint_path) 16 | end 17 | 18 | private 19 | 20 | def endpoint_path 21 | "#{Aliases::RESOURCE_PATH}/#{URI.encode_www_form_component(@name)}" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/typesense/aliases.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Aliases 5 | RESOURCE_PATH = '/aliases' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | @aliases = {} 10 | end 11 | 12 | def upsert(alias_name, mapping) 13 | @api_call.put(endpoint_path(alias_name), mapping) 14 | end 15 | 16 | def retrieve 17 | @api_call.get(RESOURCE_PATH) 18 | end 19 | 20 | def [](alias_name) 21 | @aliases[alias_name] ||= Alias.new(alias_name, @api_call) 22 | end 23 | 24 | private 25 | 26 | def endpoint_path(alias_name) 27 | "#{Aliases::RESOURCE_PATH}/#{URI.encode_www_form_component(alias_name)}" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/typesense/analytics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Analytics 5 | RESOURCE_PATH = '/analytics' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | end 10 | 11 | def rules 12 | @rules ||= AnalyticsRules.new(@api_call) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/typesense/analytics_rule.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class AnalyticsRule 5 | def initialize(rule_name, api_call) 6 | @rule_name = rule_name 7 | @api_call = api_call 8 | end 9 | 10 | def retrieve 11 | @api_call.get(endpoint_path) 12 | end 13 | 14 | def delete 15 | @api_call.delete(endpoint_path) 16 | end 17 | 18 | private 19 | 20 | def endpoint_path 21 | "#{AnalyticsRules::RESOURCE_PATH}/#{URI.encode_www_form_component(@rule_name)}" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/typesense/analytics_rules.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class AnalyticsRules 5 | RESOURCE_PATH = '/analytics/rules' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | @analytics_rules = {} 10 | end 11 | 12 | def upsert(rule_name, params) 13 | @api_call.put(endpoint_path(rule_name), params) 14 | end 15 | 16 | def retrieve 17 | @api_call.get(endpoint_path) 18 | end 19 | 20 | def [](rule_name) 21 | @analytics_rules[rule_name] ||= AnalyticsRule.new(rule_name, @api_call) 22 | end 23 | 24 | private 25 | 26 | def endpoint_path(operation = nil) 27 | "#{AnalyticsRules::RESOURCE_PATH}#{operation.nil? ? '' : "/#{URI.encode_www_form_component(operation)}"}" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/typesense/api_call.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday' 4 | require 'oj' 5 | 6 | module Typesense 7 | class ApiCall 8 | API_KEY_HEADER_NAME = 'X-TYPESENSE-API-KEY' 9 | 10 | attr_reader :logger 11 | 12 | def initialize(configuration) 13 | @configuration = configuration 14 | 15 | @api_key = @configuration.api_key 16 | @nodes = @configuration.nodes.dup # Make a copy, since we'll be adding additional metadata to the nodes 17 | @nearest_node = @configuration.nearest_node.dup 18 | @connection_timeout_seconds = @configuration.connection_timeout_seconds 19 | @healthcheck_interval_seconds = @configuration.healthcheck_interval_seconds 20 | @num_retries_per_request = @configuration.num_retries 21 | @retry_interval_seconds = @configuration.retry_interval_seconds 22 | 23 | @logger = @configuration.logger 24 | 25 | initialize_metadata_for_nodes 26 | @current_node_index = -1 27 | end 28 | 29 | def post(endpoint, body_parameters = {}, query_parameters = {}) 30 | perform_request :post, 31 | endpoint, 32 | query_parameters: query_parameters, 33 | body_parameters: body_parameters 34 | end 35 | 36 | def patch(endpoint, body_parameters = {}, query_parameters = {}) 37 | perform_request :patch, 38 | endpoint, 39 | query_parameters: query_parameters, 40 | body_parameters: body_parameters 41 | end 42 | 43 | def put(endpoint, body_parameters = {}, query_parameters = {}) 44 | perform_request :put, 45 | endpoint, 46 | query_parameters: query_parameters, 47 | body_parameters: body_parameters 48 | end 49 | 50 | def get(endpoint, query_parameters = {}) 51 | perform_request :get, 52 | endpoint, 53 | query_parameters: query_parameters 54 | end 55 | 56 | def delete(endpoint, query_parameters = {}) 57 | perform_request :delete, 58 | endpoint, 59 | query_parameters: query_parameters 60 | end 61 | 62 | def perform_request(method, endpoint, query_parameters: nil, body_parameters: nil, additional_headers: {}) 63 | @configuration.validate! 64 | last_exception = nil 65 | @logger.debug "Performing #{method.to_s.upcase} request: #{endpoint}" 66 | (1..(@num_retries_per_request + 1)).each do |num_tries| 67 | node = next_node 68 | 69 | @logger.debug "Attempting #{method.to_s.upcase} request Try ##{num_tries} to Node #{node[:index]}" 70 | 71 | begin 72 | conn = Faraday.new(uri_for(endpoint, node)) do |f| 73 | f.options.timeout = @connection_timeout_seconds 74 | f.options.open_timeout = @connection_timeout_seconds 75 | end 76 | 77 | headers = default_headers.merge(additional_headers) 78 | 79 | response = conn.send(method) do |req| 80 | req.headers = headers 81 | req.params = query_parameters unless query_parameters.nil? 82 | unless body_parameters.nil? 83 | body = body_parameters 84 | body = Oj.dump(body_parameters, mode: :compat) if headers['Content-Type'] == 'application/json' 85 | req.body = body 86 | end 87 | end 88 | set_node_healthcheck(node, is_healthy: true) if response.status.between?(1, 499) 89 | 90 | @logger.debug "Request #{method}:#{uri_for(endpoint, node)} to Node #{node[:index]} was successfully made (at the network layer). response.status was #{response.status}." 91 | 92 | parsed_response = if response.headers && (response.headers['content-type'] || '').include?('application/json') 93 | Oj.load(response.body, mode: :compat) 94 | else 95 | response.body 96 | end 97 | 98 | # If response is 2xx return the object, else raise the response as an exception 99 | return parsed_response if response.status.between?(200, 299) 100 | 101 | exception_message = (parsed_response && parsed_response['message']) || 'Error' 102 | raise custom_exception_klass_for(response), exception_message 103 | rescue Faraday::ConnectionFailed, Faraday::TimeoutError, 104 | Errno::EINVAL, Errno::ENETDOWN, Errno::ENETUNREACH, Errno::ENETRESET, 105 | Errno::ECONNABORTED, Errno::ECONNRESET, Errno::ETIMEDOUT, 106 | Errno::ECONNREFUSED, Errno::EHOSTDOWN, Errno::EHOSTUNREACH, 107 | Typesense::Error::ServerError, Typesense::Error::HTTPStatus0Error => e 108 | # Rescue network layer exceptions and HTTP 5xx errors, so the loop can continue. 109 | # Using loops for retries instead of rescue...retry to maintain consistency with client libraries in 110 | # other languages that might not support the same construct. 111 | set_node_healthcheck(node, is_healthy: false) 112 | last_exception = e 113 | @logger.warn "Request #{method}:#{uri_for(endpoint, node)} to Node #{node[:index]} failed due to \"#{e.class}: #{e.message}\"" 114 | @logger.warn "Sleeping for #{@retry_interval_seconds}s and then retrying request..." 115 | sleep @retry_interval_seconds 116 | end 117 | end 118 | @logger.debug "No retries left. Raising last error \"#{last_exception.class}: #{last_exception.message}\"..." 119 | raise last_exception 120 | end 121 | 122 | private 123 | 124 | def uri_for(endpoint, node) 125 | "#{node[:protocol]}://#{node[:host]}:#{node[:port]}#{endpoint}" 126 | end 127 | 128 | ## Attempts to find the next healthy node, looping through the list of nodes once. 129 | # But if no healthy nodes are found, it will just return the next node, even if it's unhealthy 130 | # so we can try the request for good measure, in case that node has become healthy since 131 | def next_node 132 | # Check if nearest_node is set and is healthy, if so return it 133 | unless @nearest_node.nil? 134 | @logger.debug "Nodes health: Node #{@nearest_node[:index]} is #{@nearest_node[:is_healthy] == true ? 'Healthy' : 'Unhealthy'}" 135 | if @nearest_node[:is_healthy] == true || node_due_for_healthcheck?(@nearest_node) 136 | @logger.debug "Updated current node to Node #{@nearest_node[:index]}" 137 | return @nearest_node 138 | end 139 | @logger.debug 'Falling back to individual nodes' 140 | end 141 | 142 | # Fallback to nodes as usual 143 | @logger.debug "Nodes health: #{@nodes.each_with_index.map { |node, i| "Node #{i} is #{node[:is_healthy] == true ? 'Healthy' : 'Unhealthy'}" }.join(' || ')}" 144 | candidate_node = nil 145 | (0..@nodes.length).each do |_i| 146 | @current_node_index = (@current_node_index + 1) % @nodes.length 147 | candidate_node = @nodes[@current_node_index] 148 | if candidate_node[:is_healthy] == true || node_due_for_healthcheck?(candidate_node) 149 | @logger.debug "Updated current node to Node #{candidate_node[:index]}" 150 | return candidate_node 151 | end 152 | end 153 | 154 | # None of the nodes are marked healthy, but some of them could have become healthy since last health check. 155 | # So we will just return the next node. 156 | @logger.debug "No healthy nodes were found. Returning the next node, Node #{candidate_node[:index]}" 157 | candidate_node 158 | end 159 | 160 | def node_due_for_healthcheck?(node) 161 | is_due_for_check = Time.now.to_i - node[:last_access_timestamp] > @healthcheck_interval_seconds 162 | @logger.debug "Node #{node[:index]} has exceeded healthcheck_interval_seconds of #{@healthcheck_interval_seconds}. Adding it back into rotation." if is_due_for_check 163 | is_due_for_check 164 | end 165 | 166 | def initialize_metadata_for_nodes 167 | unless @nearest_node.nil? 168 | @nearest_node[:index] = 'nearest_node' 169 | set_node_healthcheck(@nearest_node, is_healthy: true) 170 | end 171 | @nodes.each_with_index do |node, index| 172 | node[:index] = index 173 | set_node_healthcheck(node, is_healthy: true) 174 | end 175 | end 176 | 177 | def set_node_healthcheck(node, is_healthy:) 178 | node[:is_healthy] = is_healthy 179 | node[:last_access_timestamp] = Time.now.to_i 180 | end 181 | 182 | def custom_exception_klass_for(response) 183 | if response.status == 400 184 | Typesense::Error::RequestMalformed.new(response: response) 185 | elsif response.status == 401 186 | Typesense::Error::RequestUnauthorized.new(response: response) 187 | elsif response.status == 404 188 | Typesense::Error::ObjectNotFound.new(response: response) 189 | elsif response.status == 409 190 | Typesense::Error::ObjectAlreadyExists.new(response: response) 191 | elsif response.status == 422 192 | Typesense::Error::ObjectUnprocessable.new(response: response) 193 | elsif response.status.between?(500, 599) 194 | Typesense::Error::ServerError.new(response: response) 195 | elsif response.respond_to?(:timed_out?) && response.timed_out? 196 | Typesense::Error::TimeoutError.new(response: response) 197 | elsif response.status.zero? 198 | Typesense::Error::HTTPStatus0Error.new(response: response) 199 | else 200 | # This will handle both 300-level responses and any other unhandled status codes 201 | Typesense::Error::HTTPError.new(response: response) 202 | end 203 | end 204 | 205 | def default_headers 206 | { 207 | 'Content-Type' => 'application/json', 208 | API_KEY_HEADER_NAME.to_s => @api_key, 209 | 'User-Agent' => 'Typesense Ruby Client' 210 | } 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/typesense/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Client 5 | attr_reader :configuration, :collections, :aliases, :keys, :debug, :health, :metrics, :stats, :operations, 6 | :multi_search, :analytics, :presets, :stemming 7 | 8 | def initialize(options = {}) 9 | @configuration = Configuration.new(options) 10 | @api_call = ApiCall.new(@configuration) 11 | @collections = Collections.new(@api_call) 12 | @aliases = Aliases.new(@api_call) 13 | @keys = Keys.new(@api_call) 14 | @multi_search = MultiSearch.new(@api_call) 15 | @debug = Debug.new(@api_call) 16 | @health = Health.new(@api_call) 17 | @metrics = Metrics.new(@api_call) 18 | @stats = Stats.new(@api_call) 19 | @operations = Operations.new(@api_call) 20 | @analytics = Analytics.new(@api_call) 21 | @stemming = Stemming.new(@api_call) 22 | @presets = Presets.new(@api_call) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/typesense/collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Collection 5 | attr_reader :documents, :overrides, :synonyms 6 | 7 | def initialize(name, api_call) 8 | @name = name 9 | @api_call = api_call 10 | @documents = Documents.new(@name, @api_call) 11 | @overrides = Overrides.new(@name, @api_call) 12 | @synonyms = Synonyms.new(@name, @api_call) 13 | end 14 | 15 | def retrieve 16 | @api_call.get(endpoint_path) 17 | end 18 | 19 | def update(update_schema) 20 | @api_call.patch(endpoint_path, update_schema) 21 | end 22 | 23 | def delete 24 | @api_call.delete(endpoint_path) 25 | end 26 | 27 | private 28 | 29 | def endpoint_path 30 | "#{Collections::RESOURCE_PATH}/#{URI.encode_www_form_component(@name)}" 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/typesense/collections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Collections 5 | RESOURCE_PATH = '/collections' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | @collections = {} 10 | end 11 | 12 | def create(schema) 13 | @api_call.post(RESOURCE_PATH, schema) 14 | end 15 | 16 | def retrieve(options = {}) 17 | @api_call.get(RESOURCE_PATH, options) 18 | end 19 | 20 | def [](collection_name) 21 | @collections[collection_name] ||= Collection.new(collection_name, @api_call) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/typesense/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logger' 4 | 5 | module Typesense 6 | class Configuration 7 | attr_accessor :nodes, :nearest_node, :connection_timeout_seconds, :healthcheck_interval_seconds, :num_retries, :retry_interval_seconds, :api_key, :logger, :log_level 8 | 9 | def initialize(options = {}) 10 | @nodes = options[:nodes] || [] 11 | @nearest_node = options[:nearest_node] 12 | @connection_timeout_seconds = options[:connection_timeout_seconds] || options[:timeout_seconds] || 10 13 | @healthcheck_interval_seconds = options[:healthcheck_interval_seconds] || 15 14 | @num_retries = options[:num_retries] || (@nodes.length + (@nearest_node.nil? ? 0 : 1)) || 3 15 | @retry_interval_seconds = options[:retry_interval_seconds] || 0.1 16 | @api_key = options[:api_key] 17 | 18 | @logger = options[:logger] || Logger.new($stdout) 19 | @log_level = options[:log_level] || Logger::WARN 20 | @logger.level = @log_level 21 | 22 | show_deprecation_warnings(options) 23 | validate! 24 | end 25 | 26 | def validate! 27 | if @nodes.nil? || 28 | @nodes.empty? || 29 | @nodes.any? { |node| node_missing_parameters?(node) } 30 | raise Error::MissingConfiguration, 'Missing required configuration. Ensure that nodes[][:protocol], nodes[][:host] and nodes[][:port] are set.' 31 | end 32 | 33 | raise Error::MissingConfiguration, 'Missing required configuration. Ensure that api_key is set.' if @api_key.nil? 34 | end 35 | 36 | private 37 | 38 | def node_missing_parameters?(node) 39 | %i[protocol host port].any? { |attr| node.send(:[], attr).nil? } 40 | end 41 | 42 | def show_deprecation_warnings(options) 43 | @logger.warn 'Deprecation warning: timeout_seconds is now renamed to connection_timeout_seconds' unless options[:timeout_seconds].nil? 44 | @logger.warn 'Deprecation warning: master_node is now consolidated to nodes, starting with Typesense Server v0.12' unless options[:master_node].nil? 45 | @logger.warn 'Deprecation warning: read_replica_nodes is now consolidated to nodes, starting with Typesense Server v0.12' unless options[:read_replica_nodes].nil? 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/typesense/debug.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Debug 5 | RESOURCE_PATH = '/debug' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | end 10 | 11 | def retrieve 12 | @api_call.get(RESOURCE_PATH) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/typesense/document.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Document 5 | def initialize(collection_name, document_id, api_call) 6 | @collection_name = collection_name 7 | @document_id = document_id 8 | @api_call = api_call 9 | end 10 | 11 | def retrieve 12 | @api_call.get(endpoint_path) 13 | end 14 | 15 | def delete 16 | @api_call.delete(endpoint_path) 17 | end 18 | 19 | def update(partial_document, options = {}) 20 | @api_call.patch(endpoint_path, partial_document, options) 21 | end 22 | 23 | private 24 | 25 | def endpoint_path 26 | "#{Collections::RESOURCE_PATH}/#{URI.encode_www_form_component(@collection_name)}#{Documents::RESOURCE_PATH}/#{URI.encode_www_form_component(@document_id)}" 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/typesense/documents.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'oj' 4 | 5 | module Typesense 6 | class Documents 7 | RESOURCE_PATH = '/documents' 8 | 9 | def initialize(collection_name, api_call) 10 | @collection_name = collection_name 11 | @api_call = api_call 12 | @documents = {} 13 | end 14 | 15 | def create(document, options = {}) 16 | @api_call.post(endpoint_path, document, options) 17 | end 18 | 19 | def upsert(document, options = {}) 20 | @api_call.post(endpoint_path, document, options.merge(action: :upsert)) 21 | end 22 | 23 | def update(document, options = {}) 24 | if options['filter_by'] || options[:filter_by] 25 | @api_call.patch(endpoint_path, document, options) 26 | else 27 | @api_call.post(endpoint_path, document, options.merge(action: :update)) 28 | end 29 | end 30 | 31 | def create_many(documents, options = {}) 32 | @api_call.logger.warn('#create_many is deprecated and will be removed in a future version. Use #import instead, which now takes both an array of documents or a JSONL string of documents') 33 | import(documents, options) 34 | end 35 | 36 | # @param [Array,String] documents An array of document hashes or a JSONL string of documents. 37 | def import(documents, options = {}) 38 | documents_in_jsonl_format = if documents.is_a?(Array) 39 | documents.map { |document| Oj.dump(document, mode: :compat) }.join("\n") 40 | else 41 | documents 42 | end 43 | 44 | results_in_jsonl_format = @api_call.perform_request( 45 | 'post', 46 | endpoint_path('import'), 47 | query_parameters: options, 48 | body_parameters: documents_in_jsonl_format, 49 | additional_headers: { 'Content-Type' => 'text/plain' } 50 | ) 51 | 52 | if documents.is_a?(Array) 53 | results_in_jsonl_format.split("\n").map do |r| 54 | Oj.load(r) 55 | rescue Oj::ParseError => e 56 | { 57 | 'success' => false, 58 | 'exception' => e.class.name, 59 | 'error' => e.message, 60 | 'json' => r 61 | } 62 | end 63 | else 64 | results_in_jsonl_format 65 | end 66 | end 67 | 68 | def export(options = {}) 69 | @api_call.get(endpoint_path('export'), options) 70 | end 71 | 72 | def search(search_parameters) 73 | @api_call.get(endpoint_path('search'), search_parameters) 74 | end 75 | 76 | def [](document_id) 77 | @documents[document_id] ||= Document.new(@collection_name, document_id, @api_call) 78 | end 79 | 80 | def delete(query_parameters = {}) 81 | @api_call.delete(endpoint_path, query_parameters) 82 | end 83 | 84 | def truncate 85 | @api_call.delete(endpoint_path, { truncate: true }) 86 | end 87 | 88 | private 89 | 90 | def endpoint_path(operation = nil) 91 | "#{Collections::RESOURCE_PATH}/#{URI.encode_www_form_component(@collection_name)}#{Documents::RESOURCE_PATH}#{operation.nil? ? '' : "/#{operation}"}" 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/typesense/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Error < StandardError 5 | attr_reader :data 6 | 7 | def initialize(data) 8 | @data = data 9 | 10 | super 11 | end 12 | 13 | class MissingConfiguration < Error 14 | end 15 | 16 | class ObjectAlreadyExists < Error 17 | end 18 | 19 | class ObjectNotFound < Error 20 | end 21 | 22 | class ObjectUnprocessable < Error 23 | end 24 | 25 | class RequestMalformed < Error 26 | end 27 | 28 | class RequestUnauthorized < Error 29 | end 30 | 31 | class ServerError < Error 32 | end 33 | 34 | class HTTPStatus0Error < Error 35 | end 36 | 37 | class TimeoutError < Error 38 | end 39 | 40 | class NoMethodError < ::NoMethodError 41 | end 42 | 43 | class HTTPError < Error 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/typesense/health.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Health 5 | RESOURCE_PATH = '/health' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | end 10 | 11 | def retrieve 12 | @api_call.get(RESOURCE_PATH) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/typesense/key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Key 5 | def initialize(id, api_call) 6 | @id = id 7 | @api_call = api_call 8 | end 9 | 10 | def retrieve 11 | @api_call.get(endpoint_path) 12 | end 13 | 14 | def delete 15 | @api_call.delete(endpoint_path) 16 | end 17 | 18 | private 19 | 20 | def endpoint_path 21 | "#{Keys::RESOURCE_PATH}/#{URI.encode_www_form_component(@id)}" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/typesense/keys.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'base64' 4 | require 'json' 5 | require 'openssl' 6 | 7 | module Typesense 8 | class Keys 9 | RESOURCE_PATH = '/keys' 10 | 11 | def initialize(api_call) 12 | @api_call = api_call 13 | @keys = {} 14 | end 15 | 16 | def create(parameters) 17 | @api_call.post(RESOURCE_PATH, parameters) 18 | end 19 | 20 | def retrieve 21 | @api_call.get(RESOURCE_PATH) 22 | end 23 | 24 | def generate_scoped_search_key(search_key, parameters) 25 | parameters_json = JSON.dump(parameters) 26 | digest = Base64.encode64(OpenSSL::HMAC.digest('sha256', search_key, parameters_json)).gsub("\n", '') 27 | key_prefix = search_key[0...4] 28 | raw_scoped_key = "#{digest}#{key_prefix}#{parameters_json}" 29 | Base64.encode64(raw_scoped_key).gsub("\n", '') 30 | end 31 | 32 | def [](id) 33 | @keys[id] ||= Key.new(id, @api_call) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/typesense/metrics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Metrics 5 | RESOURCE_PATH = '/metrics.json' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | end 10 | 11 | def retrieve 12 | @api_call.get(RESOURCE_PATH) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/typesense/multi_search.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class MultiSearch 5 | RESOURCE_PATH = '/multi_search' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | end 10 | 11 | def perform(searches, query_params = {}) 12 | @api_call.post(RESOURCE_PATH, searches, query_params) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/typesense/operations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Operations 5 | RESOURCE_PATH = '/operations' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | end 10 | 11 | def perform(operation_name, query_params = {}) 12 | @api_call.post("#{RESOURCE_PATH}/#{operation_name}", {}, query_params) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/typesense/override.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Override 5 | def initialize(collection_name, override_id, api_call) 6 | @collection_name = collection_name 7 | @override_id = override_id 8 | @api_call = api_call 9 | end 10 | 11 | def retrieve 12 | @api_call.get(endpoint_path) 13 | end 14 | 15 | def delete 16 | @api_call.delete(endpoint_path) 17 | end 18 | 19 | private 20 | 21 | def endpoint_path 22 | "#{Collections::RESOURCE_PATH}/#{URI.encode_www_form_component(@collection_name)}#{Overrides::RESOURCE_PATH}/#{URI.encode_www_form_component(@override_id)}" 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/typesense/overrides.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Overrides 5 | RESOURCE_PATH = '/overrides' 6 | 7 | def initialize(collection_name, api_call) 8 | @collection_name = collection_name 9 | @api_call = api_call 10 | @overrides = {} 11 | end 12 | 13 | def upsert(override_id, params) 14 | @api_call.put(endpoint_path(override_id), params) 15 | end 16 | 17 | def retrieve 18 | @api_call.get(endpoint_path) 19 | end 20 | 21 | def [](override_id) 22 | @overrides[override_id] ||= Override.new(@collection_name, override_id, @api_call) 23 | end 24 | 25 | private 26 | 27 | def endpoint_path(operation = nil) 28 | "#{Collections::RESOURCE_PATH}/#{URI.encode_www_form_component(@collection_name)}#{Overrides::RESOURCE_PATH}#{operation.nil? ? '' : "/#{URI.encode_www_form_component(operation)}"}" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/typesense/preset.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Preset 5 | def initialize(preset_name, api_call) 6 | @preset_name = preset_name 7 | @api_call = api_call 8 | end 9 | 10 | def retrieve 11 | @api_call.get(endpoint_path) 12 | end 13 | 14 | def delete 15 | @api_call.delete(endpoint_path) 16 | end 17 | 18 | private 19 | 20 | def endpoint_path 21 | "#{Presets::RESOURCE_PATH}/#{URI.encode_www_form_component(@preset_name)}" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/typesense/presets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Presets 5 | RESOURCE_PATH = '/presets' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | @presets = {} 10 | end 11 | 12 | def upsert(preset_name, params) 13 | @api_call.put(endpoint_path(preset_name), params) 14 | end 15 | 16 | def retrieve 17 | @api_call.get(endpoint_path) 18 | end 19 | 20 | def [](preset_name) 21 | @presets[preset_name] ||= Preset.new(preset_name, @api_call) 22 | end 23 | 24 | private 25 | 26 | def endpoint_path(operation = nil) 27 | "#{Presets::RESOURCE_PATH}#{operation.nil? ? '' : "/#{URI.encode_www_form_component(operation)}"}" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/typesense/stats.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Stats 5 | RESOURCE_PATH = '/stats.json' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | end 10 | 11 | def retrieve 12 | @api_call.get(RESOURCE_PATH) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/typesense/stemming.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Stemming 5 | RESOURCE_PATH = '/stemming' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | end 10 | 11 | def dictionaries 12 | @dictionaries ||= StemmingDictionaries.new(@api_call) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/typesense/stemming_dictionaries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class StemmingDictionaries 5 | RESOURCE_PATH = '/stemming/dictionaries' 6 | 7 | def initialize(api_call) 8 | @api_call = api_call 9 | @dictionaries = {} 10 | end 11 | 12 | def upsert(dict_id, words_and_roots_combinations) 13 | words_and_roots_combinations_in_jsonl = if words_and_roots_combinations.is_a?(Array) 14 | words_and_roots_combinations.map { |combo| Oj.dump(combo, mode: :compat) }.join("\n") 15 | else 16 | words_and_roots_combinations 17 | end 18 | 19 | result_in_jsonl = @api_call.perform_request( 20 | 'post', 21 | endpoint_path('import'), 22 | query_parameters: { id: dict_id }, 23 | body_parameters: words_and_roots_combinations_in_jsonl, 24 | additional_headers: { 'Content-Type' => 'text/plain' } 25 | ) 26 | 27 | if words_and_roots_combinations.is_a?(Array) 28 | result_in_jsonl.split("\n").map { |r| Oj.load(r) } 29 | else 30 | result_in_jsonl 31 | end 32 | end 33 | 34 | def retrieve 35 | response = @api_call.get(endpoint_path) 36 | response || { 'dictionaries' => [] } 37 | end 38 | 39 | def [](dict_id) 40 | @dictionaries[dict_id] ||= StemmingDictionary.new(dict_id, @api_call) 41 | end 42 | 43 | private 44 | 45 | def endpoint_path(operation = nil) 46 | "#{StemmingDictionaries::RESOURCE_PATH}#{operation.nil? ? '' : "/#{URI.encode_www_form_component(operation)}"}" 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/typesense/stemming_dictionary.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class StemmingDictionary 5 | def initialize(id, api_call) 6 | @dict_id = id 7 | @api_call = api_call 8 | end 9 | 10 | def retrieve 11 | @api_call.get(endpoint_path) 12 | end 13 | 14 | private 15 | 16 | def endpoint_path 17 | "#{StemmingDictionaries::RESOURCE_PATH}/#{URI.encode_www_form_component(@dict_id)}" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/typesense/synonym.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Synonym 5 | def initialize(collection_name, synonym_id, api_call) 6 | @collection_name = collection_name 7 | @synonym_id = synonym_id 8 | @api_call = api_call 9 | end 10 | 11 | def retrieve 12 | @api_call.get(endpoint_path) 13 | end 14 | 15 | def delete 16 | @api_call.delete(endpoint_path) 17 | end 18 | 19 | private 20 | 21 | def endpoint_path 22 | "#{Collections::RESOURCE_PATH}/#{URI.encode_www_form_component(@collection_name)}#{Synonyms::RESOURCE_PATH}/#{URI.encode_www_form_component(@synonym_id)}" 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/typesense/synonyms.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | class Synonyms 5 | RESOURCE_PATH = '/synonyms' 6 | 7 | def initialize(collection_name, api_call) 8 | @collection_name = collection_name 9 | @api_call = api_call 10 | @synonyms = {} 11 | end 12 | 13 | def upsert(synonym_id, params) 14 | @api_call.put(endpoint_path(synonym_id), params) 15 | end 16 | 17 | def retrieve 18 | @api_call.get(endpoint_path) 19 | end 20 | 21 | def [](synonym_id) 22 | @synonyms[synonym_id] ||= Synonym.new(@collection_name, synonym_id, @api_call) 23 | end 24 | 25 | private 26 | 27 | def endpoint_path(operation = nil) 28 | "#{Collections::RESOURCE_PATH}/#{URI.encode_www_form_component(@collection_name)}#{Synonyms::RESOURCE_PATH}#{operation.nil? ? '' : "/#{URI.encode_www_form_component(operation)}"}" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/typesense/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Typesense 4 | VERSION = '3.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start do 5 | add_group 'lib', 'lib' 6 | 7 | add_filter 'spec' 8 | end 9 | 10 | require 'bundler/setup' 11 | require 'webmock/rspec' 12 | require 'typesense' 13 | 14 | RSpec.configure do |config| 15 | # Enable flags like --only-failures and --next-failure 16 | config.example_status_persistence_file_path = '.rspec_status' 17 | 18 | # Disable RSpec exposing methods globally on `Module` and `main` 19 | config.disable_monkey_patching! 20 | 21 | config.expect_with :rspec do |c| 22 | c.syntax = :expect 23 | end 24 | 25 | config.expose_dsl_globally = true 26 | 27 | # This config option will be enabled by default on RSpec 4, 28 | # but for reasons of backwards compatibility, you have to 29 | # set it on RSpec 3. 30 | # 31 | # It causes the host group and examples to inherit metadata 32 | # from the shared context. 33 | config.shared_context_metadata_behavior = :apply_to_host_groups 34 | end 35 | -------------------------------------------------------------------------------- /spec/typesense/alias_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Alias do 7 | subject(:client) { typesense } 8 | 9 | let(:books_alias) { typesense.aliases['books'] } 10 | 11 | include_context 'with Typesense configuration' 12 | 13 | describe '#retrieve' do 14 | it 'returns the specified alias' do 15 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/aliases/books', typesense.configuration.nodes[0])) 16 | .with(headers: { 17 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 18 | 'Content-Type' => 'application/json' 19 | }) 20 | .to_return(status: 200, body: JSON.dump('collection_name' => 'books_january'), headers: { 'Content-Type': 'application/json' }) 21 | 22 | result = books_alias.retrieve 23 | 24 | expect(result).to eq('collection_name' => 'books_january') 25 | end 26 | 27 | it 'returns the specified alias with URI encoded name' do 28 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/aliases/abc123%3F%3D%2B-_!%40%23%24%25%5E%26*()~+%2F', typesense.configuration.nodes[0])) 29 | .with(headers: { 30 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 31 | 'Content-Type' => 'application/json' 32 | }) 33 | .to_return(status: 200, body: JSON.dump('collection_name' => 'books_january'), headers: { 'Content-Type': 'application/json' }) 34 | 35 | result = client.aliases["abc123?=+-_!@\#$%^&*()~ /"].retrieve 36 | 37 | expect(result).to eq('collection_name' => 'books_january') 38 | end 39 | end 40 | 41 | describe '#delete' do 42 | it 'deletes the specified collection' do 43 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/aliases/books', typesense.configuration.nodes[0])) 44 | .with(headers: { 45 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 46 | }) 47 | .to_return(status: 200, body: JSON.dump('collection_name' => 'books_january'), headers: { 'Content-Type': 'application/json' }) 48 | 49 | result = books_alias.delete 50 | 51 | expect(result).to eq('collection_name' => 'books_january') 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/typesense/aliases_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Aliases do 7 | subject(:aliases) { typesense.aliases } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | describe '#upsert' do 12 | it 'upserts an alias and returns it' do 13 | stub_request(:put, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/aliases/books', typesense.configuration.nodes[0])) 14 | .with(body: JSON.dump('collection_name' => 'books_january'), 15 | headers: { 16 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 17 | 'Content-Type' => 'application/json' 18 | }) 19 | .to_return(status: 200, body: JSON.dump('collection_name' => 'books_january'), headers: { 'Content-Type': 'application/json' }) 20 | 21 | result = aliases.upsert('books', 'collection_name' => 'books_january') 22 | 23 | expect(result).to eq('collection_name' => 'books_january') 24 | end 25 | end 26 | 27 | describe '#retrieve' do 28 | it 'returns all aliases' do 29 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/aliases', typesense.configuration.nodes[0])) 30 | .with(headers: { 31 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 32 | 'Content-Type' => 'application/json' 33 | }) 34 | .to_return(status: 200, 35 | body: JSON.dump([{ 'collection_name' => 'books_january' }]), 36 | headers: { 37 | 'Content-Type': 'application/json' 38 | }) 39 | 40 | result = aliases.retrieve 41 | 42 | expect(result).to eq([{ 'collection_name' => 'books_january' }]) 43 | end 44 | end 45 | 46 | describe '#[]' do 47 | it 'returns an alias object' do 48 | result = aliases['books'] 49 | 50 | expect(result).to be_a(Typesense::Alias) 51 | expect(result.instance_variable_get(:@name)).to eq('books') 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/typesense/analytics_rule_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::AnalyticsRule do 7 | subject(:analytics_rule) { typesense.analytics.rules['search_suggestions'] } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:analytics_rule_data) do 12 | { 13 | 'name' => 'search_suggestions', 14 | 'type' => 'popular_queries', 15 | 'params' => { 16 | 'source' => { 'collections' => ['products'] }, 17 | 'destination' => { 'collection' => 'products_top_queries' }, 18 | 'limit' => 100 19 | } 20 | } 21 | end 22 | 23 | describe '#retrieve' do 24 | it 'returns the specified analytics rule' do 25 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/analytics/rules/search_suggestions', typesense.configuration.nodes[0])) 26 | .with(headers: { 27 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 28 | 'Content-Type': 'application/json' 29 | }) 30 | .to_return(status: 200, body: JSON.dump(analytics_rule_data), headers: { 'Content-Type': 'application/json' }) 31 | 32 | result = analytics_rule.retrieve 33 | 34 | expect(result).to eq(analytics_rule_data) 35 | end 36 | end 37 | 38 | describe '#delete' do 39 | it 'deletes the specified analytics rule' do 40 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/analytics/rules/search_suggestions', typesense.configuration.nodes[0])) 41 | .with(headers: { 42 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 43 | }) 44 | .to_return(status: 200, body: JSON.dump('name' => 'search_suggestions'), headers: { 'Content-Type': 'application/json' }) 45 | 46 | result = analytics_rule.delete 47 | 48 | expect(result).to eq('name' => 'search_suggestions') 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/typesense/analytics_rules_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::AnalyticsRules do 7 | subject(:analytics_rules) { typesense.analytics.rules } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:analytics_rule) do 12 | { 13 | 'name' => 'search_suggestions', 14 | 'type' => 'popular_queries', 15 | 'params' => { 16 | 'source' => { 'collections' => ['products'] }, 17 | 'destination' => { 'collection' => 'products_top_queries' }, 18 | 'limit' => 100 19 | } 20 | } 21 | end 22 | 23 | describe '#upsert' do 24 | it 'creates a rule and returns it' do 25 | stub_request(:put, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/analytics/rules/search_suggestions', typesense.configuration.nodes[0])) 26 | .with(body: analytics_rule, 27 | headers: { 28 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 29 | 'Content-Type' => 'application/json' 30 | }) 31 | .to_return(status: 201, body: JSON.dump(analytics_rule), headers: { 'Content-Type': 'application/json' }) 32 | 33 | result = typesense.analytics.rules.upsert(analytics_rule['name'], analytics_rule) 34 | 35 | expect(result).to eq(analytics_rule) 36 | end 37 | end 38 | 39 | describe '#retrieve' do 40 | it 'retrieves all analytics rules' do 41 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/analytics/rules', typesense.configuration.nodes[0])) 42 | .with(headers: { 43 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 44 | 'Content-Type' => 'application/json' 45 | }) 46 | .to_return(status: 201, body: JSON.dump([analytics_rule]), headers: { 'Content-Type': 'application/json' }) 47 | 48 | result = analytics_rules.retrieve 49 | 50 | expect(result).to eq([analytics_rule]) 51 | end 52 | end 53 | 54 | describe '#[]' do 55 | it 'creates an analytics rule object and returns it' do 56 | result = analytics_rules['search_suggestions'] 57 | 58 | expect(result).to be_a(Typesense::AnalyticsRule) 59 | expect(result.instance_variable_get(:@rule_name)).to eq('search_suggestions') 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/typesense/api_call_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | require 'timecop' 6 | 7 | describe Typesense::ApiCall do 8 | subject(:api_call) { described_class.new(typesense.configuration) } 9 | 10 | include_context 'with Typesense configuration' 11 | 12 | shared_examples 'General error handling' do |method| 13 | { 14 | 400 => Typesense::Error::RequestMalformed, 15 | 401 => Typesense::Error::RequestUnauthorized, 16 | 404 => Typesense::Error::ObjectNotFound, 17 | 409 => Typesense::Error::ObjectAlreadyExists, 18 | 422 => Typesense::Error::ObjectUnprocessable, 19 | 500 => Typesense::Error::ServerError, 20 | 300 => Typesense::Error 21 | }.each do |response_code, error| 22 | it "throws #{error} for a #{response_code} response" do 23 | stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[0])) 24 | .to_return(status: response_code, 25 | body: JSON.dump('message' => 'Error Message'), 26 | headers: { 'Content-Type' => 'application/json' }) 27 | 28 | stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[1])) 29 | .to_return(status: response_code, 30 | body: JSON.dump('message' => 'Error Message'), 31 | headers: { 'Content-Type' => 'application/json' }) 32 | 33 | stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[2])) 34 | .to_return(status: response_code) 35 | 36 | expect { api_call.send(method, '') }.to raise_error error 37 | end 38 | end 39 | end 40 | 41 | shared_examples 'Node selection' do |method| 42 | it 'does not retry requests when nodes are healthy' do 43 | node_0_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[0])) 44 | .to_return(status: 422, 45 | body: JSON.dump('message' => 'Object unprocessable'), 46 | headers: { 'Content-Type' => 'application/json' }) 47 | 48 | node_1_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[1])) 49 | .to_return(status: 409, 50 | body: JSON.dump('message' => 'Object already exists'), 51 | headers: { 'Content-Type' => 'application/json' }) 52 | 53 | node_2_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[2])) 54 | .to_return(status: 500, 55 | body: JSON.dump('message' => 'Error Message'), 56 | headers: { 'Content-Type' => 'application/json' }) 57 | 58 | expect { subject.send(method, '/') }.to raise_error(Typesense::Error::ObjectUnprocessable) 59 | expect(node_0_stub).to have_been_requested 60 | expect(node_1_stub).not_to have_been_requested 61 | expect(node_2_stub).not_to have_been_requested 62 | end 63 | 64 | it 'raises an error when no nodes are healthy' do 65 | node_0_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[0])) 66 | .to_return(status: 500, 67 | body: JSON.dump('message' => 'Error Message'), 68 | headers: { 'Content-Type' => 'application/json' }) 69 | 70 | node_1_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[1])) 71 | .to_return(status: 500, 72 | body: JSON.dump('message' => 'Error Message'), 73 | headers: { 'Content-Type' => 'application/json' }) 74 | 75 | node_2_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[2])) 76 | .to_return(status: 500, 77 | body: JSON.dump('message' => 'Error Message'), 78 | headers: { 'Content-Type' => 'application/json' }) 79 | 80 | expect { subject.send(method, '/') }.to raise_error(Typesense::Error::ServerError) 81 | expect(node_0_stub).to have_been_requested.times(2) # 4 tries, for 3 nodes by default 82 | expect(node_1_stub).to have_been_requested 83 | expect(node_2_stub).to have_been_requested 84 | end 85 | 86 | it 'selects the next available node when there is a server error' do 87 | node_0_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[0])) 88 | .to_return(status: 500, 89 | body: JSON.dump('message' => 'Error Message'), 90 | headers: { 'Content-Type' => 'application/json' }) 91 | 92 | node_1_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[1])) 93 | .to_return(status: 500, 94 | body: JSON.dump('message' => 'Error Message'), 95 | headers: { 'Content-Type' => 'application/json' }) 96 | 97 | node_2_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[2])) 98 | .to_return(status: 200, 99 | body: JSON.dump('message' => 'Success'), 100 | headers: { 'Content-Type' => 'application/json' }) 101 | 102 | expect { subject.send(method, '/') }.not_to raise_error 103 | expect(node_0_stub).to have_been_requested 104 | expect(node_1_stub).to have_been_requested 105 | expect(node_2_stub).to have_been_requested 106 | end 107 | 108 | it 'selects the next available node when there is a connection timeout' do 109 | node_0_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[0])).to_timeout 110 | node_1_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[1])).to_timeout 111 | node_2_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[2])) 112 | .to_return(status: 200, 113 | body: JSON.dump('message' => 'Success'), 114 | headers: { 'Content-Type' => 'application/json' }) 115 | 116 | expect { subject.send(method, '/') }.not_to raise_error 117 | expect(node_0_stub).to have_been_requested 118 | expect(node_1_stub).to have_been_requested 119 | expect(node_2_stub).to have_been_requested 120 | end 121 | 122 | it 'remove unhealthy nodes out of rotation, until threshold' do 123 | node_0_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[0])).to_timeout 124 | node_1_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[1])).to_timeout 125 | node_2_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[2])) 126 | .to_return(status: 200, 127 | body: JSON.dump('message' => 'Success'), 128 | headers: { 'Content-Type' => 'application/json' }) 129 | current_time = Time.now 130 | Timecop.freeze(current_time) do 131 | subject.send(method, '/') # Two nodes are unhealthy after this 132 | subject.send(method, '/') # Request should have been made to node 2 133 | subject.send(method, '/') # Request should have been made to node 2 134 | end 135 | Timecop.freeze(current_time + 5) do 136 | subject.send(method, '/') # Request should have been made to node 2 137 | end 138 | Timecop.freeze(current_time + 65) do 139 | subject.send(method, '/') # Request should have been made to node 2, since node 0 and node 1 are still unhealthy, though they were added back into rotation 140 | end 141 | stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[0])) 142 | Timecop.freeze(current_time + 125) do 143 | subject.send(method, '/') # Request should have been made to node 0, since it is now healthy and the unhealthy threshold was exceeded 144 | end 145 | 146 | expect(node_0_stub).to have_been_requested.times(3) 147 | expect(node_1_stub).to have_been_requested.times(2) 148 | expect(node_2_stub).to have_been_requested.times(5) 149 | end 150 | 151 | describe 'when nearest_node is specified' do 152 | let(:typesense) do 153 | Typesense::Client.new( 154 | api_key: 'abcd', 155 | nearest_node: { 156 | host: 'nearestNode', 157 | port: 6108, 158 | protocol: 'http' 159 | }, 160 | nodes: [ 161 | { 162 | host: 'node0', 163 | port: 8108, 164 | protocol: 'http' 165 | }, 166 | { 167 | host: 'node1', 168 | port: 8108, 169 | protocol: 'http' 170 | }, 171 | { 172 | host: 'node2', 173 | port: 8108, 174 | protocol: 'http' 175 | } 176 | ], 177 | connection_timeout_seconds: 10, 178 | retry_interval_seconds: 0.01 179 | # log_level: Logger::DEBUG 180 | ) 181 | end 182 | 183 | it 'uses the nearest_node if it is present and healthy, otherwise fallsback to regular nodes' do 184 | nearest_node_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nearest_node)).to_timeout 185 | node_0_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[0])).to_timeout 186 | node_1_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[1])).to_timeout 187 | node_2_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[2])) 188 | .to_return(status: 200, 189 | body: JSON.dump('message' => 'Success'), 190 | headers: { 'Content-Type' => 'application/json' }) 191 | current_time = Time.now 192 | Timecop.freeze(current_time) do 193 | subject.send(method, '/') # Node nearest_node, Node 0 and Node 1 are marked as unhealthy after this, request should have been made to Node 2 194 | subject.send(method, '/') # Request should have been made to node 2 195 | subject.send(method, '/') # Request should have been made to node 2 196 | end 197 | Timecop.freeze(current_time + 5) do 198 | subject.send(method, '/') # Request should have been made to node 2 199 | end 200 | Timecop.freeze(current_time + 65) do 201 | subject.send(method, '/') # Request should have been attempted to nearest_node, Node 0 and Node 1, but finally made to Node 2 (since nearest_node, Node 0 and Node 1 are still unhealthy, though they were added back into rotation after the threshold) 202 | end 203 | # Let request to nearest_node succeed 204 | stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nearest_node)) 205 | Timecop.freeze(current_time + 125) do 206 | subject.send(method, '/') # Request should have been made to node nearest_node, since it is now healthy and the unhealthy threshold was exceeded 207 | subject.send(method, '/') # Request should have been made to node nearest_node, since no roundrobin if it is present and healthy 208 | subject.send(method, '/') # Request should have been made to node nearest_node, since no roundrobin if it is present and healthy 209 | end 210 | 211 | expect(nearest_node_stub).to have_been_requested.times(5) 212 | expect(node_0_stub).to have_been_requested.times(2) 213 | expect(node_1_stub).to have_been_requested.times(2) 214 | expect(node_2_stub).to have_been_requested.times(5) 215 | end 216 | 217 | it 'raises an error when no nodes are healthy' do 218 | nearest_node_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nearest_node)) 219 | .to_return(status: 500, 220 | body: JSON.dump('message' => 'Error Message'), 221 | headers: { 'Content-Type' => 'application/json' }) 222 | 223 | node_0_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[0])) 224 | .to_return(status: 500, 225 | body: JSON.dump('message' => 'Error Message'), 226 | headers: { 'Content-Type' => 'application/json' }) 227 | 228 | node_1_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[1])) 229 | .to_return(status: 500, 230 | body: JSON.dump('message' => 'Error Message'), 231 | headers: { 'Content-Type' => 'application/json' }) 232 | 233 | node_2_stub = stub_request(:any, described_class.new(typesense.configuration).send(:uri_for, '/', typesense.configuration.nodes[2])) 234 | .to_return(status: 500, 235 | body: JSON.dump('message' => 'Error Message'), 236 | headers: { 'Content-Type' => 'application/json' }) 237 | 238 | expect { subject.send(method, '/') }.to raise_error(Typesense::Error::ServerError) 239 | expect(nearest_node_stub).to have_been_requested 240 | expect(node_0_stub).to have_been_requested.times(2) 241 | expect(node_1_stub).to have_been_requested 242 | expect(node_2_stub).to have_been_requested 243 | end 244 | end 245 | end 246 | 247 | describe '#post' do 248 | it_behaves_like 'General error handling', :post 249 | it_behaves_like 'Node selection', :post 250 | end 251 | 252 | describe '#get' do 253 | it_behaves_like 'General error handling', :get 254 | it_behaves_like 'Node selection', :get 255 | end 256 | 257 | describe '#delete' do 258 | it_behaves_like 'General error handling', :delete 259 | it_behaves_like 'Node selection', :delete 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /spec/typesense/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | 5 | describe Typesense::Client do 6 | subject(:typesence) { typesense } 7 | 8 | include_context 'with Typesense configuration' 9 | 10 | describe '#collections' do 11 | it 'creates a collections object and returns it' do 12 | result = typesense.collections 13 | 14 | expect(result).to be_a(Typesense::Collections) 15 | end 16 | end 17 | 18 | describe '#debug' do 19 | it 'creates a debug object and returns it' do 20 | result = typesense.debug 21 | 22 | expect(result).to be_a(Typesense::Debug) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/typesense/collection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Collection do 7 | subject(:companies_collection) { typesense.collections['companies'] } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:company_schema) do 12 | { 13 | 'name' => 'companies', 14 | 'num_documents' => 0, 15 | 'fields' => [ 16 | { 17 | 'name' => 'company_name', 18 | 'type' => 'string', 19 | 'facet' => false 20 | }, 21 | { 22 | 'name' => 'num_employees', 23 | 'type' => 'int32', 24 | 'facet' => false 25 | }, 26 | { 27 | 'name' => 'country', 28 | 'type' => 'string', 29 | 'facet' => true 30 | } 31 | ], 32 | 'token_ranking_field' => 'num_employees' 33 | } 34 | end 35 | 36 | describe '#retrieve' do 37 | it 'returns the specified collection' do 38 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies', typesense.configuration.nodes[0])) 39 | .with(headers: { 40 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 41 | 'Content-Type' => 'application/json' 42 | }) 43 | .to_return(status: 200, body: JSON.dump(company_schema), headers: { 'Content-Type': 'application/json' }) 44 | 45 | result = companies_collection.retrieve 46 | 47 | expect(result).to eq(company_schema) 48 | end 49 | end 50 | 51 | describe '#update' do 52 | it 'updates the specified collection' do 53 | update_schema = { 54 | 'fields' => [ 55 | 'name' => 'field', 'drop' => true 56 | ] 57 | } 58 | stub_request(:patch, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies', typesense.configuration.nodes[0])) 59 | .with( 60 | body: update_schema, 61 | headers: { 62 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 63 | 'Content-Type' => 'application/json' 64 | } 65 | ) 66 | .to_return(status: 200, body: JSON.dump(company_schema), headers: { 'Content-Type': 'application/json' }) 67 | 68 | result = companies_collection.update(update_schema) 69 | 70 | expect(result).to eq(company_schema) 71 | end 72 | end 73 | 74 | describe '#delete' do 75 | it 'deletes the specified collection' do 76 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies', typesense.configuration.nodes[0])) 77 | .with(headers: { 78 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 79 | }) 80 | .to_return(status: 200, body: JSON.dump(company_schema), headers: { 'Content-Type': 'application/json' }) 81 | 82 | result = companies_collection.delete 83 | 84 | expect(result).to eq(company_schema) 85 | end 86 | end 87 | 88 | describe '#documents' do 89 | it 'creates a documents object and returns it' do 90 | result = companies_collection.documents 91 | 92 | expect(result).to be_a(Typesense::Documents) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/typesense/collections_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Collections do 7 | subject(:collections) { typesense.collections } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:company_schema) do 12 | { 13 | 'name' => 'companies', 14 | 'num_documents' => 0, 15 | 'fields' => [ 16 | { 17 | 'name' => 'company_name', 18 | 'type' => 'string', 19 | 'facet' => false 20 | }, 21 | { 22 | 'name' => 'num_employees', 23 | 'type' => 'int32', 24 | 'facet' => false 25 | }, 26 | { 27 | 'name' => 'country', 28 | 'type' => 'string', 29 | 'facet' => true 30 | } 31 | ], 32 | 'token_ranking_field' => 'num_employees' 33 | } 34 | end 35 | 36 | describe '#create' do 37 | it 'creates a collection and returns it' do 38 | # since num_documents is a read-only attribute 39 | schema_for_creation = company_schema.reject { |key, _| key == 'num_documents' } 40 | 41 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections', typesense.configuration.nodes[0])) 42 | .with(body: schema_for_creation, 43 | headers: { 44 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 45 | 'Content-Type' => 'application/json' 46 | }) 47 | .to_return(status: 200, body: JSON.dump(company_schema), headers: { 'Content-Type': 'application/json' }) 48 | 49 | result = collections.create(schema_for_creation) 50 | 51 | expect(result).to eq(company_schema) 52 | end 53 | 54 | context 'with integration', :integration do 55 | let(:integration_schema) do 56 | { 57 | 'name' => 'integration_companies', 58 | 'fields' => [ 59 | { 60 | 'name' => 'company_name', 61 | 'type' => 'string', 62 | 'facet' => false 63 | }, 64 | { 65 | 'name' => 'num_employees', 66 | 'type' => 'int32', 67 | 'facet' => false 68 | }, 69 | { 70 | 'name' => 'country', 71 | 'type' => 'string', 72 | 'facet' => true 73 | } 74 | ], 75 | 'default_sorting_field' => 'num_employees' 76 | } 77 | end 78 | 79 | let(:integration_client) do 80 | Typesense::Client.new( 81 | nodes: [{ 82 | host: 'localhost', 83 | port: '8108', 84 | protocol: 'http' 85 | }], 86 | api_key: 'xyz', 87 | connection_timeout_seconds: 10 88 | ) 89 | end 90 | 91 | let(:expected_fields) do 92 | [ 93 | { 94 | 'name' => 'company_name', 95 | 'type' => 'string', 96 | 'facet' => false, 97 | 'index' => true, 98 | 'infix' => false, 99 | 'locale' => '', 100 | 'optional' => false, 101 | 'sort' => false, 102 | 'stem' => false, 103 | 'stem_dictionary' => '', 104 | 'store' => true 105 | }, 106 | { 107 | 'name' => 'num_employees', 108 | 'type' => 'int32', 109 | 'facet' => false, 110 | 'index' => true, 111 | 'infix' => false, 112 | 'locale' => '', 113 | 'optional' => false, 114 | 'sort' => true, 115 | 'stem' => false, 116 | 'stem_dictionary' => '', 117 | 'store' => true 118 | }, 119 | { 120 | 'name' => 'country', 121 | 'type' => 'string', 122 | 'facet' => true, 123 | 'index' => true, 124 | 'infix' => false, 125 | 'locale' => '', 126 | 'optional' => false, 127 | 'sort' => false, 128 | 'stem' => false, 129 | 'stem_dictionary' => '', 130 | 'store' => true 131 | } 132 | ] 133 | end 134 | 135 | before do 136 | WebMock.disable! 137 | begin 138 | integration_client.collections['integration_companies'].delete 139 | rescue Typesense::Error::ObjectNotFound 140 | # Collection doesn't exist, which is fine 141 | end 142 | end 143 | 144 | after do 145 | begin 146 | integration_client.collections['integration_companies'].delete 147 | rescue Typesense::Error::ObjectNotFound 148 | # Collection doesn't exist, which is fine 149 | end 150 | WebMock.enable! 151 | end 152 | 153 | it 'creates a collection on a real Typesense server' do 154 | result = integration_client.collections.create(integration_schema) 155 | 156 | expect(result['name']).to eq('integration_companies') 157 | expect(result['fields']).to eq(expected_fields) 158 | expect(result['default_sorting_field']).to eq(integration_schema['default_sorting_field']) 159 | expect(result['num_documents']).to eq(0) 160 | end 161 | end 162 | end 163 | 164 | describe '#retrieve' do 165 | it 'returns all collections' do 166 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections', typesense.configuration.nodes[0])) 167 | .with(headers: { 168 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 169 | 'Content-Type' => 'application/json' 170 | }) 171 | .to_return(status: 200, body: JSON.dump([company_schema]), headers: { 'Content-Type': 'application/json' }) 172 | 173 | result = collections.retrieve 174 | 175 | expect(result).to eq([company_schema]) 176 | end 177 | end 178 | 179 | describe '#[]' do 180 | it 'returns a collection object' do 181 | result = collections['companies'] 182 | 183 | expect(result).to be_a(Typesense::Collection) 184 | expect(result.instance_variable_get(:@name)).to eq('companies') 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /spec/typesense/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Configuration do 7 | subject(:configuration) { typesense.configuration } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | describe '#validate!' do 12 | it 'throws an Error if the nodes config is not set' do 13 | typesense.configuration.nodes = nil 14 | 15 | expect { configuration.validate! }.to raise_error Typesense::Error::MissingConfiguration 16 | end 17 | 18 | it 'throws an Error if the api_key config is not set' do 19 | typesense.configuration.api_key = nil 20 | 21 | expect { configuration.validate! }.to raise_error Typesense::Error::MissingConfiguration 22 | end 23 | 24 | %i[protocol host port].each do |config_value| 25 | it "throws an Error if nodes config value for #{config_value} is nil" do 26 | typesense.configuration.nodes[0].send(:[]=, config_value.to_sym, nil) 27 | 28 | expect { configuration.validate! }.to raise_error Typesense::Error::MissingConfiguration 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/typesense/debug_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Debug do 7 | subject(:debug) { typesense.debug } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | describe '#retrieve' do 12 | it 'retrieves debugging information' do 13 | debug_info = { 14 | 'version' => '0.8.0' 15 | } 16 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/debug', typesense.configuration.nodes[0])) 17 | .with(headers: { 18 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 19 | 'Content-Type' => 'application/json' 20 | }) 21 | .to_return(status: 200, body: JSON.dump(debug_info), headers: { 'Content-Type': 'application/json' }) 22 | 23 | result = debug.retrieve 24 | 25 | expect(result).to eq(debug_info) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/typesense/document_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Document do 7 | subject(:document124) { typesense.collections['companies'].documents['124'] } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:company_schema) do 12 | { 13 | 'name' => 'companies', 14 | 'num_documents' => 0, 15 | 'fields' => [ 16 | { 17 | 'name' => 'company_name', 18 | 'type' => 'string', 19 | 'facet' => false 20 | }, 21 | { 22 | 'name' => 'num_employees', 23 | 'type' => 'int32', 24 | 'facet' => false 25 | }, 26 | { 27 | 'name' => 'country', 28 | 'type' => 'string', 29 | 'facet' => true 30 | } 31 | ], 32 | 'token_ranking_field' => 'num_employees' 33 | } 34 | end 35 | 36 | let(:document) do 37 | { 38 | 'id' => '124', 39 | 'company_name' => 'Stark Industries', 40 | 'num_employees' => 5215, 41 | 'country' => 'USA' 42 | } 43 | end 44 | 45 | describe '#retrieve' do 46 | it 'returns the specified document' do 47 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/124', typesense.configuration.nodes[0])) 48 | .with(headers: { 49 | 'Content-Type' => 'application/json', 50 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 51 | }) 52 | .to_return(status: 200, body: JSON.dump(document), headers: { 'Content-Type': 'application/json' }) 53 | 54 | result = document124.retrieve 55 | 56 | expect(result).to eq(document) 57 | end 58 | end 59 | 60 | describe '#update' do 61 | it 'updates the specified document' do 62 | partial_document = { 63 | 'id' => '124', 64 | 'num_employees' => 5200 65 | } 66 | stub_request(:patch, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/124', typesense.configuration.nodes[0])) 67 | .with( 68 | headers: { 69 | 'Content-Type' => 'application/json', 70 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 71 | }, 72 | query: { 73 | dirty_values: 'coerce_or_reject' 74 | } 75 | ) 76 | .to_return(status: 200, body: JSON.dump(partial_document), headers: { 'Content-Type': 'application/json' }) 77 | 78 | result = document124.update(partial_document, dirty_values: 'coerce_or_reject') 79 | 80 | expect(result).to eq(partial_document) 81 | end 82 | end 83 | 84 | describe '#delete' do 85 | it 'deletes the specified document' do 86 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/124', typesense.configuration.nodes[0])) 87 | .with(headers: { 88 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 89 | }) 90 | .to_return(status: 200, body: JSON.dump(document), headers: { 'Content-Type': 'application/json' }) 91 | 92 | result = document124.delete 93 | 94 | expect(result).to eq(document) 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/typesense/documents_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Documents do 7 | subject(:companies_documents) { typesense.collections['companies'].documents } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:company_schema) do 12 | { 13 | 'name' => 'companies', 14 | 'num_documents' => 0, 15 | 'fields' => [ 16 | { 17 | 'name' => 'company_name', 18 | 'type' => 'string', 19 | 'facet' => false 20 | }, 21 | { 22 | 'name' => 'num_employees', 23 | 'type' => 'int32', 24 | 'facet' => false 25 | }, 26 | { 27 | 'name' => 'country', 28 | 'type' => 'string', 29 | 'facet' => true 30 | } 31 | ], 32 | 'token_ranking_field' => 'num_employees' 33 | } 34 | end 35 | 36 | let(:document) do 37 | { 38 | 'id' => '124', 39 | 'company_name' => 'Stark Industries', 40 | 'num_employees' => 5215, 41 | 'country' => 'USA' 42 | } 43 | end 44 | 45 | describe '#create' do 46 | it 'creates creates/indexes a document and returns it' do 47 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents', typesense.configuration.nodes[0])) 48 | .with(body: document, 49 | headers: { 50 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 51 | 'Content-Type' => 'application/json' 52 | }, 53 | query: { 54 | 'dirty_values' => 'coerce_or_reject' 55 | }) 56 | .to_return(status: 200, body: JSON.dump(document), headers: { 'Content-Type': 'application/json' }) 57 | 58 | result = companies_documents.create(document, dirty_values: 'coerce_or_reject') 59 | 60 | expect(result).to eq(document) 61 | end 62 | end 63 | 64 | describe '#update' do 65 | context 'when using update by query' do 66 | it 'updates the document and returns it' do 67 | stub_request(:patch, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents', typesense.configuration.nodes[0])) 68 | .with(body: document, 69 | headers: { 70 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 71 | 'Content-Type' => 'application/json' 72 | }, 73 | query: { 74 | 'filter_by' => 'field:=value', 75 | 'dirty_values' => 'coerce_or_reject' 76 | }) 77 | .to_return(status: 200, body: JSON.dump(document), headers: { 'Content-Type': 'application/json' }) 78 | 79 | result = companies_documents.update(document, dirty_values: 'coerce_or_reject', filter_by: 'field:=value') 80 | 81 | expect(result).to eq(document) 82 | end 83 | end 84 | 85 | it 'updates the document and returns it' do 86 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents', typesense.configuration.nodes[0])) 87 | .with(body: document, 88 | headers: { 89 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 90 | 'Content-Type' => 'application/json' 91 | }, 92 | query: { 93 | 'action' => 'update', 94 | 'dirty_values' => 'coerce_or_reject' 95 | }) 96 | .to_return(status: 200, body: JSON.dump(document), headers: { 'Content-Type': 'application/json' }) 97 | 98 | result = companies_documents.update(document, dirty_values: 'coerce_or_reject') 99 | 100 | expect(result).to eq(document) 101 | end 102 | end 103 | 104 | describe '#upserts' do 105 | it 'upserts the document and returns it' do 106 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents', typesense.configuration.nodes[0])) 107 | .with(body: document, 108 | headers: { 109 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 110 | 'Content-Type' => 'application/json' 111 | }, 112 | query: { 113 | 'action' => 'upsert', 114 | 'dirty_values' => 'coerce_or_reject' 115 | }) 116 | .to_return(status: 200, body: JSON.dump(document), headers: { 'Content-Type': 'application/json' }) 117 | 118 | result = companies_documents.upsert(document, dirty_values: 'coerce_or_reject') 119 | 120 | expect(result).to eq(document) 121 | end 122 | end 123 | 124 | describe '#create_many' do 125 | context 'when no options are specified' do 126 | it 'creates creates/indexes documents in bulk' do 127 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/import', typesense.configuration.nodes[0])) 128 | .with(body: "#{JSON.dump(document)}\n#{JSON.dump(document)}", 129 | headers: { 130 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 131 | }) 132 | .to_return(status: 200, body: JSON.dump({ 'success' => true }), headers: { 'Content-Type': 'text/plain' }) 133 | 134 | result = companies_documents.create_many([document, document]) 135 | 136 | expect(result).to eq([{ 'success' => true }]) 137 | end 138 | end 139 | 140 | context 'when an option is specified' do 141 | it 'creates creates/indexes documents in bulk, with the option' do 142 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/import', typesense.configuration.nodes[0])) 143 | .with(body: "#{JSON.dump(document)}\n#{JSON.dump(document)}", 144 | headers: { 145 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 146 | }, 147 | query: { 148 | 'upsert' => 'true' 149 | }) 150 | .to_return(status: 200, body: JSON.dump({ 'success' => true }), headers: { 'Content-Type': 'text/plain' }) 151 | 152 | result = companies_documents.create_many([document, document], upsert: true) 153 | 154 | expect(result).to eq([{ 'success' => true }]) 155 | end 156 | end 157 | end 158 | 159 | describe '#import' do 160 | context 'when an option is specified' do 161 | it 'passes the option to the API' do 162 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/import', typesense.configuration.nodes[0])) 163 | .with(body: "#{JSON.dump(document)}\n#{JSON.dump(document)}", 164 | headers: { 165 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 166 | }, 167 | query: { 168 | 'action' => 'upsert' 169 | }) 170 | .to_return(status: 200, body: '{}', headers: { 'Content-Type': 'text/plain' }) 171 | 172 | result = companies_documents.import("#{JSON.dump(document)}\n#{JSON.dump(document)}", action: :upsert) 173 | 174 | expect(result).to eq('{}') 175 | end 176 | end 177 | 178 | context 'when an array of docs is passed' do 179 | it 'converts it to JSONL and returns an array of results' do 180 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/import', typesense.configuration.nodes[0])) 181 | .with(body: "#{JSON.dump(document)}\n#{JSON.dump(document)}", 182 | headers: { 183 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 184 | }) 185 | .to_return(status: 200, body: "{}\n{}", headers: { 'Content-Type': 'text/plain' }) 186 | 187 | result = companies_documents.import([document, document]) 188 | 189 | expect(result).to eq([{}, {}]) 190 | end 191 | end 192 | 193 | context 'when a JSONL string is passed' do 194 | it 'sends the string as is and returns a string' do 195 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/import', typesense.configuration.nodes[0])) 196 | .with(body: "#{JSON.dump(document)}\n#{JSON.dump(document)}", 197 | headers: { 198 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 199 | }) 200 | .to_return(status: 200, body: "{}\n{}", headers: { 'Content-Type': 'text/plain' }) 201 | 202 | result = companies_documents.import("#{JSON.dump(document)}\n#{JSON.dump(document)}") 203 | 204 | expect(result).to eq("{}\n{}") 205 | end 206 | end 207 | end 208 | 209 | describe '#export' do 210 | it 'exports all documents in a collection as an array' do 211 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/export', typesense.configuration.nodes[0])) 212 | .with(headers: { 213 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 214 | 'Content-Type' => 'application/json' 215 | }, 216 | query: { 217 | 'include_fields' => 'field1' 218 | }) 219 | .to_return(status: 200, body: "#{JSON.dump(document)}\n#{JSON.dump(document)}") 220 | 221 | result = companies_documents.export(include_fields: 'field1') 222 | 223 | expect(result).to eq("#{JSON.dump(document)}\n#{JSON.dump(document)}") 224 | end 225 | end 226 | 227 | describe '#delete' do 228 | it 'delete documents in a collection' do 229 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents', typesense.configuration.nodes[0])) 230 | .with(headers: { 231 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 232 | 'Content-Type' => 'application/json' 233 | }, 234 | query: { 235 | filter_by: 'field:=value' 236 | }) 237 | .to_return(status: 200, body: '{}', headers: { 'Content-Type': 'application/json' }) 238 | 239 | result = companies_documents.delete(filter_by: 'field:=value') 240 | 241 | expect(result).to eq({}) 242 | end 243 | end 244 | 245 | describe '#truncate' do 246 | it 'truncate documents in a collection' do 247 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents', typesense.configuration.nodes[0])) 248 | .with(headers: { 249 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 250 | 'Content-Type' => 'application/json' 251 | }, 252 | query: { 253 | truncate: true 254 | }) 255 | .to_return(status: 200, body: '{ "num_deleted": 1 }', headers: { 'Content-Type': 'application/json' }) 256 | 257 | result = companies_documents.truncate 258 | 259 | expect(result['num_deleted']).to eq(1) 260 | end 261 | end 262 | 263 | describe '#search' do 264 | let(:search_parameters) do 265 | { 266 | 'q' => 'Stark', 267 | 'query_by' => 'company_name' 268 | } 269 | end 270 | 271 | let(:stubbed_search_result) do 272 | { 273 | 'facet_counts' => [], 274 | 'found' => 0, 275 | 'search_time_ms' => 0, 276 | 'page' => 0, 277 | 'hits' => [ 278 | { 279 | '_highlight' => { 280 | 'company_name' => 'Stark Industries' 281 | }, 282 | 'document' => { 283 | 'id' => '124', 284 | 'company_name' => 'Stark Industries', 285 | 'num_employees' => 5215, 286 | 'country' => 'USA' 287 | } 288 | } 289 | ] 290 | } 291 | end 292 | 293 | it 'search the documents in a collection' do 294 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/search', typesense.configuration.nodes[0])) 295 | .with(headers: { 296 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 297 | 'Content-Type' => 'application/json' 298 | }, 299 | query: search_parameters) 300 | .to_return(status: 200, body: JSON.dump(stubbed_search_result), headers: { 'Content-Type': 'application/json' }) 301 | 302 | result = companies_documents.search(search_parameters) 303 | 304 | expect(result).to eq(stubbed_search_result) 305 | end 306 | end 307 | 308 | describe '#[]' do 309 | it 'creates a document object and returns it' do 310 | result = companies_documents['124'] 311 | 312 | expect(result).to be_a(Typesense::Document) 313 | expect(result.instance_variable_get(:@document_id)).to eq('124') 314 | end 315 | end 316 | end 317 | -------------------------------------------------------------------------------- /spec/typesense/health_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Health do 7 | include_context 'with Typesense configuration' 8 | 9 | describe '#retrieve' do 10 | it 'retrieves health information' do 11 | health_info = { 12 | 'ok' => true 13 | } 14 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/health', typesense.configuration.nodes[0])) 15 | .with(headers: { 16 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 17 | 'Content-Type' => 'application/json' 18 | }) 19 | .to_return(status: 200, body: JSON.dump(health_info), headers: { 'Content-Type': 'application/json' }) 20 | 21 | result = typesense.health.retrieve 22 | 23 | expect(result).to eq(health_info) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/typesense/key_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Key do 7 | subject(:key) { typesense.keys['123'] } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | describe '#retrieve' do 12 | it 'returns the specified key' do 13 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/keys/123', typesense.configuration.nodes[0])) 14 | .with(headers: { 15 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 16 | 'Content-Type' => 'application/json' 17 | }) 18 | .to_return(status: 200, body: JSON.dump({}), headers: { 'Content-Type': 'application/json' }) 19 | 20 | result = key.retrieve 21 | 22 | expect(result).to eq({}) 23 | end 24 | end 25 | 26 | describe '#delete' do 27 | it 'deletes the specified key' do 28 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/keys/123', typesense.configuration.nodes[0])) 29 | .with(headers: { 30 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 31 | }) 32 | .to_return(status: 200, body: JSON.dump({}), headers: { 'Content-Type': 'application/json' }) 33 | 34 | result = key.delete 35 | 36 | expect(result).to eq({}) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/typesense/keys_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Keys do 7 | subject(:keys) { typesense.keys } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | describe '#create' do 12 | it 'creates a key and returns it' do 13 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/keys', typesense.configuration.nodes[0])) 14 | .with(body: JSON.dump('description' => 'Search-only key.', 'actions' => ['documents:search'], 'collections' => ['*']), 15 | headers: { 16 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 17 | 'Content-Type' => 'application/json' 18 | }) 19 | .to_return(status: 200, body: JSON.dump({}), headers: { 'Content-Type': 'application/json' }) 20 | 21 | result = keys.create('description' => 'Search-only key.', 'actions' => ['documents:search'], 'collections' => ['*']) 22 | 23 | expect(result).to eq({}) 24 | end 25 | end 26 | 27 | describe '#retrieve' do 28 | it 'returns all keys' do 29 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/keys', typesense.configuration.nodes[0])) 30 | .with(headers: { 31 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 32 | 'Content-Type' => 'application/json' 33 | }) 34 | .to_return(status: 200, 35 | body: JSON.dump([{ 'description' => 'Search-only key.', 'actions' => ['documents:search'], 'collections' => ['*'] }]), 36 | headers: { 37 | 'Content-Type': 'application/json' 38 | }) 39 | 40 | result = keys.retrieve 41 | 42 | expect(result).to eq([{ 'description' => 'Search-only key.', 'actions' => ['documents:search'], 'collections' => ['*'] }]) 43 | end 44 | end 45 | 46 | describe '#generate_scoped_search_key' do 47 | it 'returns a scoped search key' do 48 | # The following keys were generated and verified to work with an actual Typesense server 49 | # We're only verifying that the algorithm works as expected client-side 50 | search_key = 'RN23GFr1s6jQ9kgSNg2O7fYcAUXU7127' 51 | scoped_search_key = 'SC9sT0hncHFwTHNFc3U3d3psRDZBUGNXQUViQUdDNmRHSmJFQnNnczJ4VT1STjIzeyJmaWx0ZXJfYnkiOiJjb21wYW55X2lkOjEyNCJ9' 52 | result = keys.generate_scoped_search_key(search_key, filter_by: 'company_id:124') 53 | 54 | expect(result).to eq(scoped_search_key) 55 | end 56 | end 57 | 58 | describe '#[]' do 59 | it 'returns an key object' do 60 | result = keys['123'] 61 | 62 | expect(result).to be_a(Typesense::Key) 63 | expect(result.instance_variable_get(:@id)).to eq('123') 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/typesense/metrics_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Metrics do 7 | include_context 'with Typesense configuration' 8 | 9 | describe '#retrieve' do 10 | it 'retrieves cluster metrics' do 11 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/metrics.json', typesense.configuration.nodes[0])) 12 | .with(headers: { 13 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 14 | 'Content-Type' => 'application/json' 15 | }) 16 | .to_return(status: 200, body: '{}', headers: { 'Content-Type': 'application/json' }) 17 | 18 | result = typesense.metrics.retrieve 19 | 20 | expect(result).to eq({}) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/typesense/multi_search_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::MultiSearch do 7 | include_context 'with Typesense configuration' 8 | 9 | describe '#perform' do 10 | it 'does a multi-search request' do 11 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/multi_search', typesense.configuration.nodes[0])) 12 | .with( 13 | headers: { 14 | 'Content-Type' => 'application/json', 15 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 16 | }, 17 | query: hash_including({ param: 'a' }), 18 | body: JSON.dump({ searches: [] }) 19 | ).to_return(status: 200, body: '{}', headers: { 'Content-Type': 'application/json' }) 20 | 21 | result = typesense.multi_search.perform({ searches: [] }, { param: 'a' }) 22 | 23 | expect(result).to eq({}) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/typesense/operations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Operations do 7 | subject(:operations) { typesense.operations } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | describe '#perform' do 12 | it 'performs the specificied operation' do 13 | stub_request(:post, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/operations/snapshot', typesense.configuration.nodes[0])) 14 | .with(headers: { 15 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 16 | 'Content-Type' => 'application/json' 17 | }, 18 | query: { 19 | snapshot_path: '/tmp/dbsnap' 20 | }) 21 | .to_return(status: 200, body: '{}', headers: { 'Content-Type': 'application/json' }) 22 | 23 | result = operations.perform(:snapshot, { snapshot_path: '/tmp/dbsnap' }) 24 | 25 | expect(result).to eq({}) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/typesense/override_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Override do 7 | subject(:override) { typesense.collections['companies'].overrides['lex-exact'] } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:override_data) do 12 | { 13 | 'id' => 'lex-exact', 14 | 'rule' => { 15 | 'query' => 'lex luthor', 16 | 'match' => 'exact' 17 | }, 18 | 'includes' => [{ 'id' => '125', 'position' => 1 }], 19 | 'excludes' => [{ 'id' => '124' }] 20 | } 21 | end 22 | 23 | describe '#retrieve' do 24 | it 'returns the specified override' do 25 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/overrides/lex-exact', typesense.configuration.nodes[0])) 26 | .with(headers: { 27 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 28 | 'Content-Type': 'application/json' 29 | }) 30 | .to_return(status: 200, body: JSON.dump(override_data), headers: { 'Content-Type': 'application/json' }) 31 | 32 | result = override.retrieve 33 | 34 | expect(result).to eq(override_data) 35 | end 36 | end 37 | 38 | describe '#delete' do 39 | it 'deletes the specified override' do 40 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/overrides/lex-exact', typesense.configuration.nodes[0])) 41 | .with(headers: { 42 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 43 | }) 44 | .to_return(status: 200, body: JSON.dump('id' => 'lex-exact'), headers: { 'Content-Type': 'application/json' }) 45 | 46 | result = override.delete 47 | 48 | expect(result).to eq('id' => 'lex-exact') 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/typesense/overrides_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Overrides do 7 | subject(:companies_overrides) { typesense.collections['companies'].overrides } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:override) do 12 | { 13 | 'id' => 'lex-exact', 14 | 'rule' => { 15 | 'query' => 'lex luthor', 16 | 'match' => 'exact' 17 | }, 18 | 'includes' => [{ 'id' => '125', 'position' => 1 }], 19 | 'excludes' => [{ 'id' => '124' }] 20 | } 21 | end 22 | 23 | describe '#upsert' do 24 | it 'creates an override rule and returns it' do 25 | stub_request(:put, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/overrides/lex-exact', typesense.configuration.nodes[0])) 26 | .with(body: override, 27 | headers: { 28 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 29 | 'Content-Type' => 'application/json' 30 | }) 31 | .to_return(status: 201, body: JSON.dump(override), headers: { 'Content-Type': 'application/json' }) 32 | 33 | result = companies_overrides.upsert(override['id'], override) 34 | 35 | expect(result).to eq(override) 36 | end 37 | end 38 | 39 | describe '#retrieve' do 40 | it 'retrieves all overrides' do 41 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/overrides', typesense.configuration.nodes[0])) 42 | .with(headers: { 43 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 44 | 'Content-Type' => 'application/json' 45 | }) 46 | .to_return(status: 201, body: JSON.dump([override]), headers: { 'Content-Type': 'application/json' }) 47 | 48 | result = companies_overrides.retrieve 49 | 50 | expect(result).to eq([override]) 51 | end 52 | end 53 | 54 | describe '#[]' do 55 | it 'creates an override object and returns it' do 56 | result = companies_overrides['lex-override'] 57 | 58 | expect(result).to be_a(Typesense::Override) 59 | expect(result.instance_variable_get(:@override_id)).to eq('lex-override') 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/typesense/preset_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Preset do 7 | subject(:preset) { typesense.presets['search-view'] } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:preset_data) do 12 | { 13 | 'name' => 'search-view', 14 | 'value' => { 15 | 'query_by' => 'title,subjects,author', 16 | 'query_by_weights' => '1,4,8', 17 | 'sort_by' => '_text_match:desc' 18 | } 19 | } 20 | end 21 | 22 | describe '#retrieve' do 23 | it 'returns the specified preset' do 24 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/presets/search-view', typesense.configuration.nodes[0])) 25 | .with(headers: { 26 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 27 | 'Content-Type': 'application/json' 28 | }) 29 | .to_return(status: 200, body: JSON.dump(preset_data), headers: { 'Content-Type': 'application/json' }) 30 | 31 | result = preset.retrieve 32 | 33 | expect(result).to eq(preset_data) 34 | end 35 | end 36 | 37 | describe '#delete' do 38 | it 'deletes the specified preset' do 39 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/presets/search-view', typesense.configuration.nodes[0])) 40 | .with(headers: { 41 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 42 | }) 43 | .to_return(status: 200, body: JSON.dump('name' => 'search-view'), headers: { 'Content-Type': 'application/json' }) 44 | 45 | result = preset.delete 46 | 47 | expect(result).to eq('name' => 'search-view') 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/typesense/presets_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Presets do 7 | subject(:presets) { typesense.presets } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:preset_data) do 12 | { 13 | 'name' => 'search-view', 14 | 'value' => { 15 | 'query_by' => 'title,subjects,author', 16 | 'query_by_weights' => '1,4,8', 17 | 'sort_by' => '_text_match:desc' 18 | } 19 | } 20 | end 21 | 22 | describe '#upsert' do 23 | it 'creates a preset and returns it' do 24 | stub_request(:put, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/presets/search-view', typesense.configuration.nodes[0])) 25 | .with(body: preset_data, 26 | headers: { 27 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 28 | 'Content-Type' => 'application/json' 29 | }) 30 | .to_return(status: 201, body: JSON.dump(preset_data), headers: { 'Content-Type': 'application/json' }) 31 | 32 | result = presets.upsert(preset_data['name'], preset_data) 33 | 34 | expect(result).to eq(preset_data) 35 | end 36 | end 37 | 38 | describe '#retrieve' do 39 | it 'retrieves all presets' do 40 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/presets', typesense.configuration.nodes[0])) 41 | .with(headers: { 42 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 43 | 'Content-Type' => 'application/json' 44 | }) 45 | .to_return(status: 201, body: JSON.dump([preset_data]), headers: { 'Content-Type': 'application/json' }) 46 | 47 | result = presets.retrieve 48 | 49 | expect(result).to eq([preset_data]) 50 | end 51 | end 52 | 53 | describe '#[]' do 54 | it 'creates a preset object and returns it' do 55 | result = presets['search-view'] 56 | 57 | expect(result).to be_a(Typesense::Preset) 58 | expect(result.instance_variable_get(:@preset_name)).to eq('search-view') 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/typesense/shared_configuration_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | 5 | shared_context 'with Typesense configuration', shared_context: :metadata do 6 | let(:typesense) do 7 | Typesense::Client.new( 8 | api_key: 'abcd', 9 | nodes: [ 10 | { 11 | host: 'node0', 12 | port: 8108, 13 | protocol: 'http' 14 | }, 15 | { 16 | host: 'node1', 17 | port: 8108, 18 | protocol: 'http' 19 | }, 20 | { 21 | host: 'node2', 22 | port: 8108, 23 | protocol: 'http' 24 | } 25 | ], 26 | connection_timeout_seconds: 10, 27 | retry_interval_seconds: 0.01, 28 | log_level: Logger::ERROR 29 | ) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/typesense/stats_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Stats do 7 | include_context 'with Typesense configuration' 8 | 9 | describe '#retrieve' do 10 | it 'retrieves cluster stats' do 11 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/stats.json', typesense.configuration.nodes[0])) 12 | .with(headers: { 13 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 14 | 'Content-Type' => 'application/json' 15 | }) 16 | .to_return(status: 200, body: '{}', headers: { 'Content-Type': 'application/json' }) 17 | 18 | result = typesense.stats.retrieve 19 | 20 | expect(result).to eq({}) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/typesense/stemming_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | 5 | describe 'StemmingDictionaries' do 6 | let(:client) do 7 | Typesense::Client.new( 8 | nodes: [{ host: 'localhost', port: '8108', protocol: 'http' }], 9 | api_key: 'xyz', 10 | connection_timeout_seconds: 10 11 | ) 12 | end 13 | 14 | let(:dictionary_id) { 'test_dictionary' } 15 | let(:dictionary) do 16 | [ 17 | { 'root' => 'exampleRoot1', 'word' => 'exampleWord1' }, 18 | { 'root' => 'exampleRoot2', 'word' => 'exampleWord2' } 19 | ] 20 | end 21 | 22 | before do 23 | WebMock.disable! 24 | # Create the dictionary at the start of each test 25 | client.stemming.dictionaries.upsert(dictionary_id, dictionary) 26 | end 27 | 28 | after do 29 | WebMock.enable! 30 | end 31 | 32 | it 'can upsert a dictionary' do 33 | response = client.stemming.dictionaries.upsert(dictionary_id, dictionary) 34 | expect(response).to eq(dictionary) 35 | end 36 | 37 | it 'can retrieve a dictionary' do 38 | response = client.stemming.dictionaries[dictionary_id].retrieve 39 | expect(response['id']).to eq(dictionary_id) 40 | expect(response['words']).to eq(dictionary) 41 | end 42 | 43 | it 'can retrieve all dictionaries' do 44 | response = client.stemming.dictionaries.retrieve 45 | expect(response['dictionaries'].length).to eq(1) 46 | expect(response['dictionaries'][0]).to eq(dictionary_id) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/typesense/synonym_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Synonym do 7 | subject(:synonym) { typesense.collections['companies'].synonyms['synonym-set-1'] } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:synonym_data) do 12 | { 13 | 'id' => 'synonym-set-1', 14 | 'synonyms' => %w[ 15 | lex 16 | luthor 17 | businessman 18 | ] 19 | } 20 | end 21 | 22 | describe '#retrieve' do 23 | it 'returns the specified synonym' do 24 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/synonyms/synonym-set-1', typesense.configuration.nodes[0])) 25 | .with(headers: { 26 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 27 | 'Content-Type': 'application/json' 28 | }) 29 | .to_return(status: 200, body: JSON.dump(synonym_data), headers: { 'Content-Type': 'application/json' }) 30 | 31 | result = synonym.retrieve 32 | 33 | expect(result).to eq(synonym_data) 34 | end 35 | end 36 | 37 | describe '#delete' do 38 | it 'deletes the specified synonym' do 39 | stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/synonyms/synonym-set-1', typesense.configuration.nodes[0])) 40 | .with(headers: { 41 | 'X-Typesense-Api-Key' => typesense.configuration.api_key 42 | }) 43 | .to_return(status: 200, body: JSON.dump('id' => 'synonym-set-1'), headers: { 'Content-Type': 'application/json' }) 44 | 45 | result = synonym.delete 46 | 47 | expect(result).to eq('id' => 'synonym-set-1') 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/typesense/synonyms_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../spec_helper' 4 | require_relative 'shared_configuration_context' 5 | 6 | describe Typesense::Synonyms do 7 | subject(:companies_synonyms) { typesense.collections['companies'].synonyms } 8 | 9 | include_context 'with Typesense configuration' 10 | 11 | let(:synonym) do 12 | { 13 | 'id' => 'synonym-set-1', 14 | 'synonyms' => %w[ 15 | lex 16 | luthor 17 | businessman 18 | ] 19 | } 20 | end 21 | 22 | describe '#upsert' do 23 | it 'creates an synonym rule and returns it' do 24 | stub_request(:put, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/synonyms/synonym-set-1', typesense.configuration.nodes[0])) 25 | .with(body: synonym, 26 | headers: { 27 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 28 | 'Content-Type' => 'application/json' 29 | }) 30 | .to_return(status: 201, body: JSON.dump(synonym), headers: { 'Content-Type': 'application/json' }) 31 | 32 | result = companies_synonyms.upsert(synonym['id'], synonym) 33 | 34 | expect(result).to eq(synonym) 35 | end 36 | end 37 | 38 | describe '#retrieve' do 39 | it 'retrieves all synonyms' do 40 | stub_request(:get, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/synonyms', typesense.configuration.nodes[0])) 41 | .with(headers: { 42 | 'X-Typesense-Api-Key' => typesense.configuration.api_key, 43 | 'Content-Type' => 'application/json' 44 | }) 45 | .to_return(status: 201, body: JSON.dump([synonym]), headers: { 'Content-Type': 'application/json' }) 46 | 47 | result = companies_synonyms.retrieve 48 | 49 | expect(result).to eq([synonym]) 50 | end 51 | end 52 | 53 | describe '#[]' do 54 | it 'creates an synonym object and returns it' do 55 | result = companies_synonyms['synonym-set-1'] 56 | 57 | expect(result).to be_a(Typesense::Synonym) 58 | expect(result.instance_variable_get(:@synonym_id)).to eq('synonym-set-1') 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/typesense_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Typesense do 6 | it 'has a version number' do 7 | expect(described_class::VERSION).not_to be_nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /typesense.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'typesense/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'typesense' 9 | spec.version = Typesense::VERSION 10 | spec.authors = ['Typesense, Inc.'] 11 | spec.email = ['contact@typesense.org'] 12 | 13 | spec.summary = 'Ruby Library for Typesense' 14 | spec.description = 'Typesense is an open source search engine for building a delightful search experience.' 15 | spec.homepage = 'https://typesense.org' 16 | spec.license = 'Apache-2.0' 17 | 18 | # rubocop:disable Gemspec/RequiredRubyVersion, Lint/RedundantCopDisableDirective 19 | spec.required_ruby_version = '>= 2.7' 20 | # rubocop:enable Gemspec/RequiredRubyVersion, Lint/RedundantCopDisableDirective 21 | 22 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 23 | f.match(%r{^(test|spec|features)/}) 24 | end 25 | spec.bindir = 'exe' 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.require_paths = ['lib'] 28 | 29 | spec.add_dependency 'base64', '~> 0.2.0' 30 | spec.add_dependency 'faraday', '~> 2.8' 31 | spec.add_dependency 'oj', '~> 3.16' 32 | spec.metadata['rubygems_mfa_required'] = 'true' 33 | end 34 | --------------------------------------------------------------------------------