diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83323e8..8ef1e26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby: ["2.7", "3.0", "3.1", "3.2"] + ruby: ["3.3", "3.4", "4.0"] steps: - uses: actions/checkout@master @@ -29,4 +29,4 @@ jobs: - name: Run tests run: | - bundle exec rspec + bundle exec rspec diff --git a/.gitignore b/.gitignore index b04a8c8..aec1287 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ # rspec failure tracking .rspec_status +.env diff --git a/Gemfile b/Gemfile index 2016e38..c4b2ce4 100644 --- a/Gemfile +++ b/Gemfile @@ -8,4 +8,4 @@ gemspec gem "rake", "~> 13.4" gem "rspec", "~> 3.13" -gem "standard", "~> 1.28.5" +gem "standard", "~> 1.56.0" diff --git a/Gemfile.lock b/Gemfile.lock index e6e71f4..93f23d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,40 +2,40 @@ PATH remote: . specs: qdrant-ruby (0.9.10) - faraday (>= 2.0.1, < 3) + logger GEM remote: https://rubygems.org/ specs: - ast (2.4.2) - base64 (0.2.0) - byebug (11.1.3) + ast (2.4.3) + byebug (13.0.0) + reline (>= 0.6.0) coderay (1.1.3) diff-lcs (1.6.2) - faraday (2.8.1) - base64 - faraday-net_http (>= 2.0, < 3.1) - ruby2_keywords (>= 0.0.4) - faraday-net_http (3.0.2) - json (2.7.4) - language_server-protocol (3.17.0.3) + io-console (0.9.2) + json (2.21.2) + language_server-protocol (3.17.0.6) lint_roller (1.1.0) - method_source (1.0.0) - parallel (1.24.0) - parser (3.3.5.0) + logger (1.7.0) + method_source (1.1.0) + parallel (2.1.0) + parser (3.3.12.0) ast (~> 2.4.1) racc - pry (0.14.2) + prism (1.9.0) + pry (0.16.0) coderay (~> 1.1) method_source (~> 1.0) - pry-byebug (3.10.1) - byebug (~> 11.0) - pry (>= 0.13, < 0.15) + reline (>= 0.6.0) + pry-byebug (3.12.0) + byebug (~> 13.0) + pry (>= 0.13, < 0.17) racc (1.8.1) rainbow (3.1.1) rake (13.4.2) - regexp_parser (2.9.2) - rexml (3.4.2) + regexp_parser (2.12.0) + reline (0.7.0) + io-console (~> 0.5) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -45,40 +45,44 @@ GEM rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.6) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.6) - rubocop (1.50.2) + rspec-support (3.13.7) + rubocop (1.88.2) json (~> 2.3) - parallel (~> 1.10) - parser (>= 3.2.0.0) + 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.28.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.30.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) - ruby2_keywords (0.0.5) - standard (1.28.5) + standard (1.56.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.50.2) + rubocop (~> 1.88.0) standard-custom (~> 1.0.0) - standard-performance (~> 1.0.1) + standard-performance (~> 1.8) standard-custom (1.0.2) lint_roller (~> 1.0) rubocop (~> 1.50) - standard-performance (1.0.1) - lint_roller (~> 1.0) - rubocop-performance (~> 1.16.0) - unicode-display_width (2.6.0) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) PLATFORMS arm64-darwin-23 @@ -92,7 +96,7 @@ DEPENDENCIES qdrant-ruby! rake (~> 13.4) rspec (~> 3.13) - standard (~> 1.28.5) + standard (~> 1.56.0) BUNDLED WITH - 2.4.0 + 2.5.3 diff --git a/README.md b/README.md index 9041423..4e10e93 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,16 @@ client.set_lock( 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. +The default `bundle exec rake` task runs `lint` (standardrb auto-format), `spec`, and `e2e` (a hosted end-to-end smoke test against a real Qdrant deployment) in sequence. Because the E2E step needs real credentials, run it with your hosted endpoint and API key exported: + +```sh +export QDRANT_URL="https://" +export QDRANT_API_KEY="" +bundle exec rake +``` + +The individual steps are also available as `bundle exec rake lint`, `bundle exec rake spec`, and `bundle exec rake e2e`. Run the hosted check against an isolated or disposable project, since it creates and deletes a temporary collection. + To install this gem onto your local machine, run `bundle exec rake install`. 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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). ## Contributing diff --git a/Rakefile b/Rakefile index b6ae734..547573b 100644 --- a/Rakefile +++ b/Rakefile @@ -1,8 +1,19 @@ # frozen_string_literal: true require "bundler/gem_tasks" +require "rbconfig" require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) -task default: :spec +desc "Run the standardrb linter (auto-fix)" +task :lint do + sh "bundle exec standardrb --fix" +end + +desc "Run the hosted Qdrant API end-to-end smoke test" +task :e2e do + exec RbConfig.ruby, File.expand_path("script/e2e.rb", __dir__) +end + +task default: %i[lint spec e2e] diff --git a/lib/qdrant/client.rb b/lib/qdrant/client.rb index a744384..ae11974 100644 --- a/lib/qdrant/client.rb +++ b/lib/qdrant/client.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true -require "faraday" +require "logger" require "forwardable" +require_relative "client/connection" + module Qdrant class Client extend Forwardable @@ -14,11 +16,11 @@ class Client def initialize( url:, api_key: nil, - adapter: Faraday.default_adapter, raise_error: false, - logger: nil + logger: nil, + adapter: nil # Deprecated. Doesn't select the transport. Should be removed in subsequent releases ) - @url = url + @url = normalize_url(url) @api_key = api_key @adapter = adapter @raise_error = raise_error @@ -26,16 +28,12 @@ def initialize( end def connection - @connection ||= Faraday.new(url: url) do |faraday| - if api_key - faraday.headers["api-key"] = api_key - end - faraday.request :json - faraday.response :logger, @logger, {headers: true, bodies: true, errors: true} - faraday.response :raise_error if raise_error - faraday.response :json, content_type: /\bjson$/ - faraday.adapter adapter - end + @connection ||= Connection.new( + url: url, + api_key: api_key, + raise_error: raise_error, + logger: logger + ) end def aliases @@ -61,5 +59,14 @@ def clusters def points @points ||= Qdrant::Points.new(client: self) end + + private + + def normalize_url(url) + raise ArgumentError, "url must be a String" unless url.is_a?(String) + return url if url.start_with?("http://", "https://") + + "https://#{url}" + end end end diff --git a/lib/qdrant/client/connection.rb b/lib/qdrant/client/connection.rb new file mode 100644 index 0000000..9e203c0 --- /dev/null +++ b/lib/qdrant/client/connection.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "net/http" + +require_relative "request" +require_relative "response" + +module Qdrant + class Client + class Connection + def initialize(url:, api_key:, raise_error:, logger:) + @uri = url + @api_key = api_key + @raise_error = raise_error + @logger = logger + end + + def get(path, &block) + execute(Net::HTTP::Get, path, &block) + end + + def post(path, &block) + execute(Net::HTTP::Post, path, &block) + end + + def put(path, &block) + execute(Net::HTTP::Put, path, &block) + end + + def patch(path, &block) + execute(Net::HTTP::Patch, path, &block) + end + + def delete(path, &block) + execute(Net::HTTP::Delete, path, &block) + end + + private + + def execute(verb, path, &block) + response = RequestBuilder + .new(verb, @uri, path, @api_key, @logger) + .tap(&block) + .build + .perform(@raise_error) + + ResponseBuilder.new(response).build + end + end + end +end diff --git a/lib/qdrant/client/request.rb b/lib/qdrant/client/request.rb new file mode 100644 index 0000000..130f99d --- /dev/null +++ b/lib/qdrant/client/request.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "uri" + +module Qdrant + class Client + RequestData = Struct.new(:params, :body) + + class RequestBuilder + def initialize(verb, base_url, path, api_key, logger) + raise ArgumentError, "unsupported HTTP verb" unless verb < Net::HTTPRequest + + @verb = verb + @base_url = base_url + @path = path + @api_key = api_key + @logger = logger + + @request = RequestData.new({}, nil) + end + + def tap + yield @request if block_given? + self + end + + def build + Request.new build_uri, @verb, @request.body, @api_key, @logger + end + + private + + def build_uri + path, query = @path.to_s.split("?", 2) + + URI.join(@base_url, path).tap do |uri| + if (query_pairs = URI.decode_www_form(query.to_s) + @request.params.to_a).any? + uri.query = URI.encode_www_form(query_pairs) + end + end + end + end + + class Request + def initialize(uri, verb, body, api_key, logger) + @logger = logger + @uri = uri + @verb = verb + + @data = verb.new(uri.request_uri).tap do |request| + request["api-key"] = api_key if api_key + + if body + request.body = JSON.generate(body) + request["Content-Type"] = request["Accept"] = "application/json" + end + end + + logger.info("#{verb_name} #{uri}") + logger.info("Request headers: #{redacted_headers.inspect}") + logger.info("Request body: #{@data.body}") if @data.body + end + + def perform(raise_error) + @logger.info("Performing Request: #{verb_name} #{@uri}") + + response = Net::HTTP.new(@uri.host, @uri.port).tap do |h| + h.use_ssl = true if @uri.scheme == "https" + end.request(@data) + + response.value if raise_error + + @logger.info("Response status: #{response.code}") + response + rescue => e + @logger.error("#{verb_name} #{@uri} failed: #{e.class}: #{e.message}") + raise + end + + private + + def verb_name + @verb.name.split("::").last.upcase + end + + def redacted_headers + @data.to_hash.tap do |headers| + headers["api-key"] = "[FILTERED]" if headers.key?("api-key") + end + end + end + end +end diff --git a/lib/qdrant/client/response.rb b/lib/qdrant/client/response.rb new file mode 100644 index 0000000..215b901 --- /dev/null +++ b/lib/qdrant/client/response.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "json" + +module Qdrant + class Client + Response = Struct.new(:status, :headers, :body) + + class ResponseBuilder + JSON_CONTENT_TYPE_REGEX = /\bjson\z/ + + def initialize(response) + @response = response + end + + def build + Response.new(@response.code.to_i, @response.to_hash, parse_body) + end + + private + + def parse_body + body = @response.body + return body unless !body.nil? && !body.empty? && json_response? + + JSON.parse(body) + rescue JSON::ParserError + # Fallback to raw body if JSON parsing fails unexpectedly + body + end + + def json_response? + content_type = @response["Content-Type"] + return false if content_type.nil? + + media_type = content_type.split(";").first.to_s.strip + media_type.match?(JSON_CONTENT_TYPE_REGEX) + end + end + end +end diff --git a/lib/qdrant/points.rb b/lib/qdrant/points.rb index a76b0a1..0152bb0 100644 --- a/lib/qdrant/points.rb +++ b/lib/qdrant/points.rb @@ -51,7 +51,6 @@ def delete( ordering: nil, filter: nil ) - raise ArgumentError, "Either points or filter should be provided" if points.nil? && filter.nil? response = client.connection.post("collections/#{collection_name}/#{PATH}/delete") do |req| diff --git a/qdrant.gemspec b/qdrant.gemspec index 8ee39c9..765756a 100644 --- a/qdrant.gemspec +++ b/qdrant.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.description = "Ruby wrapper for the Qdrant vector search database API" spec.homepage = "https://github.com/andreibondarev/qdrant-ruby" spec.license = "Apache-2.0" - spec.required_ruby_version = ">= 2.6.0" + spec.required_ruby_version = ">= 3.3" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/andreibondarev/qdrant-ruby" @@ -35,6 +35,6 @@ Gem::Specification.new do |spec| # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html - spec.add_dependency "faraday", ">= 2.0.1", "< 3" + spec.add_dependency "logger" spec.add_development_dependency "pry-byebug", "~> 3.9" end diff --git a/script/e2e.rb b/script/e2e.rb new file mode 100644 index 0000000..d0cb248 --- /dev/null +++ b/script/e2e.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "logger" +require "securerandom" +require "qdrant" + +def assert!(condition, message) + raise "e2e assertion failed: #{message}" unless condition +end + +def ok_body!(body, operation) + assert!(body.is_a?(Hash), "#{operation} returned #{body.class}, expected Hash") + assert!(body["status"] == "ok", "#{operation} returned status #{body["status"].inspect}") + body +end + +url = ENV.fetch("QDRANT_URL", "").strip +api_key = ENV.fetch("QDRANT_API_KEY", "").strip + +abort "QDRANT_URL must be set to an https:// hosted endpoint" if url.empty? || !url.start_with?("https://") +abort "QDRANT_API_KEY must be set" if api_key.empty? + +logger = Logger.new($stderr) +logger.level = Logger::WARN + +client = Qdrant::Client.new( + url: url, + api_key: api_key, + raise_error: true, + logger: logger +) + +collection_name = "qdrant_ruby_e2e_#{Process.pid}_#{Time.now.utc.strftime("%Y%m%d%H%M%S")}_#{SecureRandom.hex(4)}" +collection_created = false +failure = nil + +begin + body = ok_body!(client.collections.list, "collections.list") + assert!(body.dig("result", "collections").is_a?(Array), "collections.list result is not an Array") + puts "e2e: connection and authentication passed" + + body = client.collections.create( + collection_name: collection_name, + vectors: {size: 3, distance: "Cosine"} + ) + collection_created = true + ok_body!(body, "collections.create") + assert!(body["result"] == true, "collections.create did not return result=true") + puts "e2e: collection created (#{collection_name})" + + body = ok_body!(client.collections.get(collection_name: collection_name), "collections.get") + status = body.dig("result", "status") + assert!(%w[green yellow].include?(status), "collection status was #{status.inspect}") + + points = [ + {id: 1, vector: [1.0, 0.0, 0.0], payload: {"source" => "hosted-e2e", "rank" => 1}}, + {id: 2, vector: [0.0, 1.0, 0.0], payload: {"source" => "hosted-e2e", "rank" => 2}} + ] + + body = ok_body!( + client.points.upsert(collection_name: collection_name, wait: true, points: points), + "points.upsert" + ) + assert!(body.dig("result", "status") == "completed", "points.upsert did not return a completed status") + + body = ok_body!(client.points.get(collection_name: collection_name, id: 1), "points.get") + assert!(body.dig("result", "id") == 1, "points.get returned the wrong point") + + body = ok_body!( + client.points.get_all( + collection_name: collection_name, + ids: [1, 2], + with_payload: true, + with_vector: true + ), + "points.get_all" + ) + results = body.fetch("result") + assert!(results.is_a?(Array) && results.length == 2, "points.get_all did not return two points") + assert!(results.map { |point| point["id"] }.sort == [1, 2], "points.get_all returned unexpected IDs") + assert!(results.find do |point| + point["id"] == 1 + end.dig("payload", "source") == "hosted-e2e", "point payload was not persisted") + puts "e2e: point write and read round trip passed" + + body = ok_body!( + client.points.search( + collection_name: collection_name, + vector: [1.0, 0.0, 0.0], + limit: 1, + with_payload: true, + with_vector: false + ), + "points.search" + ) + hit = body.fetch("result").first + assert!(hit && hit["id"] == 1, "points.search did not return point 1 first") + assert!(hit["score"].to_f > 0.99, "points.search score was #{hit["score"].inspect}") + + body = ok_body!(client.points.count(collection_name: collection_name, exact: true), "points.count") + assert!(body.dig("result", "count") == 2, "points.count did not return 2") + puts "e2e: search and count passed" + + body = ok_body!(client.points.delete(collection_name: collection_name, points: [1, 2], wait: true), "points.delete") + assert!(body.dig("result", "status") == "completed", "points.delete did not return a completed status") + body = ok_body!(client.points.count(collection_name: collection_name, exact: true), "points.count after delete") + assert!(body.dig("result", "count") == 0, "points.count after delete did not return 0") + puts "e2e: point deletion passed" +rescue => e + failure = e + warn "e2e failed: #{e.class}: #{e.message}" +ensure + if collection_created + begin + body = ok_body!(client.collections.delete(collection_name: collection_name), "collections.delete cleanup") + assert!(body["result"] == true, "collections.delete cleanup did not return result=true") + puts "e2e: temporary collection deleted" + rescue => e + failure ||= e + warn "e2e cleanup failed: #{e.class}: #{e.message}" + end + end +end + +if failure + exit 1 +else + puts "e2e passed" +end diff --git a/spec/qdrant/aliases_spec.rb b/spec/qdrant/aliases_spec.rb index 8148d4f..20a7b9e 100644 --- a/spec/qdrant/aliases_spec.rb +++ b/spec/qdrant/aliases_spec.rb @@ -14,11 +14,11 @@ describe "#list" do let(:response) { - OpenStruct.new(body: aliases_fixture) + Qdrant::Client::Response.new(nil, nil, aliases_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with(Qdrant::Aliases::PATH) .and_return(response) end diff --git a/spec/qdrant/clusters_spec.rb b/spec/qdrant/clusters_spec.rb index 6f3d3a2..5fbd86f 100644 --- a/spec/qdrant/clusters_spec.rb +++ b/spec/qdrant/clusters_spec.rb @@ -13,12 +13,12 @@ let(:cluster_fixture) { JSON.parse(File.read("spec/fixtures/cluster.json")) } let(:response) { - OpenStruct.new(body: cluster_fixture) + Qdrant::Client::Response.new(nil, nil, cluster_fixture) } describe "#info" do before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with("cluster") .and_return(response) end @@ -30,7 +30,7 @@ describe "#recover" do before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("cluster/recover") .and_return(response) end diff --git a/spec/qdrant/collections_spec.rb b/spec/qdrant/collections_spec.rb index bdd74a6..b228a82 100644 --- a/spec/qdrant/collections_spec.rb +++ b/spec/qdrant/collections_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require "stringio" RSpec.describe Qdrant::Collections do let(:client) { @@ -16,10 +17,10 @@ let(:aliases_fixture) { JSON.parse(File.read("spec/fixtures/aliases.json")) } describe "#list" do - let(:response) { OpenStruct.new(body: collections_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, collections_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with(Qdrant::Collections::PATH) .and_return(response) end @@ -30,10 +31,10 @@ end describe "#get" do - let(:response) { OpenStruct.new(body: collection_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, collection_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with("collections/test_collection") .and_return(response) end @@ -45,10 +46,10 @@ end describe "#create" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:put) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:put) .with("collections/test_collection") .and_return(response) end @@ -67,10 +68,10 @@ end describe "#delete" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:delete) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:delete) .with("collections/test_collection") .and_return(response) end @@ -83,10 +84,10 @@ end describe "#update" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:patch) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:patch) .with("collections/test_collection") .and_return(response) end @@ -104,10 +105,10 @@ end describe "#update_aliases" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/aliases") .and_return(response) end @@ -127,10 +128,10 @@ end describe "#aliases" do - let(:response) { OpenStruct.new(body: aliases_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, aliases_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with("collections/test_collection/aliases") .and_return(response) end @@ -142,10 +143,10 @@ end describe "#create_index" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:put) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:put) .with("collections/test_collection/index") .and_return(response) end @@ -161,57 +162,66 @@ end it "adds wait=false query param when specified" do - allow_any_instance_of(Faraday::Connection).to receive(:put) - .with("collections/test_collection/index?wait=false") - .and_return(response) - - response = collections.create_index( + request = nil + allow_any_instance_of(Qdrant::Client::Connection).to receive(:put) + .with("collections/test_collection/index") do |_path, &block| + request = Qdrant::Client::RequestData.new({}, nil).tap(&block) if block + response + end + + collections.create_index( collection_name: "test_collection", field_name: "description", field_schema: "text", wait: false ) - expect(response.dig("status")).to eq("ok") - expect(response.dig("result")).to eq(true) + + expect(request.params).to eq("wait" => false) end it "adds ordering query param when specified" do - allow_any_instance_of(Faraday::Connection).to receive(:put) - .with("collections/test_collection/index?ordering=weak") - .and_return(response) - - response = collections.create_index( + request = nil + allow_any_instance_of(Qdrant::Client::Connection).to receive(:put) + .with("collections/test_collection/index") do |_path, &block| + request = Qdrant::Client::RequestData.new({}, nil).tap(&block) if block + response + end + + collections.create_index( collection_name: "test_collection", field_name: "description", field_schema: "text", ordering: "weak" ) - expect(response.dig("status")).to eq("ok") - expect(response.dig("result")).to eq(true) + + expect(request.params).to eq("ordering" => "weak") end it "adds both wait=false and ordering params when specified" do - allow_any_instance_of(Faraday::Connection).to receive(:put) - .with("collections/test_collection/index?ordering=weak&wait=false") - .and_return(response) - - response = collections.create_index( + request = nil + allow_any_instance_of(Qdrant::Client::Connection).to receive(:put) + .with("collections/test_collection/index") do |_path, &block| + request = Qdrant::Client::RequestData.new({}, nil).tap(&block) if block + response + end + + collections.create_index( collection_name: "test_collection", field_name: "description", field_schema: "text", ordering: "weak", wait: false ) - expect(response.dig("status")).to eq("ok") - expect(response.dig("result")).to eq(true) + + expect(request.params).to eq("ordering" => "weak", "wait" => false) end end describe "#delete_index" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:delete) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:delete) .with("collections/test_collection/index/description") .and_return(response) end @@ -227,10 +237,10 @@ end describe "#cluster_info" do - let(:response) { OpenStruct.new(body: JSON.parse(File.read("spec/fixtures/collection_cluster.json"))) } + let(:response) { Qdrant::Client::Response.new(nil, nil, JSON.parse(File.read("spec/fixtures/collection_cluster.json"))) } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with("collections/test_collection/cluster") .and_return(response) end @@ -244,10 +254,10 @@ end describe "#update_cluster" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/cluster") .and_return(response) end @@ -267,10 +277,10 @@ end describe "#list_snapshots" do - let(:response) { OpenStruct.new(body: JSON.parse(File.read("spec/fixtures/snapshots.json"))) } + let(:response) { Qdrant::Client::Response.new(nil, nil, JSON.parse(File.read("spec/fixtures/snapshots.json"))) } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with("collections/test_collection/snapshots") .and_return(response) end @@ -286,10 +296,10 @@ let(:snapshot_fixture) { JSON.parse(File.read("spec/fixtures/snapshot.json")) } describe "#create_snapshot" do - let(:response) { OpenStruct.new(body: snapshot_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, snapshot_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/snapshots") .and_return(response) end @@ -304,10 +314,10 @@ end describe "#delete_snapshot" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:delete) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:delete) .with("collections/test_collection/snapshots/test_collection-6106351684939824381-2023-04-06-20-43-03.snapshot") .and_return(response) end @@ -323,31 +333,31 @@ end describe "#download_snapshot" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + it "writes the downloaded snapshot bytes to the file" do + snapshot_bytes = "snapshot-bytes-123" + io = StringIO.new - before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with("collections/test_collection/snapshots/test_collection-6106351684939824381-2023-04-06-20-43-03.snapshot") - .and_return(response) - - allow(File).to receive(:open).with("/dir/snapshot.txt", "wb+").and_return(999) - end + .and_return(Qdrant::Client::Response.new(nil, nil, snapshot_bytes)) + allow(File).to receive(:open).with("/dir/snapshot.txt", "wb+").and_yield(io) - it "returns the schema" do - response = collections.download_snapshot( + bytes = collections.download_snapshot( collection_name: "test_collection", snapshot_name: "test_collection-6106351684939824381-2023-04-06-20-43-03.snapshot", filepath: "/dir/snapshot.txt" ) - expect(response).to eq(999) + + expect(bytes).to eq(snapshot_bytes.bytesize) + expect(io.string).to eq(snapshot_bytes) end end describe "#restore_snapshot" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/snapshots/recover") .and_return(response) end diff --git a/spec/qdrant/connection_spec.rb b/spec/qdrant/connection_spec.rb new file mode 100644 index 0000000..8226334 --- /dev/null +++ b/spec/qdrant/connection_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "spec_helper" +require "socket" + +RSpec.describe Qdrant::Client::Connection do + # Spins up a one-shot stdlib TCP server that captures each raw request and + # answers with the given scripted responses. Returns the port and a proc that + # yields the list of captured requests. + def with_server(responses, &block) + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + requests = [] + + thread = Thread.new do + responses.each do |response| + client = server.accept + requests << read_http_request(client) + client.write(build_response(response)) + client.close + rescue Errno::ECONNRESET, Errno::EPIPE + break + end + end + + begin + yield port, proc { requests } + ensure + thread.join(5) + server.close + end + end + + def read_http_request(client) + request_line = client.gets + headers = {} + while (line = client.gets) && line != "\r\n" + key, value = line.split(":", 2) + headers[key.strip.downcase] = value.strip + end + body = nil + length = headers["content-length"]&.to_i + body = client.read(length) if length&.positive? + {request_line: request_line, headers: headers, body: body} + end + + def build_response(response) + body = response[:body] + "HTTP/1.1 #{response[:status]} #{response[:reason]}\r\n" \ + "Content-Type: #{response[:content_type]}\r\n" \ + "Content-Length: #{body.bytesize}\r\n" \ + "Connection: close\r\n\r\n" \ + "#{body}" + end + + def connection(port, raise_error: false, api_key: nil, logger: Logger.new(File::NULL)) + Qdrant::Client::Connection.new( + url: "http://127.0.0.1:#{port}", + api_key: api_key, + raise_error: raise_error, + logger: logger + ) + end + + describe "request transport" do + it "POSTs params as a query string, JSON body and api-key header, and parses a JSON response" do + with_server([{status: 200, reason: "OK", content_type: "application/json; charset=utf-8", + body: '{"status":"ok"}'}]) do |port, captured| + conn = connection(port, api_key: "secret") + + response = conn.post("collections/points") do |req| + req.params["wait"] = false + req.body = {vector: [1]} + end + + request = captured.call.first + expect(request[:request_line]).to start_with("POST /collections/points?wait=false ") + expect(request[:headers]["api-key"]).to eq("secret") + expect(request[:headers]["content-type"]).to include("application/json") + expect(request[:headers]["accept"]).to include("application/json") + expect(JSON.parse(request[:body])).to eq("vector" => [1]) + + expect(response.status).to eq(200) + expect(response.body).to eq("status" => "ok") + end + end + + it "returns plain text verbatim and leaves binary snapshot bytes untouched" do + metrics = "# TYPE qdrant_points_total counter\nqdrant_points_total 42" + with_server([{status: 200, reason: "OK", content_type: "text/plain", body: metrics}]) do |port, captured| + expect(connection(port).get("metrics").body).to eq(metrics) + expect(captured.call.first[:request_line]).to start_with("GET /metrics ") + end + + bytes = "\x00\x01\x02\xFF".b + with_server([{status: 200, reason: "OK", content_type: "application/octet-stream", + body: bytes}]) do |port, _captured| + expect(connection(port).get("snapshots/backup").body.b).to eq(bytes) + end + end + + it "preserves param ordering and false values, and merges an existing query string" do + with_server([{status: 200, reason: "OK", content_type: "application/json", body: "{}"}]) do |port, captured| + connection(port).put("collections/index") do |req| + req.params["ordering"] = "weak" + req.params["wait"] = false + end + expect(captured.call.first[:request_line]).to start_with("PUT /collections/index?ordering=weak&wait=false ") + end + + with_server([{status: 200, reason: "OK", content_type: "application/json", body: "{}"}]) do |port, captured| + connection(port).get("collections/points?existing=1") do |req| + req.params["wait"] = false + end + expect(captured.call.first[:request_line]).to start_with("GET /collections/points?existing=1&wait=false ") + end + end + + it "returns status 400 and parsed body with raise_error false, and raises with raise_error true" do + error_body = '{"status":{"error":"bad request"}}' + responses = [ + {status: 400, reason: "Bad Request", content_type: "application/json", body: error_body}, + {status: 400, reason: "Bad Request", content_type: "application/json", body: error_body} + ] + + with_server(responses) do |port, _captured| + quiet = connection(port, raise_error: false) + response = quiet.get("collections") + expect(response.status).to eq(400) + expect(response.body).to eq("status" => {"error" => "bad request"}) + + loud = connection(port, raise_error: true) + expect { loud.get("collections") }.to raise_error(Net::HTTPClientException) + end + end + end + + describe "client configuration surface" do + it "stores a custom adapter, memoizes the connection, and accepts a custom logger" do + logger = Logger.new(File::NULL) + client = Qdrant::Client.new( + url: "http://127.0.0.1:1", + adapter: :custom, + logger: logger + ) + + expect(client.adapter).to eq(:custom) + expect(client.logger).to eq(logger) + expect(client.connection).to be_a(Qdrant::Client::Connection) + expect(client.connection).to be(client.connection) + end + end +end diff --git a/spec/qdrant/points_spec.rb b/spec/qdrant/points_spec.rb index bf6e2a2..1f77221 100644 --- a/spec/qdrant/points_spec.rb +++ b/spec/qdrant/points_spec.rb @@ -16,11 +16,11 @@ describe "#upsert" do let(:response) { - OpenStruct.new(body: status_response_fixture) + Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:put) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:put) .with("collections/test_collection/points") .and_return(response) end @@ -39,11 +39,11 @@ describe "#get_all" do let(:response) { - OpenStruct.new(body: points_fixture) + Qdrant::Client::Response.new(nil, nil, points_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points") .and_return(response) end @@ -59,11 +59,11 @@ describe "#delete" do let(:response) { - OpenStruct.new(body: status_response_fixture) + Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/delete") .and_return(response) end @@ -80,11 +80,11 @@ describe "#search" do let(:response) { - OpenStruct.new(body: points_fixture) + Qdrant::Client::Response.new(nil, nil, points_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/search") .and_return(response) end @@ -103,11 +103,11 @@ describe "#count" do let(:response) { - OpenStruct.new(body: count_response_fixture) + Qdrant::Client::Response.new(nil, nil, count_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/count") .and_return(response) end @@ -122,11 +122,11 @@ describe "#batch_search" do let(:response) { - OpenStruct.new(body: points_fixture) + Qdrant::Client::Response.new(nil, nil, points_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/search/batch") .and_return(response) end @@ -145,11 +145,11 @@ describe "#recommend" do let(:response) { - OpenStruct.new(body: points_fixture) + Qdrant::Client::Response.new(nil, nil, points_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/recommend") .and_return(response) end @@ -166,11 +166,11 @@ describe "#batch_recommend" do let(:response) { - OpenStruct.new(body: points_fixture) + Qdrant::Client::Response.new(nil, nil, points_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/recommend/batch") .and_return(response) end @@ -189,11 +189,11 @@ describe "#scroll" do let(:response) { - OpenStruct.new(body: points_fixture) + Qdrant::Client::Response.new(nil, nil, points_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/scroll") .and_return(response) end @@ -209,11 +209,11 @@ describe "#list" do let(:response) { - OpenStruct.new(body: points_fixture) + Qdrant::Client::Response.new(nil, nil, points_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points") .and_return(response) end @@ -229,11 +229,11 @@ describe "#set_payload" do let(:response) { - OpenStruct.new(body: status_response_fixture) + Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/payload") .and_return(response) end @@ -252,11 +252,11 @@ describe "#clear_payload" do let(:response) { - OpenStruct.new(body: status_response_fixture) + Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/payload/clear") .and_return(response) end @@ -272,11 +272,11 @@ describe "#clear_payload_keys" do let(:response) { - OpenStruct.new(body: status_response_fixture) + Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/payload/delete") .and_return(response) end @@ -293,11 +293,11 @@ describe "#query" do let(:response) { - OpenStruct.new(body: points_fixture) + Qdrant::Client::Response.new(nil, nil, points_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("collections/test_collection/points/query") .and_return(response) end diff --git a/spec/qdrant/service_spec.rb b/spec/qdrant/service_spec.rb index 749e898..d47c386 100644 --- a/spec/qdrant/service_spec.rb +++ b/spec/qdrant/service_spec.rb @@ -12,7 +12,7 @@ describe "#telemetry" do let(:response) { - OpenStruct.new(body: { + Qdrant::Client::Response.new(nil, nil, { result: { id: "11111", app: { @@ -24,7 +24,7 @@ } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with("telemetry") .and_return(response) end @@ -35,10 +35,10 @@ end describe "#metrics" do - let(:response) { OpenStruct.new(body: "metrics") } + let(:response) { Qdrant::Client::Response.new(nil, nil, "metrics") } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with("metrics") .and_return(response) end @@ -51,10 +51,10 @@ let(:locks_fixture) { JSON.parse(File.read("spec/fixtures/locks.json")) } describe "#set_lock" do - let(:response) { OpenStruct.new(body: locks_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, locks_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with("locks") .and_return(response) end @@ -70,10 +70,10 @@ end describe "#locks" do - let(:response) { OpenStruct.new(body: locks_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, locks_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with("locks") .and_return(response) end diff --git a/spec/qdrant/snapshots_spec.rb b/spec/qdrant/snapshots_spec.rb index 197a7a1..3643c54 100644 --- a/spec/qdrant/snapshots_spec.rb +++ b/spec/qdrant/snapshots_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require "stringio" RSpec.describe Qdrant::Snapshots do let(:client) { @@ -16,10 +17,10 @@ let(:status_response_fixture) { JSON.parse(File.read("spec/fixtures/status_response.json")) } describe "#create" do - let(:response) { OpenStruct.new(body: snapshot_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, snapshot_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:post) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:post) .with(Qdrant::Snapshots::PATH) .and_return(response) end @@ -32,10 +33,10 @@ end describe "#list" do - let(:response) { OpenStruct.new(body: snapshots_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, snapshots_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:get) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) .with(Qdrant::Snapshots::PATH) .and_return(response) end @@ -48,10 +49,10 @@ end describe "#delete" do - let(:response) { OpenStruct.new(body: status_response_fixture) } + let(:response) { Qdrant::Client::Response.new(nil, nil, status_response_fixture) } before do - allow_any_instance_of(Faraday::Connection).to receive(:delete) + allow_any_instance_of(Qdrant::Client::Connection).to receive(:delete) .with("snapshots/my-snapshot") .and_return(response) end @@ -66,20 +67,22 @@ end describe "#download" do - before do - allow_any_instance_of(Faraday::Connection).to receive(:get) - .with("snapshots/my-snapshot") - .and_return("01010101001") + it "writes the downloaded snapshot bytes to the file" do + snapshot_bytes = "01010101001" + io = StringIO.new - allow(File).to receive(:open).with("/dir/snapshot.txt", "wb+").and_return(999) - end + allow_any_instance_of(Qdrant::Client::Connection).to receive(:get) + .with("snapshots/my-snapshot") + .and_return(Qdrant::Client::Response.new(nil, nil, snapshot_bytes)) + allow(File).to receive(:open).with("/dir/snapshot.txt", "wb+").and_yield(io) - it "returns the restore status" do - response = snapshots.download( + bytes = snapshots.download( snapshot_name: "my-snapshot", filepath: "/dir/snapshot.txt" ) - expect(response).to eq(999) # Random number of bytes written + + expect(bytes).to eq(snapshot_bytes.bytesize) + expect(io.string).to eq(snapshot_bytes) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7c5db54..7be95ce 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true require "json" -require "ostruct" require "qdrant" -require "faraday" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure