diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b6e092..ea04578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,16 +6,37 @@ on: - "*" push: branches: - - master + - main jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler: default + bundler-cache: true + + - name: StandardRb check + run: bundle exec standardrb --format progress --format github --color + + - name: RBS signatures + run: bundle exec rbs -r logger -r uri -r net-http -r json -I sig validate + tests: runs-on: ubuntu-latest strategy: matrix: - ruby: ["2.7", "3.0", "3.1", "3.2", "3.3"] + ruby: ["2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "4.0"] + env: + # standard requires Ruby >= 3.0, linting runs in its own job + BUNDLE_WITHOUT: lint steps: - - uses: actions/checkout@master + - uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -24,20 +45,19 @@ jobs: bundler: default bundler-cache: true - - name: StandardRb check - run: bundle exec standardrb --format progress --format github --color - - name: Run tests run: | bundle exec rspec docs: runs-on: ubuntu-latest + env: + BUNDLE_WITHOUT: lint steps: - - uses: actions/checkout@master + - uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3 + ruby-version: 3.4 bundler: default bundler-cache: true - name: Build docs diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 0000000..c19ce03 --- /dev/null +++ b/.standard.yml @@ -0,0 +1 @@ +ruby_version: 2.7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9225746..2908b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ - Fix `objects.exists?` raising on missing objects instead of returning `false` - Fix `objects.batch_create(tenant:)` being ignored by Weaviate: the tenant is now set on each object - Fix `query.aggs` failing with "groupBy must be a list, instead got: " when `group_by`/`object_limit` are not passed +- `live?`/`ready?` return `false` when Weaviate responds with an error (e.g. 503 while starting) or cannot be reached, instead of raising +- `collections.add_tenants` sends a JSON array and accepts hashes with `activityStatus` +- Query arguments (`where:`, `near_text:`, `sort:`, ...) can be Ruby hashes/arrays, serialized to GraphQL with strings escaped. Raw GraphQL strings still work +- Add API key headers for all model providers supported by Weaviate 1.38 (e.g. `:anthropic`, `:google_studio`, `:google_vertex`, `:mistral`, `:voyageai`, `:jinaai`, `:nvidia`) +- Add `headers:`, `open_timeout:`, `read_timeout:` and `write_timeout:` options to `Weaviate::Client.new` +- Add cross-references: `objects.add_reference`, `replace_references`, `delete_reference` and `batch_create_references` +- Add `group_by:` to `query.get`, and `limit:`/`with_hybrid:` to `query.aggs` +- Add `collections.update_tenants`, `get_tenant`, `tenant_exists?`, and `tenant:` to `collections.shards` +- Add `backups.list`, `cancel`, `cancel_restore`, `config:` on create/restore and `bucket:`/`path:` on status reads +- Add `client.nodes(output:, class_name:, shard_name:)` +- Add `client.aliases`, `client.users`, `client.roles` and `client.groups` +- Deprecate `query.explore`: Weaviate disables Explore when more than one vectorizer module is enabled +- Add RBS signatures for the public API +- Minimum Ruby version is now 2.7 ### Breaking - HTTP errors are raised as `Weaviate::HTTPError` subclasses (e.g. `Weaviate::ResourceNotFound`, `Weaviate::ServerError`) instead of `Faraday::Error` subclasses. Network failures raise `Weaviate::ConnectionFailed`/`Weaviate::TimeoutError` diff --git a/Gemfile b/Gemfile index f5b5abf..32005f2 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,11 @@ gemspec gem "rake", "~> 13.0" gem "rspec", "~> 3.0" -gem "standard", "~> 1.25.0" -gem "racc" # required by standard, previously pulled in through graphlient gem "ostruct" # used in specs, no longer a default gem on Ruby >= 3.5 + +# Linting runs on the latest Ruby only (standard requires Ruby >= 3.0). Skip with BUNDLE_WITHOUT=lint. +group :lint do + gem "standard", "~> 1.57" + gem "rbs", "~> 3.8" +end diff --git a/Gemfile.lock b/Gemfile.lock index 0699c70..642dc2a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,18 +7,21 @@ PATH GEM remote: https://rubygems.org/ specs: - ast (2.4.2) + ast (2.4.3) byebug (11.1.3) coderay (1.1.3) diff-lcs (1.5.0) - json (2.6.3) - language_server-protocol (3.17.0.3) + json (3.1.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) logger (1.7.0) method_source (1.0.0) ostruct (0.6.3) - parallel (1.22.1) - parser (3.2.1.1) + parallel (2.3.0) + parser (3.3.12.0) ast (~> 2.4.1) + racc + prism (1.9.0) pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) @@ -28,9 +31,11 @@ GEM racc (1.8.1) rainbow (3.1.1) rake (13.0.6) + rbs (3.10.4) + logger + tsort rdiscount (2.2.7.4) - regexp_parser (2.7.0) - rexml (3.4.2) + regexp_parser (2.13.1) rspec (3.11.0) rspec-core (~> 3.11.0) rspec-expectations (~> 3.11.0) @@ -44,28 +49,42 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.11.0) rspec-support (3.11.0) - rubocop (1.48.1) - json (~> 2.3) - parallel (~> 1.10) - parser (>= 3.2.0.0) + rubocop (1.91.0) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.26.0, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.0) - parser (>= 3.2.1.0) - rubocop-performance (1.16.0) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (1.13.0) - standard (1.25.3) + standard (1.57.0) language_server-protocol (~> 3.17.0.2) - rubocop (~> 1.48.1) - rubocop-performance (~> 1.16.0) - unicode-display_width (2.4.2) - yard (0.9.37) + lint_roller (~> 1.0) + rubocop (~> 1.91.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + tsort (0.2.0) + unicode-display_width (3.3.0) + unicode-emoji (~> 4.3) + unicode-emoji (4.3.0) + yard (0.9.45) PLATFORMS arm64-darwin-21 @@ -79,13 +98,13 @@ PLATFORMS DEPENDENCIES ostruct pry-byebug (~> 3.9) - racc rake (~> 13.0) + rbs (~> 3.8) rdiscount rspec (~> 3.0) - standard (~> 1.25.0) + standard (~> 1.57) weaviate-ruby! yard BUNDLED WITH - 2.4.0 + 2.6.9 diff --git a/README.md b/README.md index 249af59..703afc8 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ Ruby wrapper for the Weaviate.io API. -Part of the [Langchain.rb](https://github.com/andreibondarev/langchainrb) stack. +Part of the [Langchain.rb](https://github.com/patterns-ai-core/langchainrb) stack. Available for paid consulting engagements! [Email me](mailto:andrei@sourcelabs.io). -![Tests status](https://github.com/andreibondarev/weaviate-ruby/actions/workflows/ci.yml/badge.svg) +![Tests status](https://github.com/patterns-ai-core/weaviate-ruby/actions/workflows/ci.yml/badge.svg) [![Gem Version](https://badge.fury.io/rb/weaviate-ruby.svg)](https://badge.fury.io/rb/weaviate-ruby) [![Docs](http://img.shields.io/badge/yard-docs-blue.svg)](http://rubydoc.info/gems/weaviate-ruby) -[![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/andreibondarev/weaviate-ruby/blob/main/LICENSE.txt) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/patterns-ai-core/weaviate-ruby/blob/main/LICENSE.txt) [![](https://dcbadge.vercel.app/api/server/WDARp7J2n8?compact=true&style=flat)](https://discord.gg/WDARp7J2n8) [![X](https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow%20%40rushing_andrei)](https://twitter.com/rushing_andrei) @@ -39,9 +39,11 @@ require 'weaviate' client = Weaviate::Client.new( url: 'https://some-endpoint.weaviate.network', # Replace with your endpoint api_key: '', # Weaviate API key - model_service: :openai, # Service that will be used to generate vectors. Possible values: :openai, :azure_openai, :cohere, :huggingface, :google_palm - model_service_api_key: 'xxxxxxx', # Either OpenAI, Azure OpenAI, Cohere, Hugging Face or Google PaLM api key + model_service: :openai, # Service used to generate vectors, see Weaviate::Client::API_KEY_HEADERS for all values (e.g. :openai, :cohere, :google_studio, :voyageai, :mistral) + model_service_api_key: 'xxxxxxx', # API key for the model service + headers: { 'X-Aws-Access-Key' => '...', 'X-Aws-Secret-Key' => '...' }, # Optional. Extra headers, e.g. for providers that need several keys or a custom base URL proxy: 'http://user:pass@localhost:8888', # Optional. Defaults to the http_proxy/https_proxy env vars + open_timeout: 5, read_timeout: 30, write_timeout: 30, # Optional. In seconds, default to 60 logger: Logger.new($stdout) # Optional. Requests and responses are logged with API keys filtered out ) ``` @@ -274,12 +276,35 @@ client.objects.batch_delete( path: ["id"] } ) + +# References: add, replace and delete cross-references on a reference property +client.objects.add_reference(class_name: "Post", id: post_id, property: "ofCategory", to_class_name: "Category", to_id: category_id) +client.objects.replace_references(class_name: "Post", id: post_id, property: "ofCategory", to: [{ class_name: "Category", id: category_id }]) +client.objects.delete_reference(class_name: "Post", id: post_id, property: "ofCategory", to_class_name: "Category", to_id: category_id) + +# Batch create references +client.objects.batch_create_references(references: [ + { from_class_name: "Post", from_id: post_id, property: "ofCategory", to_class_name: "Category", to_id: category_id } +]) ``` ### Querying #### Get{} + +Arguments such as `where`, `near_text` or `sort` can be passed either as raw GraphQL strings or as Ruby hashes/arrays. +Hashes are converted to GraphQL: strings are quoted and escaped (safe for user input), and symbols are sent as enums. +`operator`, `order` and `fusionType` values are always sent as enums. + ```ruby +client.query.get( + class_name: 'Question', + fields: 'question category', + where: { path: ['category'], operator: :Equal, valueText: params[:category] }, + sort: [{ path: ['category'], order: :asc }], + limit: 10 +) + near_text = '{ concepts: ["biology"] }' near_vector = '{ vector: [0.1, 0.2, ...] }' sort_obj = '{ path: ["category"], order: desc }' @@ -327,6 +352,14 @@ client.query.get( limit: 5 ) +# Grouping results by a property (requires a near_* or hybrid search) +client.query.get( + class_name: 'Question', + fields: '_additional { group { groupedBy { value } count hits { question _additional { id distance } } } }', + near_text: { concepts: ['biology'] }, + group_by: { path: ['category'], groups: 3, objectsPerGroup: 2 } +) + # Named vector query - uses targetVectors query_title = client.query.get( class_name: 'ArticleNV', @@ -351,9 +384,22 @@ client.query.aggs( object_limit: "10", near_text: "{ concepts: [\"knowledge\"] }" ) + +# Aggregating over hybrid search results, limiting the number of groups returned +client.query.aggs( + class_name: "Question", + fields: 'groupedBy { value } meta { count }', + group_by: ["category"], + with_hybrid: { query: "knowledge", alpha: 0.5 }, + object_limit: 100, + limit: 5 +) ``` #### Explore{} + +**Deprecated.** Weaviate disables `Explore` when more than one vectorizer module is enabled, which is the case on a default install of recent versions and on Weaviate Cloud. Use `query.get` on each collection instead. + ```ruby client.query.explore( fields: 'className', @@ -404,21 +450,85 @@ client.backups.restore_status( backend: "filesystem", id: "my-first-backup" ) + +# List backups, newest first by default +client.backups.list(backend: "filesystem", order: "desc") + +# Cancel a backup or restore that is in progress +client.backups.cancel(backend: "filesystem", id: "my-first-backup") +client.backups.cancel_restore(backend: "filesystem", id: "my-first-backup") + +# Cloud storage backends accept a config, plus bucket/path when reading the status +client.backups.create(backend: "s3", id: "my-backup", config: { Bucket: "my-bucket", Path: "backups" }) +client.backups.get(backend: "s3", id: "my-backup", bucket: "my-bucket", path: "backups") ``` ### Nodes ```ruby client.nodes + +# Include shard details, optionally for one collection or shard +client.nodes(output: "verbose", class_name: "Question") ``` -### Health +### Aliases ```ruby -# Live determines whether the application is alive. It can be used for Kubernetes liveness probe. -client.live? +# Point an alias at a collection, then query or write through the alias +client.aliases.create(alias_name: "Questions", class_name: "QuestionV1") +client.query.get(class_name: "Questions", fields: "question") + +# Switch the alias to a new collection, e.g. after a re-index +client.aliases.update(alias_name: "Questions", class_name: "QuestionV2") + +client.aliases.list +client.aliases.get(alias_name: "Questions") +client.aliases.delete(alias_name: "Questions") +``` + +### Users and roles + +These require [RBAC](https://docs.weaviate.io/deploy/configuration/authorization) to be enabled. + +```ruby +client.users.own_info + +# Database users +response = client.users.create(user_id: "reporting") +response["apikey"] # The new user's API key +client.users.list +client.users.get(user_id: "reporting") +client.users.rotate_key(user_id: "reporting") +client.users.deactivate(user_id: "reporting", revoke_key: true) +client.users.activate(user_id: "reporting") +client.users.delete(user_id: "reporting") + +# Roles and permissions +client.roles.create( + name: "question-reader", + permissions: [ + { action: "read_data", data: { collection: "Question" } }, + { action: "read_collections", collections: { collection: "*" } } # Required for GraphQL queries + ] +) +client.roles.add_permissions(name: "question-reader", permissions: [{ action: "read_tenants", tenants: { collection: "Question" } }]) +client.roles.has_permission?(name: "question-reader", permission: { action: "read_data", data: { collection: "Question" } }) +client.roles.list + +# Assigning roles to users (user_type: "db" or "oidc") and OIDC groups +client.users.assign_roles(user_id: "reporting", roles: ["question-reader"]) +client.users.roles(user_id: "reporting") +client.roles.user_assignments(name: "question-reader") +client.users.revoke_roles(user_id: "reporting", roles: ["question-reader"]) +client.groups.assign_roles(group_id: "analysts", roles: ["question-reader"]) ``` +### Health ```ruby -# Live determines whether the application is ready to receive traffic. It can be used for Kubernetes readiness probe. +# Whether the application is alive. It can be used for Kubernetes liveness probe. +client.live? + +# Whether the application is ready to receive traffic. It can be used for Kubernetes readiness probe. +# Both return false when Weaviate responds with an error or cannot be reached. client.ready? ``` @@ -437,7 +547,18 @@ client.collections.create( See [Weaviate Multi-tenancy operations](https://weaviate.io/developers/weaviate/manage-data/multi-tenancy). Note that the mix of snake case(used by Ruby) and lower camel case(used by Weaviate) is intentional as that hash is passed directly to Weaviate. -All data methods in this library support an optional `tenant` argument which must be passed if multi-tenancy is enabled on the related collection +All data methods in this library support an optional `tenant` argument which must be passed if multi-tenancy is enabled on the related collection. + +```ruby +# Tenants can be added by name, or with an activity status +client.collections.add_tenants(class_name: "Question", tenants: ["tenant-a", { name: "tenant-b", activityStatus: "COLD" }]) +client.collections.update_tenants(class_name: "Question", tenants: [{ name: "tenant-b", activityStatus: "HOT" }]) +client.collections.get_tenant(class_name: "Question", tenant: "tenant-b") +client.collections.tenant_exists?(class_name: "Question", tenant: "tenant-b") +client.collections.list_tenants(class_name: "Question") +client.collections.remove_tenants(class_name: "Question", tenants: ["tenant-a"]) +client.collections.shards(class_name: "Question", tenant: "tenant-b") +``` ## Development @@ -447,7 +568,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/andreibondarev/weaviate. +Bug reports and pull requests are welcome on GitHub at https://github.com/patterns-ai-core/weaviate-ruby. ## License diff --git a/Rakefile b/Rakefile index 388c02e..9b19e27 100644 --- a/Rakefile +++ b/Rakefile @@ -9,5 +9,7 @@ RSpec::Core::RakeTask.new(:spec) task default: :spec YARD::Rake::YardocTask.new do |t| + # Document from the Ruby sources only. YARD's RBS parser doesn't handle multi-line signatures in sig/ + t.files = ["lib/**/*.rb"] t.options = ["--fail-on-warning"] end diff --git a/lib/weaviate.rb b/lib/weaviate.rb index 01eb063..0da085a 100644 --- a/lib/weaviate.rb +++ b/lib/weaviate.rb @@ -18,4 +18,8 @@ module Weaviate autoload :Health, "weaviate/health" autoload :Classifications, "weaviate/classifications" autoload :Backups, "weaviate/backups" + autoload :Aliases, "weaviate/aliases" + autoload :Users, "weaviate/users" + autoload :Roles, "weaviate/roles" + autoload :Groups, "weaviate/groups" end diff --git a/lib/weaviate/aliases.rb b/lib/weaviate/aliases.rb new file mode 100644 index 0000000..b856abe --- /dev/null +++ b/lib/weaviate/aliases.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Weaviate + # Aliases are alternative names for a collection, e.g. to switch a "Products" alias to a newly built collection. + class Aliases < Base + PATH = "aliases" + + def list(class_name: nil) + response = client.connection.get(PATH) do |req| + req.params["class"] = class_name unless class_name.nil? + end + response.body + end + + def create(alias_name:, class_name:) + response = client.connection.post(PATH) do |req| + req.body = {alias: alias_name, class: class_name} + end + response.body + end + + def get(alias_name:) + response = client.connection.get("#{PATH}/#{alias_name}") + response.body + end + + # Point an alias to a different collection. + def update(alias_name:, class_name:) + response = client.connection.put("#{PATH}/#{alias_name}") do |req| + req.body = {class: class_name} + end + response.body + end + + def delete(alias_name:) + response = client.connection.delete("#{PATH}/#{alias_name}") + response.success? + end + end +end diff --git a/lib/weaviate/backups.rb b/lib/weaviate/backups.rb index b2d4fbf..4c4f2fc 100644 --- a/lib/weaviate/backups.rb +++ b/lib/weaviate/backups.rb @@ -4,17 +4,29 @@ module Weaviate class Backups < Base PATH = "backups" + # List backups on a backend. + # @param order [String] "asc" or "desc" (default) by start time + def list(backend:, order: nil) + response = client.connection.get("#{PATH}/#{backend}") do |req| + req.params["order"] = order unless order.nil? + end + response.body + end + + # @param config [Hash] Backup configuration, e.g. { Bucket: "my-bucket", Path: "backups", CPUPercentage: 80 } def create( backend:, id:, include: nil, - exclude: nil + exclude: nil, + config: nil ) response = client.connection.post("#{PATH}/#{backend}") do |req| req.body = {} req.body["id"] = id req.body["include"] = include if include req.body["exclude"] = exclude if exclude + req.body["config"] = config if config end response.body @@ -22,22 +34,41 @@ def create( def get( backend:, - id: + id:, + bucket: nil, + path: nil ) - response = client.connection.get("#{PATH}/#{backend}/#{id}") + response = client.connection.get("#{PATH}/#{backend}/#{id}") do |req| + location_params(req, bucket, path) + end response.body end + # Cancel a backup that is in progress. + def cancel( + backend:, + id:, + bucket: nil, + path: nil + ) + response = client.connection.delete("#{PATH}/#{backend}/#{id}") do |req| + location_params(req, bucket, path) + end + response.success? + end + def restore( backend:, id:, include: nil, - exclude: nil + exclude: nil, + config: nil ) response = client.connection.post("#{PATH}/#{backend}/#{id}/restore") do |req| req.body = {} req.body["include"] = include if include req.body["exclude"] = exclude if exclude + req.body["config"] = config if config end response.body @@ -45,10 +76,34 @@ def restore( def restore_status( backend:, - id: + id:, + bucket: nil, + path: nil ) - response = client.connection.get("#{PATH}/#{backend}/#{id}/restore") + response = client.connection.get("#{PATH}/#{backend}/#{id}/restore") do |req| + location_params(req, bucket, path) + end response.body end + + # Cancel a restore that is in progress. + def cancel_restore( + backend:, + id:, + bucket: nil, + path: nil + ) + response = client.connection.delete("#{PATH}/#{backend}/#{id}/restore") do |req| + location_params(req, bucket, path) + end + response.success? + end + + private + + def location_params(req, bucket, path) + req.params["bucket"] = bucket unless bucket.nil? + req.params["path"] = path unless path.nil? + end end end diff --git a/lib/weaviate/classifications.rb b/lib/weaviate/classifications.rb index b73c1d1..218a86a 100644 --- a/lib/weaviate/classifications.rb +++ b/lib/weaviate/classifications.rb @@ -27,9 +27,7 @@ def create( req.body["filters"] = filters if filters end - if response.success? - response.body - end + response.body end end end diff --git a/lib/weaviate/client.rb b/lib/weaviate/client.rb index af4c845..c0a21be 100644 --- a/lib/weaviate/client.rb +++ b/lib/weaviate/client.rb @@ -4,33 +4,60 @@ module Weaviate class Client - attr_reader :url, :api_key, :model_service, :model_service_api_key, :proxy, :logger + attr_reader :url, :api_key, :model_service, :model_service_api_key, :headers, :proxy, :logger, + :open_timeout, :read_timeout, :write_timeout API_VERSION = "v1" + # Headers Weaviate reads model provider API keys from. Providers that need more than one + # header (e.g. AWS) or a custom base URL can be configured with the `headers:` option. API_KEY_HEADERS = { - ollama: "X-Ollama-Not-Used", - openai: "X-OpenAI-Api-Key", + anthropic: "X-Anthropic-Api-Key", + anyscale: "X-Anyscale-Api-Key", azure_openai: "X-Azure-Api-Key", cohere: "X-Cohere-Api-Key", + contextualai: "X-ContextualAI-Api-Key", + databricks: "X-Databricks-Token", + deepseek: "X-Deepseek-Api-Key", + digitalocean: "X-Digitalocean-Api-Key", + friendliai: "X-Friendli-Token", + google: "X-Goog-Api-Key", + google_palm: "X-Palm-Api-Key", + google_studio: "X-Goog-Studio-Api-Key", + google_vertex: "X-Goog-Vertex-Api-Key", huggingface: "X-HuggingFace-Api-Key", - google_palm: "X-Palm-Api-Key" + jinaai: "X-JinaAI-Api-Key", + mistral: "X-Mistral-Api-Key", + nvidia: "X-Nvidia-Api-Key", + ollama: "X-Ollama-Not-Used", + openai: "X-OpenAI-Api-Key", + twelvelabs: "X-Twelvelabs-Api-Key", + voyageai: "X-VoyageAI-Api-Key", + xai: "X-Xai-Api-Key" } # @param url [String] The Weaviate instance URL # @param api_key [String] The Weaviate API key # @param model_service [Symbol] The service used to generate vectors, one of the API_KEY_HEADERS keys # @param model_service_api_key [String] The API key for the model service + # @param headers [Hash] Additional headers sent with every request, e.g. { "X-Aws-Access-Key" => "...", "X-Aws-Secret-Key" => "..." } # @param proxy [String] Proxy URL, e.g. "http://user:pass@localhost:8888". Defaults to the http_proxy/https_proxy env vars. # @param logger [Logger] Logger for requests and responses + # @param open_timeout [Numeric] Seconds to wait for a connection to open. Defaults to Net::HTTP's 60 seconds. + # @param read_timeout [Numeric] Seconds to wait for a response. Defaults to Net::HTTP's 60 seconds. + # @param write_timeout [Numeric] Seconds to wait for a request to be written. Defaults to Net::HTTP's 60 seconds. # @param adapter [Object] Deprecated and ignored, Faraday is no longer used def initialize( url:, api_key: nil, model_service: nil, model_service_api_key: nil, + headers: {}, proxy: nil, logger: nil, + open_timeout: nil, + read_timeout: nil, + write_timeout: nil, adapter: nil ) validate_model_service!(model_service) unless model_service.nil? @@ -40,8 +67,12 @@ def initialize( @api_key = api_key @model_service = model_service @model_service_api_key = model_service_api_key + @headers = headers @proxy = proxy @logger = logger || Logger.new($stdout) + @open_timeout = open_timeout + @read_timeout = read_timeout + @write_timeout = write_timeout end def oidc @@ -62,9 +93,10 @@ def meta @meta.get end - def nodes + # @see Weaviate::Nodes#list + def nodes(**options) @nodes ||= Weaviate::Nodes.new(client: self) - @nodes.list + @nodes.list(**options) end def live? @@ -93,6 +125,22 @@ def query @query ||= Weaviate::Query.new(client: self) end + def aliases + @aliases ||= Weaviate::Aliases.new(client: self) + end + + def users + @users ||= Weaviate::Users.new(client: self) + end + + def roles + @roles ||= Weaviate::Roles.new(client: self) + end + + def groups + @groups ||= Weaviate::Groups.new(client: self) + end + def graphql @graphql ||= Weaviate::GraphQL.new(client: self) end @@ -100,16 +148,19 @@ def graphql def connection @connection ||= Weaviate::Connection.new( url: "#{url}/#{API_VERSION}", - headers: headers, + headers: default_headers.merge(headers), logger: logger, - proxy: proxy + proxy: proxy, + open_timeout: open_timeout, + read_timeout: read_timeout, + write_timeout: write_timeout ) end private - def headers - headers = {} + def default_headers + headers = {"User-Agent" => "weaviate-ruby/#{Weaviate::VERSION}"} headers["Authorization"] = "Bearer #{api_key}" if api_key headers[API_KEY_HEADERS[model_service]] = model_service_api_key if model_service && model_service_api_key headers diff --git a/lib/weaviate/collections.rb b/lib/weaviate/collections.rb index cbfd703..876bb20 100644 --- a/lib/weaviate/collections.rb +++ b/lib/weaviate/collections.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "json" + module Weaviate # Manage collections (formerly known as "classes" in the Weaviate schema). class Collections < Base @@ -14,12 +16,7 @@ def list # Get a single collection def get(class_name:) response = client.connection.get("#{PATH}/#{class_name}") - - if response.success? - response.body - elsif response.status == 404 - response.reason_phrase - end + response.body end # Create a new collection. @@ -67,12 +64,7 @@ def create( # Remove a collection (and all data in the instances). def delete(class_name:) response = client.connection.delete("#{PATH}/#{class_name}") - - if response.success? - response.body.empty? - else - response.body - end + response.body.empty? end # Update settings of an existing collection. @@ -110,17 +102,43 @@ def update( end # Adds one or more tenants to a collection. + # @param tenants [Array] Tenant names, or hashes such as { name: "a", activityStatus: "COLD" } def add_tenants( class_name:, tenants: ) response = client.connection.post("#{PATH}/#{class_name}/tenants") do |req| - tenants_str = tenants.map { |t| %({"name": "#{t}"}) }.join(", ") - req.body = "[#{tenants_str}]" + req.body = tenants_body(tenants) + end + response.body + end + + # Update the activity status of one or more tenants. + # @param tenants [Array] e.g. [{ name: "a", activityStatus: "COLD" }] + def update_tenants( + class_name:, + tenants: + ) + response = client.connection.put("#{PATH}/#{class_name}/tenants") do |req| + req.body = tenants_body(tenants) end response.body end + # Get a single tenant of a collection. + def get_tenant(class_name:, tenant:) + response = client.connection.get("#{PATH}/#{class_name}/tenants/#{tenant}") + response.body + end + + # Check if a tenant exists. + def tenant_exists?(class_name:, tenant:) + response = client.connection.head("#{PATH}/#{class_name}/tenants/#{tenant}") + response.success? + rescue Weaviate::ResourceNotFound + false + end + # List tenants of a collection. def list_tenants(class_name:) response = client.connection.get("#{PATH}/#{class_name}/tenants") @@ -136,9 +154,6 @@ def remove_tenants( req.body = tenants end - if response.success? - end - response.body end @@ -151,15 +166,15 @@ def add_property( req.body = property end - if response.success? - end response.body end # Inspect the shards of a collection - def shards(class_name:) - response = client.connection.get("#{PATH}/#{class_name}/shards") - response.body if response.success? + def shards(class_name:, tenant: nil) + response = client.connection.get("#{PATH}/#{class_name}/shards") do |req| + req.params["tenant"] = tenant unless tenant.nil? + end + response.body end # Update shard status @@ -170,11 +185,16 @@ def update_shard_status(class_name:, shard_name:, status:) req.body = {} req.body["status"] = status end - response.body if response.success? + + response.body end private + def tenants_body(tenants) + Array(tenants).map { |tenant| tenant.is_a?(Hash) ? tenant : {name: tenant.to_s} } + end + def deep_merge(current, changes) current.merge(changes) do |_key, current_value, new_value| (current_value.is_a?(Hash) && new_value.is_a?(Hash)) ? deep_merge(current_value, new_value) : new_value diff --git a/lib/weaviate/connection.rb b/lib/weaviate/connection.rb index 9ca4472..16b7c6d 100644 --- a/lib/weaviate/connection.rb +++ b/lib/weaviate/connection.rb @@ -33,15 +33,16 @@ def success? 422 => UnprocessableEntityError } - FILTERED_HEADERS = /authorization|api-key/i + FILTERED_HEADERS = /authorization|api-key|token|secret|access-key/i - attr_reader :url, :headers, :logger, :proxy + attr_reader :url, :headers, :logger, :proxy, :timeouts - def initialize(url:, headers: {}, logger: nil, proxy: nil) + def initialize(url:, headers: {}, logger: nil, proxy: nil, open_timeout: nil, read_timeout: nil, write_timeout: nil) @url = url.chomp("/") @headers = headers @logger = logger @proxy = proxy && URI(proxy) + @timeouts = {open_timeout: open_timeout, read_timeout: read_timeout, write_timeout: write_timeout}.compact end METHODS.each_key do |method| @@ -87,7 +88,8 @@ def perform(uri, http_request) proxy&.port, proxy&.user, proxy&.password, - use_ssl: uri.scheme == "https" + use_ssl: uri.scheme == "https", + **timeouts ) do |http| http.request(http_request) end diff --git a/lib/weaviate/groups.rb b/lib/weaviate/groups.rb new file mode 100644 index 0000000..9570899 --- /dev/null +++ b/lib/weaviate/groups.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Weaviate + # Manage roles of OIDC groups. Requires RBAC and OIDC to be enabled. + class Groups < Base + PATH = "authz/groups" + GROUP_TYPE = "oidc" + + # Groups that have roles assigned. + def list + response = client.connection.get("#{PATH}/#{GROUP_TYPE}") + response.body + end + + def roles(group_id:, include_full_roles: nil) + response = client.connection.get("#{PATH}/#{group_id}/roles/#{GROUP_TYPE}") do |req| + req.params["includeFullRoles"] = include_full_roles unless include_full_roles.nil? + end + response.body + end + + def assign_roles(group_id:, roles:) + update_roles(group_id, "assign", roles) + end + + def revoke_roles(group_id:, roles:) + update_roles(group_id, "revoke", roles) + end + + private + + def update_roles(group_id, action, roles) + response = client.connection.post("#{PATH}/#{group_id}/#{action}") do |req| + req.body = {roles: Array(roles), groupType: GROUP_TYPE} + end + response.success? + end + end +end diff --git a/lib/weaviate/health.rb b/lib/weaviate/health.rb index a73fd64..ca24c8e 100644 --- a/lib/weaviate/health.rb +++ b/lib/weaviate/health.rb @@ -4,14 +4,23 @@ module Weaviate class Health < Base PATH = ".well-known" + # Whether Weaviate is alive. Returns false when it responds with an error or cannot be reached. def live? - response = client.connection.get("#{PATH}/live") - response.status == 200 + check("#{PATH}/live") end + # Whether Weaviate is ready to receive traffic. Returns false when it responds with an error + # (e.g. 503 while starting up) or cannot be reached. def ready? - response = client.connection.get("#{PATH}/ready") - response.status == 200 + check("#{PATH}/ready") + end + + private + + def check(path) + client.connection.get(path).status == 200 + rescue Weaviate::HTTPError, Weaviate::ConnectionFailed + false end end end diff --git a/lib/weaviate/nodes.rb b/lib/weaviate/nodes.rb index 3614b3d..ac69443 100644 --- a/lib/weaviate/nodes.rb +++ b/lib/weaviate/nodes.rb @@ -4,8 +4,16 @@ module Weaviate class Nodes < Base PATH = "nodes" - def list - response = client.connection.get(PATH) + # @param output [String] "minimal" (default) or "verbose", which includes shard details + # @param class_name [String] Only return shards of this collection + # @param shard_name [String] Only return this shard, requires class_name + def list(output: nil, class_name: nil, shard_name: nil) + path = class_name.nil? ? PATH : "#{PATH}/#{class_name}" + + response = client.connection.get(path) do |req| + req.params["output"] = output unless output.nil? + req.params["shardName"] = shard_name unless shard_name.nil? + end response.body end end diff --git a/lib/weaviate/objects.rb b/lib/weaviate/objects.rb index 90f542a..e8d4639 100644 --- a/lib/weaviate/objects.rb +++ b/lib/weaviate/objects.rb @@ -179,11 +179,7 @@ def delete( req.params["tenant"] = tenant unless tenant.nil? end - if response.success? - response.body.empty? - else - response.body - end + response.body.empty? end def batch_delete( @@ -216,6 +212,92 @@ def batch_delete( response.body end + # Add a reference to an object's reference property. + def add_reference( + class_name:, + id:, + property:, + to_class_name:, + to_id:, + tenant: nil, + consistency_level: nil + ) + validate_consistency_level!(consistency_level) unless consistency_level.nil? + + response = client.connection.post(reference_path(class_name, id, property)) do |req| + req.params["consistency_level"] = consistency_level.to_s.upcase unless consistency_level.nil? + req.params["tenant"] = tenant unless tenant.nil? + req.body = {beacon: beacon(to_class_name, to_id)} + end + + response.success? + end + + # Replace all references of an object's reference property. + # @param to [Array] The objects to reference, e.g. [{ class_name: "Category", id: "..." }] + def replace_references( + class_name:, + id:, + property:, + to:, + tenant: nil, + consistency_level: nil + ) + validate_consistency_level!(consistency_level) unless consistency_level.nil? + + response = client.connection.put(reference_path(class_name, id, property)) do |req| + req.params["consistency_level"] = consistency_level.to_s.upcase unless consistency_level.nil? + req.params["tenant"] = tenant unless tenant.nil? + req.body = to.map { |ref| {beacon: beacon(ref.fetch(:class_name), ref.fetch(:id))} } + end + + response.success? + end + + # Delete a reference from an object's reference property. + def delete_reference( + class_name:, + id:, + property:, + to_class_name:, + to_id:, + tenant: nil, + consistency_level: nil + ) + validate_consistency_level!(consistency_level) unless consistency_level.nil? + + response = client.connection.delete(reference_path(class_name, id, property)) do |req| + req.params["consistency_level"] = consistency_level.to_s.upcase unless consistency_level.nil? + req.params["tenant"] = tenant unless tenant.nil? + req.body = {beacon: beacon(to_class_name, to_id)} + end + + response.success? + end + + # Batch create references. + # @param references [Array] e.g. [{ from_class_name: "Post", from_id: "...", property: "ofCategory", to_class_name: "Category", to_id: "...", tenant: nil }] + def batch_create_references( + references:, + consistency_level: nil + ) + validate_consistency_level!(consistency_level) unless consistency_level.nil? + + response = client.connection.post("batch/references") do |req| + req.params["consistency_level"] = consistency_level.to_s.upcase unless consistency_level.nil? + req.body = references.map do |ref| + body = { + from: "#{beacon(ref.fetch(:from_class_name), ref.fetch(:from_id))}/#{ref.fetch(:property)}", + to: beacon(ref.fetch(:to_class_name), ref.fetch(:to_id)) + } + body[:tenant] = ref[:tenant] unless ref[:tenant].nil? + body + end + end + + response.body + end + # Validate a data object def validate( class_name:, @@ -229,15 +311,19 @@ def validate( req.body["id"] = id unless id.nil? end - if response.success? - response.body.empty? - else - response.body - end + response.body.empty? end private + def reference_path(class_name, id, property) + "#{PATH}/#{class_name}/#{id}/references/#{property}" + end + + def beacon(class_name, id) + "weaviate://localhost/#{class_name}/#{id}" + end + def validate_consistency_level!(consistency_level) unless %w[ONE QUORUM ALL].include?(consistency_level.to_s.upcase) raise ArgumentError, 'consistency_level must be either "ONE" or "QUORUM" OR "ALL"' diff --git a/lib/weaviate/query.rb b/lib/weaviate/query.rb index 3e3a324..11102b3 100644 --- a/lib/weaviate/query.rb +++ b/lib/weaviate/query.rb @@ -1,7 +1,14 @@ # frozen_string_literal: true +require "json" + module Weaviate class Query < Base + # Fields whose values are GraphQL enums, so strings are sent unquoted, e.g. operator: "Equal" + ENUM_KEYS = %w[operator order fusionType].freeze + + GRAPHQL_NAME = /\A[_A-Za-z][_0-9A-Za-z]*\z/ + def get( class_name:, fields:, @@ -18,7 +25,8 @@ def get( near_object: nil, with_hybrid: nil, bm25: nil, - ask: nil + ask: nil, + group_by: nil ) data = client.graphql.execute( get_query( @@ -37,7 +45,8 @@ def get( near_object: near_object, with_hybrid: with_hybrid, bm25: bm25, - ask: ask + ask: ask, + group_by: group_by ) ) data.dig("Get", class_name) @@ -53,7 +62,9 @@ def aggs( near_vector: nil, near_image: nil, near_object: nil, - group_by: nil + group_by: nil, + limit: nil, + with_hybrid: nil ) data = client.graphql.execute( aggs_query( @@ -66,12 +77,15 @@ def aggs( near_vector: near_vector, near_image: near_image, near_object: near_object, - group_by: group_by + group_by: group_by, + limit: limit, + with_hybrid: with_hybrid ) ) data.dig("Aggregate", class_name) end + # @deprecated Weaviate disables Explore when more than one vectorizer module is enabled. def explore( fields:, after: nil, @@ -84,6 +98,12 @@ def explore( near_image: nil, near_object: nil ) + unless @explore_deprecation_warned + warn "[weaviate-ruby] query.explore is deprecated: Weaviate disables Explore when more than one vectorizer module is enabled, " \ + "which is the default on recent versions and on Weaviate Cloud. Use query.get on each collection instead." + @explore_deprecation_warned = true + end + # `after` is accepted for backwards compatibility, but Explore does not support it data = client.graphql.execute( explore_query( @@ -150,7 +170,8 @@ def get_query( with_hybrid: nil, bm25: nil, ask: nil, - sort: nil + sort: nil, + group_by: nil ) arguments = build_arguments( after: string_value(after), @@ -166,7 +187,8 @@ def get_query( bm25: bm25, ask: ask, where: where, - sort: sort + sort: sort, + groupBy: group_by ) <<~GRAPHQL @@ -190,16 +212,20 @@ def aggs_query( near_vector: nil, near_image: nil, near_object: nil, - group_by: nil + group_by: nil, + limit: nil, + with_hybrid: nil ) arguments = build_arguments( objectLimit: integer_value(object_limit), + limit: integer_value(limit), groupBy: group_by && Array(group_by).map(&:to_s).to_json, tenant: string_value(tenant), nearText: near_text, nearVector: near_vector, nearImage: near_image, nearObject: near_object, + hybrid: with_hybrid, where: where ) @@ -216,11 +242,45 @@ def aggs_query( # Only arguments that were provided are included in the query. # Weaviate fails on explicit nulls, e.g. "interface conversion: interface {} is nil, not int". + # String arguments are passed through as raw GraphQL, anything else is serialized with {#graphql_value}. def build_arguments(**arguments) arguments = arguments.reject { |_, value| value.nil? } return "" if arguments.empty? - "(#{arguments.map { |name, value| "#{name}: #{value}" }.join(", ")})" + arguments = arguments.map do |name, value| + "#{name}: #{value.is_a?(String) ? value : graphql_value(value)}" + end + + "(#{arguments.join(", ")})" + end + + # Serializes a Ruby value into a GraphQL input value: + # { path: ["category"], operator: :Equal, valueText: "SCIENCE" } + # => { path: ["category"], operator: Equal, valueText: "SCIENCE" } + # Strings are quoted and escaped, symbols become enums. + def graphql_value(value, key = nil) + case value + when Hash + "{ #{value.map { |k, v| "#{graphql_name(k)}: #{graphql_value(v, k.to_s)}" }.join(", ")} }" + when Array + "[#{value.map { |v| graphql_value(v, key) }.join(", ")}]" + when Symbol + graphql_name(value) + when String + (ENUM_KEYS.include?(key) && value.match?(GRAPHQL_NAME)) ? value : value.to_json + when Numeric, true, false + value.to_s + when nil + "null" + else + raise ArgumentError, "Cannot convert #{value.class} to a GraphQL value" + end + end + + def graphql_name(name) + name = name.to_s + raise ArgumentError, "Invalid GraphQL name: #{name.inspect}" unless name.match?(GRAPHQL_NAME) + name end def integer_value(value) diff --git a/lib/weaviate/roles.rb b/lib/weaviate/roles.rb new file mode 100644 index 0000000..b2cd2dc --- /dev/null +++ b/lib/weaviate/roles.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Weaviate + # Manage RBAC roles and their permissions. Requires RBAC to be enabled. + class Roles < Base + PATH = "authz/roles" + + def list + response = client.connection.get(PATH) + response.body + end + + def get(name:) + response = client.connection.get("#{PATH}/#{name}") + response.body + end + + # @param permissions [Array] e.g. [{ action: "read_data", data: { collection: "Products" } }] + def create(name:, permissions:) + response = client.connection.post(PATH) do |req| + req.body = {name: name, permissions: permissions} + end + response.success? + end + + def delete(name:) + response = client.connection.delete("#{PATH}/#{name}") + response.success? + end + + def add_permissions(name:, permissions:) + response = client.connection.post("#{PATH}/#{name}/add-permissions") do |req| + req.body = {permissions: permissions} + end + response.success? + end + + def remove_permissions(name:, permissions:) + response = client.connection.post("#{PATH}/#{name}/remove-permissions") do |req| + req.body = {permissions: permissions} + end + response.success? + end + + # @param permission [Hash] e.g. { action: "read_data", data: { collection: "Products" } } + def has_permission?(name:, permission:) + response = client.connection.post("#{PATH}/#{name}/has-permission") do |req| + req.body = permission + end + response.body == true + end + + # Users the role is assigned to. + def user_assignments(name:) + response = client.connection.get("#{PATH}/#{name}/user-assignments") + response.body + end + + # Groups the role is assigned to. + def group_assignments(name:) + response = client.connection.get("#{PATH}/#{name}/group-assignments") + response.body + end + end +end diff --git a/lib/weaviate/users.rb b/lib/weaviate/users.rb new file mode 100644 index 0000000..38eb6f9 --- /dev/null +++ b/lib/weaviate/users.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Weaviate + # Manage database users and their roles. Requires RBAC to be enabled. + class Users < Base + PATH = "users" + USER_TYPES = %w[db oidc].freeze + + # Information about the current user. + def own_info + response = client.connection.get("#{PATH}/own-info") + response.body + end + + def list(include_last_used_time: nil) + response = client.connection.get("#{PATH}/db") do |req| + req.params["includeLastUsedTime"] = include_last_used_time unless include_last_used_time.nil? + end + response.body + end + + def get(user_id:, include_last_used_time: nil) + response = client.connection.get("#{PATH}/db/#{user_id}") do |req| + req.params["includeLastUsedTime"] = include_last_used_time unless include_last_used_time.nil? + end + response.body + end + + # Create a database user. The response contains the user's API key. + def create(user_id:) + response = client.connection.post("#{PATH}/db/#{user_id}") + response.body + end + + def delete(user_id:) + response = client.connection.delete("#{PATH}/db/#{user_id}") + response.success? + end + + def activate(user_id:) + response = client.connection.post("#{PATH}/db/#{user_id}/activate") + response.success? + end + + # @param revoke_key [Boolean] Also revoke the user's API key + def deactivate(user_id:, revoke_key: nil) + response = client.connection.post("#{PATH}/db/#{user_id}/deactivate") do |req| + req.body = {revoke_key: revoke_key} unless revoke_key.nil? + end + response.success? + end + + # Rotate a user's API key. The response contains the new key. + def rotate_key(user_id:) + response = client.connection.post("#{PATH}/db/#{user_id}/rotate-key") + response.body + end + + # @param user_type [String] "db" or "oidc" + def roles(user_id:, user_type: "db", include_full_roles: nil) + validate_user_type!(user_type) + + response = client.connection.get("authz/users/#{user_id}/roles/#{user_type}") do |req| + req.params["includeFullRoles"] = include_full_roles unless include_full_roles.nil? + end + response.body + end + + def assign_roles(user_id:, roles:, user_type: "db") + update_roles(user_id, "assign", roles, user_type) + end + + def revoke_roles(user_id:, roles:, user_type: "db") + update_roles(user_id, "revoke", roles, user_type) + end + + private + + def update_roles(user_id, action, roles, user_type) + validate_user_type!(user_type) + + response = client.connection.post("authz/users/#{user_id}/#{action}") do |req| + req.body = {roles: Array(roles), userType: user_type} + end + response.success? + end + + def validate_user_type!(user_type) + unless USER_TYPES.include?(user_type.to_s) + raise ArgumentError, 'user_type must be either "db" or "oidc"' + end + end + end +end diff --git a/sig/weaviate.rbs b/sig/weaviate.rbs index 7ac30df..d6d6d94 100644 --- a/sig/weaviate.rbs +++ b/sig/weaviate.rbs @@ -1,4 +1,422 @@ module Weaviate VERSION: String - # See the writing guide of rbs: https://github.com/ruby/rbs#guides + + # Parsed JSON returned by Weaviate + type json = untyped + + # A raw GraphQL string, or a Ruby value serialized to GraphQL + type graphql_argument = String | Hash[untyped, untyped] | Array[untyped] + + type consistency_level = String | Symbol + + class Error < StandardError + end + + class ConnectionFailed < Error + end + + class TimeoutError < ConnectionFailed + end + + class HTTPError < Error + attr_reader response: Connection::Response? + + def initialize: (?String? message, ?response: Connection::Response?) -> void + def status: () -> Integer? + def body: () -> json + end + + class ClientError < HTTPError + end + + class BadRequestError < ClientError + end + + class UnauthorizedError < ClientError + end + + class ForbiddenError < ClientError + end + + class ResourceNotFound < ClientError + end + + class ConflictError < ClientError + end + + class UnprocessableEntityError < ClientError + end + + class ServerError < HTTPError + end + + class GraphQLError < Error + attr_reader errors: Array[Hash[String, untyped]] + + def initialize: (Array[Hash[String, untyped]] errors) -> void + end + + class Client + API_VERSION: String + API_KEY_HEADERS: Hash[Symbol, String] + + attr_reader url: String + attr_reader api_key: String? + attr_reader model_service: Symbol? + attr_reader model_service_api_key: String? + attr_reader headers: Hash[String, String] + attr_reader proxy: String? + attr_reader logger: Logger + attr_reader open_timeout: Numeric? + attr_reader read_timeout: Numeric? + attr_reader write_timeout: Numeric? + + def initialize: ( + url: String, + ?api_key: String?, + ?model_service: Symbol?, + ?model_service_api_key: String?, + ?headers: Hash[String, String], + ?proxy: String?, + ?logger: Logger?, + ?open_timeout: Numeric?, + ?read_timeout: Numeric?, + ?write_timeout: Numeric?, + ?adapter: untyped + ) -> void + + def oidc: () -> json + def collections: () -> Collections + def schema: () -> Collections + def meta: () -> json + def nodes: (?output: String?, ?class_name: String?, ?shard_name: String?) -> json + def live?: () -> bool + def ready?: () -> bool + def backups: () -> Backups + def classifications: () -> Classifications + def objects: () -> Objects + def query: () -> Query + def aliases: () -> Aliases + def users: () -> Users + def roles: () -> Roles + def groups: () -> Groups + def graphql: () -> GraphQL + def connection: () -> Connection + end + + class Connection + class Request < Struct[untyped] + attr_accessor params: Hash[String, untyped] + attr_accessor headers: Hash[String, String] + attr_accessor body: untyped + end + + class Response < Struct[untyped] + attr_accessor status: Integer + attr_accessor reason_phrase: String + attr_accessor headers: Hash[String, Array[String]] + attr_accessor body: json + + def success?: () -> bool + end + + attr_reader url: String + attr_reader headers: Hash[String, String] + attr_reader logger: Logger? + attr_reader proxy: URI::Generic? + attr_reader timeouts: Hash[Symbol, Numeric] + + def initialize: ( + url: String, + ?headers: Hash[String, String], + ?logger: Logger?, + ?proxy: String?, + ?open_timeout: Numeric?, + ?read_timeout: Numeric?, + ?write_timeout: Numeric? + ) -> void + + def get: (String path) ?{ (Request) -> void } -> Response + def head: (String path) ?{ (Request) -> void } -> Response + def post: (String path) ?{ (Request) -> void } -> Response + def put: (String path) ?{ (Request) -> void } -> Response + def patch: (String path) ?{ (Request) -> void } -> Response + def delete: (String path) ?{ (Request) -> void } -> Response + end + + class Base + attr_reader client: Client + + def initialize: (client: Client) -> void + end + + class GraphQL < Base + def execute: (String query) -> json + end + + class Collections < Base + def list: () -> json + def get: (class_name: String) -> json + + def create: ( + class_name: String, + ?description: String?, + ?properties: Array[Hash[untyped, untyped]]?, + ?multi_tenant: bool, + ?auto_tenant_creation: bool, + ?auto_tenant_activation: bool, + ?vector_index_type: String?, + ?vector_index_config: Hash[untyped, untyped]?, + ?vectorizer: String?, + ?module_config: Hash[untyped, untyped]?, + ?inverted_index_config: Hash[untyped, untyped]?, + ?replication_config: Hash[untyped, untyped]?, + ?vector_config: Hash[untyped, untyped]? + ) -> json + + def update: ( + class_name: String, + ?description: String?, + ?vector_index_type: String?, + ?vector_index_config: Hash[untyped, untyped]?, + ?vectorizer: String?, + ?module_config: Hash[untyped, untyped]?, + ?properties: Array[Hash[untyped, untyped]]?, + ?inverted_index_config: Hash[untyped, untyped]?, + ?replication_config: Hash[untyped, untyped]?, + ?vector_config: Hash[untyped, untyped]? + ) -> json + + def delete: (class_name: String) -> bool + def add_property: (class_name: String, property: Hash[untyped, untyped]) -> json + def add_tenants: (class_name: String, tenants: Array[String | Hash[untyped, untyped]]) -> json + def update_tenants: (class_name: String, tenants: Array[String | Hash[untyped, untyped]]) -> json + def get_tenant: (class_name: String, tenant: String) -> json + def tenant_exists?: (class_name: String, tenant: String) -> bool + def list_tenants: (class_name: String) -> json + def remove_tenants: (class_name: String, tenants: Array[String]) -> json + def shards: (class_name: String, ?tenant: String?) -> json + def update_shard_status: (class_name: String, shard_name: String, status: String) -> json + end + + Schema: singleton(Collections) + + class Objects < Base + def list: ( + ?class_name: String?, + ?limit: Integer?, + ?tenant: String?, + ?offset: Integer?, + ?after: String?, + ?include: String?, + ?sort: String?, + ?order: String? + ) -> json + + def create: ( + class_name: String, + properties: Hash[untyped, untyped], + ?tenant: String?, + ?consistency_level: consistency_level?, + ?id: String?, + ?vector: Array[Float]? + ) -> json + + def batch_create: (objects: Array[Hash[untyped, untyped]], ?consistency_level: consistency_level?, ?tenant: String?) -> json + def get: (class_name: String, id: String, ?include: String?, ?consistency_level: consistency_level?, ?tenant: String?) -> json + def exists?: (class_name: String, id: String, ?consistency_level: consistency_level?, ?tenant: String?) -> bool + + def update: ( + class_name: String, + id: String, + properties: Hash[untyped, untyped], + ?vector: Array[Float]?, + ?tenant: String?, + ?consistency_level: consistency_level? + ) -> json + + def replace: ( + class_name: String, + id: String, + properties: Hash[untyped, untyped], + ?vector: Array[Float]?, + ?tenant: String?, + ?consistency_level: consistency_level? + ) -> json + + def delete: (class_name: String, id: String, ?consistency_level: consistency_level?, ?tenant: String?) -> bool + + def batch_delete: ( + class_name: String, + where: Hash[untyped, untyped], + ?consistency_level: consistency_level?, + ?output: String?, + ?dry_run: bool?, + ?tenant: String? + ) -> json + + def add_reference: ( + class_name: String, + id: String, + property: String, + to_class_name: String, + to_id: String, + ?tenant: String?, + ?consistency_level: consistency_level? + ) -> bool + + def replace_references: ( + class_name: String, + id: String, + property: String, + to: Array[{ class_name: String, id: String }], + ?tenant: String?, + ?consistency_level: consistency_level? + ) -> bool + + def delete_reference: ( + class_name: String, + id: String, + property: String, + to_class_name: String, + to_id: String, + ?tenant: String?, + ?consistency_level: consistency_level? + ) -> bool + + def batch_create_references: (references: Array[Hash[Symbol, String]], ?consistency_level: consistency_level?) -> json + def validate: (class_name: String, properties: Hash[untyped, untyped], ?id: String?) -> bool + end + + class Query < Base + def get: ( + class_name: String, + fields: String, + ?after: String?, + ?tenant: String?, + ?limit: Integer | String | nil, + ?autocut: Integer | String | nil, + ?offset: Integer | String | nil, + ?sort: graphql_argument?, + ?where: graphql_argument?, + ?near_text: graphql_argument?, + ?near_vector: graphql_argument?, + ?near_image: graphql_argument?, + ?near_object: graphql_argument?, + ?with_hybrid: graphql_argument?, + ?bm25: graphql_argument?, + ?ask: graphql_argument?, + ?group_by: graphql_argument? + ) -> json + + def aggs: ( + class_name: String, + ?fields: String?, + ?tenant: String?, + ?where: graphql_argument?, + ?object_limit: Integer | String | nil, + ?near_text: graphql_argument?, + ?near_vector: graphql_argument?, + ?near_image: graphql_argument?, + ?near_object: graphql_argument?, + ?group_by: Array[String] | String | nil, + ?limit: Integer | String | nil, + ?with_hybrid: graphql_argument? + ) -> json + + # @deprecated + def explore: ( + fields: String, + ?after: String?, + ?limit: Integer | String | nil, + ?offset: Integer | String | nil, + ?sort: graphql_argument?, + ?where: graphql_argument?, + ?near_text: graphql_argument?, + ?near_vector: graphql_argument?, + ?near_image: graphql_argument?, + ?near_object: graphql_argument? + ) -> json + end + + class Backups < Base + def list: (backend: String, ?order: String?) -> json + def create: (backend: String, id: String, ?include: Array[String]?, ?exclude: Array[String]?, ?config: Hash[untyped, untyped]?) -> json + def get: (backend: String, id: String, ?bucket: String?, ?path: String?) -> json + def cancel: (backend: String, id: String, ?bucket: String?, ?path: String?) -> bool + def restore: (backend: String, id: String, ?include: Array[String]?, ?exclude: Array[String]?, ?config: Hash[untyped, untyped]?) -> json + def restore_status: (backend: String, id: String, ?bucket: String?, ?path: String?) -> json + def cancel_restore: (backend: String, id: String, ?bucket: String?, ?path: String?) -> bool + end + + class Classifications < Base + def get: (id: String) -> json + + def create: ( + class_name: String, + type: String, + ?classify_properties: Array[String]?, + ?based_on_properties: Array[String]?, + ?settings: Hash[untyped, untyped]?, + ?filters: Hash[untyped, untyped]? + ) -> json + end + + class Nodes < Base + def list: (?output: String?, ?class_name: String?, ?shard_name: String?) -> json + end + + class Health < Base + def live?: () -> bool + def ready?: () -> bool + end + + class Meta < Base + def get: () -> json + end + + class OIDC < Base + def get: () -> json + end + + class Aliases < Base + def list: (?class_name: String?) -> json + def create: (alias_name: String, class_name: String) -> json + def get: (alias_name: String) -> json + def update: (alias_name: String, class_name: String) -> json + def delete: (alias_name: String) -> bool + end + + class Users < Base + def own_info: () -> json + def list: (?include_last_used_time: bool?) -> json + def get: (user_id: String, ?include_last_used_time: bool?) -> json + def create: (user_id: String) -> json + def delete: (user_id: String) -> bool + def activate: (user_id: String) -> bool + def deactivate: (user_id: String, ?revoke_key: bool?) -> bool + def rotate_key: (user_id: String) -> json + def roles: (user_id: String, ?user_type: String, ?include_full_roles: bool?) -> json + def assign_roles: (user_id: String, roles: Array[String] | String, ?user_type: String) -> bool + def revoke_roles: (user_id: String, roles: Array[String] | String, ?user_type: String) -> bool + end + + class Roles < Base + def list: () -> json + def get: (name: String) -> json + def create: (name: String, permissions: Array[Hash[untyped, untyped]]) -> bool + def delete: (name: String) -> bool + def add_permissions: (name: String, permissions: Array[Hash[untyped, untyped]]) -> bool + def remove_permissions: (name: String, permissions: Array[Hash[untyped, untyped]]) -> bool + def has_permission?: (name: String, permission: Hash[untyped, untyped]) -> bool + def user_assignments: (name: String) -> json + def group_assignments: (name: String) -> json + end + + class Groups < Base + def list: () -> json + def roles: (group_id: String, ?include_full_roles: bool?) -> json + def assign_roles: (group_id: String, roles: Array[String] | String) -> bool + def revoke_roles: (group_id: String, roles: Array[String] | String) -> bool + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ed5ad11..16cd408 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,8 @@ require "ostruct" require "weaviate" +Dir[File.join(__dir__, "support", "**", "*.rb")].sort.each { |f| require f } + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" diff --git a/spec/support/http_helpers.rb b/spec/support/http_helpers.rb new file mode 100644 index 0000000..643b0a0 --- /dev/null +++ b/spec/support/http_helpers.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Stubs Net::HTTP and records the requests the client sends, so specs can +# check the HTTP method, path, query and body. +module HTTPHelpers + def stub_weaviate(status: 200, body: "{}", content_type: "application/json") + requests = [] + response_class = Net::HTTPResponse::CODE_TO_OBJ.fetch(status.to_s) + + http = instance_double(Net::HTTP) + allow(http).to receive(:request) do |req| + requests << req + response = response_class.new("1.1", status.to_s, "Message") + response["Content-Type"] = content_type + response.instance_variable_set(:@body, body) + response.instance_variable_set(:@read, true) + response + end + allow(Net::HTTP).to receive(:start) { |*_args, **_kwargs, &block| block.call(http) } + + requests + end + + def request_summary(req) + [req.method, req.path, req.body && JSON.parse(req.body)] + end +end + +RSpec.configure do |config| + config.include HTTPHelpers +end diff --git a/spec/weaviate/client_spec.rb b/spec/weaviate/client_spec.rb index 8374284..896954c 100644 --- a/spec/weaviate/client_spec.rb +++ b/spec/weaviate/client_spec.rb @@ -50,11 +50,33 @@ expect(client.connection.url).to eq("http://localhost:8080/v1") expect(client.connection.headers).to eq( + "User-Agent" => "weaviate-ruby/#{Weaviate::VERSION}", "Authorization" => "Bearer abc", "X-OpenAI-Api-Key" => "123" ) end + it "merges custom headers over the defaults" do + client = described_class.new( + url: "http://localhost:8080", + model_service: :google_studio, + model_service_api_key: "goog", + headers: {"X-Aws-Access-Key" => "aws-access", "X-Aws-Secret-Key" => "aws-secret"} + ) + + expect(client.connection.headers).to include( + "X-Goog-Studio-Api-Key" => "goog", + "X-Aws-Access-Key" => "aws-access", + "X-Aws-Secret-Key" => "aws-secret" + ) + end + + it "passes the timeouts" do + client = described_class.new(url: "http://localhost:8080", open_timeout: 2, read_timeout: 30) + + expect(client.connection.timeouts).to eq(open_timeout: 2, read_timeout: 30) + end + it "accepts a proxy" do client = described_class.new(url: "http://localhost:8080", proxy: "http://user:pass@proxy.local:8888") diff --git a/spec/weaviate/collections_spec.rb b/spec/weaviate/collections_spec.rb index bae4c7a..74d34aa 100644 --- a/spec/weaviate/collections_spec.rb +++ b/spec/weaviate/collections_spec.rb @@ -235,6 +235,19 @@ ) expect(response.dig("class")).to eq("Question") end + + it "sends tenants as JSON, accepting names or hashes" do + captured_request = nil + allow_any_instance_of(Weaviate::Connection).to receive(:post) do |_, _path, &block| + captured_request = OpenStruct.new(body: nil) + block.call(captured_request) + response + end + + schema.add_tenants(class_name: "Question", tenants: ['quote"d', {name: "cold", activityStatus: "COLD"}]) + + expect(captured_request.body).to eq([{name: 'quote"d'}, {name: "cold", activityStatus: "COLD"}]) + end end describe "#list_tenants" diff --git a/spec/weaviate/connection_spec.rb b/spec/weaviate/connection_spec.rb index 34e44b1..1174dbf 100644 --- a/spec/weaviate/connection_spec.rb +++ b/spec/weaviate/connection_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require "logger" require "stringio" RSpec.describe Weaviate::Connection do @@ -8,7 +9,7 @@ let(:connection) { described_class.new( url: "http://localhost:8080/v1/", - headers: {"Authorization" => "Bearer secret", "X-OpenAI-Api-Key" => "sk-secret"}, + headers: {"Authorization" => "Bearer secret", "X-OpenAI-Api-Key" => "sk-secret", "X-Databricks-Token" => "dapi-value-1", "X-Aws-Secret-Key" => "aws-value-2"}, logger: Logger.new(log) ) } @@ -100,6 +101,15 @@ def stub_http(response) expect { connection.get("meta") }.to raise_error(Weaviate::TimeoutError) end + it "passes timeouts to Net::HTTP" do + connection = described_class.new(url: "http://localhost:8080/v1", open_timeout: 2, read_timeout: 30) + stub_http(http_response(Net::HTTPOK, 200, body: "{}")) + + connection.get("meta") + + expect(@start_kwargs).to eq(use_ssl: false, open_timeout: 2, read_timeout: 30) + end + it "connects through the configured proxy" do connection = described_class.new(url: "https://example.weaviate.network/v1", proxy: "http://user:pass@proxy.local:8888") stub_http(http_response(Net::HTTPOK, 200, body: "{}")) @@ -118,6 +128,10 @@ def stub_http(response) expect(log.string).to include("GET http://localhost:8080/v1/meta") expect(log.string).to include("authorization: [FILTERED]") expect(log.string).to include("x-openai-api-key: [FILTERED]") - expect(log.string).not_to include("secret") + expect(log.string).to include("x-databricks-token: [FILTERED]") + expect(log.string).to include("x-aws-secret-key: [FILTERED]") + %w[Bearer\ secret sk-secret dapi-value-1 aws-value-2].each do |value| + expect(log.string).not_to include(value) + end end end diff --git a/spec/weaviate/endpoints_spec.rb b/spec/weaviate/endpoints_spec.rb new file mode 100644 index 0000000..d3cb7e3 --- /dev/null +++ b/spec/weaviate/endpoints_spec.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Request-level specs for endpoints added for parity with the Weaviate 1.38 REST API +RSpec.describe "Weaviate endpoints" do + let(:client) { Weaviate::Client.new(url: "http://localhost:8080", logger: Logger.new(nil)) } + + describe Weaviate::Objects do + let!(:requests) { stub_weaviate } + let(:objects) { client.objects } + + it "adds a reference" do + expect(objects.add_reference(class_name: "Post", id: "p1", property: "ofCategory", to_class_name: "Category", to_id: "c1", tenant: "a")).to eq(true) + expect(request_summary(requests.last)).to eq( + ["POST", "/v1/objects/Post/p1/references/ofCategory?tenant=a", {"beacon" => "weaviate://localhost/Category/c1"}] + ) + end + + it "replaces references" do + objects.replace_references(class_name: "Post", id: "p1", property: "ofCategory", to: [{class_name: "Category", id: "c1"}, {class_name: "Category", id: "c2"}]) + expect(request_summary(requests.last)).to eq( + ["PUT", "/v1/objects/Post/p1/references/ofCategory", [{"beacon" => "weaviate://localhost/Category/c1"}, {"beacon" => "weaviate://localhost/Category/c2"}]] + ) + end + + it "deletes a reference" do + objects.delete_reference(class_name: "Post", id: "p1", property: "ofCategory", to_class_name: "Category", to_id: "c1", consistency_level: "quorum") + expect(request_summary(requests.last)).to eq( + ["DELETE", "/v1/objects/Post/p1/references/ofCategory?consistency_level=QUORUM", {"beacon" => "weaviate://localhost/Category/c1"}] + ) + end + + it "batch creates references" do + objects.batch_create_references(references: [ + {from_class_name: "Post", from_id: "p1", property: "ofCategory", to_class_name: "Category", to_id: "c1"}, + {from_class_name: "Post", from_id: "p2", property: "ofCategory", to_class_name: "Category", to_id: "c1", tenant: "a"} + ]) + expect(request_summary(requests.last)).to eq( + ["POST", "/v1/batch/references", [ + {"from" => "weaviate://localhost/Post/p1/ofCategory", "to" => "weaviate://localhost/Category/c1"}, + {"from" => "weaviate://localhost/Post/p2/ofCategory", "to" => "weaviate://localhost/Category/c1", "tenant" => "a"} + ]] + ) + end + end + + describe Weaviate::Collections do + let(:collections) { client.collections } + + it "updates tenants" do + requests = stub_weaviate(body: "[]") + collections.update_tenants(class_name: "Question", tenants: [{name: "a", activityStatus: "COLD"}]) + expect(request_summary(requests.last)).to eq(["PUT", "/v1/schema/Question/tenants", [{"name" => "a", "activityStatus" => "COLD"}]]) + end + + it "gets a tenant" do + requests = stub_weaviate(body: '{"name": "a", "activityStatus": "HOT"}') + expect(collections.get_tenant(class_name: "Question", tenant: "a")).to eq("name" => "a", "activityStatus" => "HOT") + expect(request_summary(requests.last)).to eq(["GET", "/v1/schema/Question/tenants/a", nil]) + end + + it "checks if a tenant exists" do + stub_weaviate(body: "") + expect(collections.tenant_exists?(class_name: "Question", tenant: "a")).to eq(true) + + stub_weaviate(status: 404, body: "") + expect(collections.tenant_exists?(class_name: "Question", tenant: "missing")).to eq(false) + end + + it "filters shards by tenant" do + requests = stub_weaviate(body: "[]") + collections.shards(class_name: "Question", tenant: "a") + expect(request_summary(requests.last)).to eq(["GET", "/v1/schema/Question/shards?tenant=a", nil]) + end + end + + describe Weaviate::Backups do + let!(:requests) { stub_weaviate(body: "[]") } + let(:backups) { client.backups } + + it "lists backups" do + backups.list(backend: "s3", order: "asc") + expect(request_summary(requests.last)).to eq(["GET", "/v1/backups/s3?order=asc", nil]) + end + + it "passes config when creating and restoring" do + backups.create(backend: "s3", id: "b1", config: {Bucket: "my-bucket", Path: "backups"}) + expect(request_summary(requests.last)).to eq(["POST", "/v1/backups/s3", {"id" => "b1", "config" => {"Bucket" => "my-bucket", "Path" => "backups"}}]) + + backups.restore(backend: "s3", id: "b1", config: {Bucket: "my-bucket"}) + expect(request_summary(requests.last)).to eq(["POST", "/v1/backups/s3/b1/restore", {"config" => {"Bucket" => "my-bucket"}}]) + end + + it "passes bucket and path when reading status" do + backups.get(backend: "s3", id: "b1", bucket: "my-bucket", path: "backups") + expect(request_summary(requests.last)).to eq(["GET", "/v1/backups/s3/b1?bucket=my-bucket&path=backups", nil]) + + backups.restore_status(backend: "s3", id: "b1", bucket: "my-bucket") + expect(request_summary(requests.last)).to eq(["GET", "/v1/backups/s3/b1/restore?bucket=my-bucket", nil]) + end + + it "cancels backups and restores" do + expect(backups.cancel(backend: "s3", id: "b1")).to eq(true) + expect(request_summary(requests.last)).to eq(["DELETE", "/v1/backups/s3/b1", nil]) + + expect(backups.cancel_restore(backend: "s3", id: "b1")).to eq(true) + expect(request_summary(requests.last)).to eq(["DELETE", "/v1/backups/s3/b1/restore", nil]) + end + end + + describe Weaviate::Nodes do + let!(:requests) { stub_weaviate(body: '{"nodes": []}') } + + it "lists nodes" do + client.nodes + expect(request_summary(requests.last)).to eq(["GET", "/v1/nodes", nil]) + end + + it "passes output, collection and shard" do + client.nodes(output: "verbose", class_name: "Question", shard_name: "abc") + expect(request_summary(requests.last)).to eq(["GET", "/v1/nodes/Question?output=verbose&shardName=abc", nil]) + end + end + + describe Weaviate::Aliases do + let!(:requests) { stub_weaviate } + let(:aliases) { client.aliases } + + it "manages aliases" do + aliases.list(class_name: "Question") + expect(request_summary(requests.last)).to eq(["GET", "/v1/aliases?class=Question", nil]) + + aliases.create(alias_name: "Q", class_name: "Question") + expect(request_summary(requests.last)).to eq(["POST", "/v1/aliases", {"alias" => "Q", "class" => "Question"}]) + + aliases.get(alias_name: "Q") + expect(request_summary(requests.last)).to eq(["GET", "/v1/aliases/Q", nil]) + + aliases.update(alias_name: "Q", class_name: "QuestionV2") + expect(request_summary(requests.last)).to eq(["PUT", "/v1/aliases/Q", {"class" => "QuestionV2"}]) + + expect(aliases.delete(alias_name: "Q")).to eq(true) + expect(request_summary(requests.last)).to eq(["DELETE", "/v1/aliases/Q", nil]) + end + end + + describe Weaviate::Users do + let!(:requests) { stub_weaviate } + let(:users) { client.users } + + it "manages database users" do + users.own_info + users.list(include_last_used_time: true) + users.get(user_id: "bob") + users.create(user_id: "bob") + users.activate(user_id: "bob") + users.deactivate(user_id: "bob", revoke_key: true) + users.rotate_key(user_id: "bob") + users.delete(user_id: "bob") + + expect(requests.map { |r| request_summary(r) }).to eq([ + ["GET", "/v1/users/own-info", nil], + ["GET", "/v1/users/db?includeLastUsedTime=true", nil], + ["GET", "/v1/users/db/bob", nil], + ["POST", "/v1/users/db/bob", nil], + ["POST", "/v1/users/db/bob/activate", nil], + ["POST", "/v1/users/db/bob/deactivate", {"revoke_key" => true}], + ["POST", "/v1/users/db/bob/rotate-key", nil], + ["DELETE", "/v1/users/db/bob", nil] + ]) + end + + it "manages user roles" do + users.roles(user_id: "bob", include_full_roles: true) + users.assign_roles(user_id: "bob", roles: ["reader"]) + users.revoke_roles(user_id: "alice", roles: "reader", user_type: "oidc") + + expect(requests.map { |r| request_summary(r) }).to eq([ + ["GET", "/v1/authz/users/bob/roles/db?includeFullRoles=true", nil], + ["POST", "/v1/authz/users/bob/assign", {"roles" => ["reader"], "userType" => "db"}], + ["POST", "/v1/authz/users/alice/revoke", {"roles" => ["reader"], "userType" => "oidc"}] + ]) + end + + it "validates the user type" do + expect { users.roles(user_id: "bob", user_type: "ldap") }.to raise_error(ArgumentError) + end + end + + describe Weaviate::Roles do + let(:roles) { client.roles } + let(:permission) { {action: "read_data", data: {collection: "Question"}} } + + it "manages roles" do + requests = stub_weaviate + roles.list + roles.get(name: "reader") + roles.create(name: "reader", permissions: [permission]) + roles.add_permissions(name: "reader", permissions: [permission]) + roles.remove_permissions(name: "reader", permissions: [permission]) + roles.user_assignments(name: "reader") + roles.group_assignments(name: "reader") + roles.delete(name: "reader") + + json_permission = {"action" => "read_data", "data" => {"collection" => "Question"}} + expect(requests.map { |r| request_summary(r) }).to eq([ + ["GET", "/v1/authz/roles", nil], + ["GET", "/v1/authz/roles/reader", nil], + ["POST", "/v1/authz/roles", {"name" => "reader", "permissions" => [json_permission]}], + ["POST", "/v1/authz/roles/reader/add-permissions", {"permissions" => [json_permission]}], + ["POST", "/v1/authz/roles/reader/remove-permissions", {"permissions" => [json_permission]}], + ["GET", "/v1/authz/roles/reader/user-assignments", nil], + ["GET", "/v1/authz/roles/reader/group-assignments", nil], + ["DELETE", "/v1/authz/roles/reader", nil] + ]) + end + + it "checks a permission" do + requests = stub_weaviate(body: "true") + expect(roles.has_permission?(name: "reader", permission: permission)).to eq(true) + expect(request_summary(requests.last)).to eq(["POST", "/v1/authz/roles/reader/has-permission", {"action" => "read_data", "data" => {"collection" => "Question"}}]) + + stub_weaviate(body: "false") + expect(roles.has_permission?(name: "reader", permission: permission)).to eq(false) + end + end + + describe Weaviate::Groups do + let!(:requests) { stub_weaviate } + + it "manages OIDC group roles" do + client.groups.list + client.groups.roles(group_id: "admins") + client.groups.assign_roles(group_id: "admins", roles: ["admin"]) + client.groups.revoke_roles(group_id: "admins", roles: ["admin"]) + + expect(requests.map { |r| request_summary(r) }).to eq([ + ["GET", "/v1/authz/groups/oidc", nil], + ["GET", "/v1/authz/groups/admins/roles/oidc", nil], + ["POST", "/v1/authz/groups/admins/assign", {"roles" => ["admin"], "groupType" => "oidc"}], + ["POST", "/v1/authz/groups/admins/revoke", {"roles" => ["admin"], "groupType" => "oidc"}] + ]) + end + end +end diff --git a/spec/weaviate/health_spec.rb b/spec/weaviate/health_spec.rb index 6573f83..91c516b 100644 --- a/spec/weaviate/health_spec.rb +++ b/spec/weaviate/health_spec.rb @@ -36,4 +36,22 @@ expect(client.ready?).to eq(true) end end + + describe "when Weaviate is not ready or unreachable" do + it "returns false on an error response" do + allow_any_instance_of(Weaviate::Connection).to receive(:get) + .and_raise(Weaviate::ServerError.new(response: Weaviate::Connection::Response.new(status: 503))) + + expect(client.ready?).to eq(false) + expect(client.live?).to eq(false) + end + + it "returns false when the connection fails" do + allow_any_instance_of(Weaviate::Connection).to receive(:get) + .and_raise(Weaviate::ConnectionFailed, "Connection refused") + + expect(client.ready?).to eq(false) + expect(client.live?).to eq(false) + end + end end diff --git a/spec/weaviate/objects_spec.rb b/spec/weaviate/objects_spec.rb index e34f086..3fa0192 100644 --- a/spec/weaviate/objects_spec.rb +++ b/spec/weaviate/objects_spec.rb @@ -72,7 +72,7 @@ it "returns false when the object does not exist" do allow_any_instance_of(Weaviate::Connection).to receive(:head) .with("objects/Question/456") - .and_raise(Weaviate::ResourceNotFound.new(response: OpenStruct.new(status: 404))) + .and_raise(Weaviate::ResourceNotFound.new(response: Weaviate::Connection::Response.new(status: 404))) expect(objects.exists?(class_name: "Question", id: "456")).to eq(false) end diff --git a/spec/weaviate/query_spec.rb b/spec/weaviate/query_spec.rb index 622e414..72b2e6c 100644 --- a/spec/weaviate/query_spec.rb +++ b/spec/weaviate/query_spec.rb @@ -93,6 +93,52 @@ def squish(str) ) end + it "serializes Ruby hashes, arrays and symbols into GraphQL arguments" do + query.get( + class_name: "Question", + fields: "question", + where: { + operator: "And", + operands: [ + {path: ["category"], operator: :Equal, valueText: "Say \"hi\"\n"}, + {path: ["points"], operator: "GreaterThan", valueNumber: 1.5}, + {path: ["active"], operator: :Equal, valueBoolean: true} + ] + }, + near_text: {concepts: ["biology"], certainty: 0.7}, + sort: [{path: ["points"], order: "desc"}], + with_hybrid: {query: "Sweets", alpha: 0.5, fusionType: :relativeScoreFusion} + ) + + expect(squish(@captured_query)).to eq( + '{ Get { Question(nearText: { concepts: ["biology"], certainty: 0.7 }, ' \ + 'hybrid: { query: "Sweets", alpha: 0.5, fusionType: relativeScoreFusion }, ' \ + 'where: { operator: And, operands: [{ path: ["category"], operator: Equal, valueText: "Say \\"hi\\"\\n" }, ' \ + '{ path: ["points"], operator: GreaterThan, valueNumber: 1.5 }, { path: ["active"], operator: Equal, valueBoolean: true }] }, ' \ + 'sort: [{ path: ["points"], order: desc }]) { question } } }' + ) + end + + it "quotes strings for enum keys that are not valid enum names" do + query.get(class_name: "Question", fields: "question", where: {path: ["id"], operator: "Equal) { x", valueText: "a"}) + + expect(@captured_query).to include('operator: "Equal) { x"') + end + + it "rejects keys that are not valid GraphQL names" do + expect { + query.get(class_name: "Question", fields: "question", where: {"path) { x" => ["id"]}) + }.to raise_error(ArgumentError, /Invalid GraphQL name/) + end + + it "passes group_by" do + query.get(class_name: "Question", fields: "question", near_text: {concepts: ["biology"]}, group_by: {path: ["category"], groups: 2, objectsPerGroup: 3}) + + expect(squish(@captured_query)).to eq( + '{ Get { Question(nearText: { concepts: ["biology"] }, groupBy: { path: ["category"], groups: 2, objectsPerGroup: 3 }) { question } } }' + ) + end + context "when the query returns errors" do let(:response) { OpenStruct.new(body: { @@ -157,6 +203,18 @@ def squish(str) end end + describe "#aggs limit and hybrid" do + let(:response) { OpenStruct.new(body: {"data" => {"Aggregate" => {"Question" => []}}}) } + + it "passes limit and hybrid" do + query.aggs(class_name: "Question", fields: "meta { count }", with_hybrid: {query: "biology", alpha: 0.5}, object_limit: 10, limit: 5) + + expect(squish(@captured_query)).to eq( + '{ Aggregate { Question(objectLimit: 10, limit: 5, hybrid: { query: "biology", alpha: 0.5 }) { meta { count } } } }' + ) + end + end + describe "#explore" do let(:response) { OpenStruct.new(body: { @@ -166,6 +224,14 @@ def squish(str) }) } + before { allow(query).to receive(:warn) } + + it "warns that it is deprecated, once" do + expect(query).to receive(:warn).with(/query.explore is deprecated/).once + + 2.times { query.explore(fields: "className", near_vector: "{ vector: [0.1] }") } + end + it "returns the query" do response = query.explore( fields: "certainty className", diff --git a/weaviate.gemspec b/weaviate.gemspec index 9c5918a..65b8c32 100644 --- a/weaviate.gemspec +++ b/weaviate.gemspec @@ -10,13 +10,13 @@ Gem::Specification.new do |spec| spec.summary = "Ruby wrapper for the Weaviate.io API" spec.description = "Ruby wrapper for the Weaviate.io API" - spec.homepage = "https://github.com/andreibondarev/weaviate-ruby" + spec.homepage = "https://github.com/patterns-ai-core/weaviate-ruby" spec.license = "MIT" - spec.required_ruby_version = ">= 2.6.0" + spec.required_ruby_version = ">= 2.7.0" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "https://github.com/andreibondarev/weaviate-ruby" - spec.metadata["changelog_uri"] = "https://github.com/andreibondarev/weaviate-ruby/CHANGELOG.md" + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git.