diff --git a/lib/typesense/api_call.rb b/lib/typesense/api_call.rb index e41fff5..408409b 100644 --- a/lib/typesense/api_call.rb +++ b/lib/typesense/api_call.rb @@ -78,11 +78,14 @@ def perform_request(method, endpoint, query_parameters: nil, body_parameters: ni @logger.debug "Attempting #{method.to_s.upcase} request Try ##{num_tries} to Node #{node[:index]}" begin - conn, request_path = connection_and_path_for(endpoint, node) + conn, request_url = connection_and_url_for(endpoint, node) headers = default_headers.merge(additional_headers) - response = conn.send(method, request_path) do |req| + # The URL is set on the request instead of being passed to the verb. Faraday + # looks up proxy settings again for every absolute URL passed to the verb. + response = conn.send(method) do |req| + req.url(request_url) unless request_url.nil? req.headers = headers req.params = query_parameters unless query_parameters.nil? unless body_parameters.nil? @@ -130,17 +133,21 @@ def perform_request(method, endpoint, query_parameters: nil, body_parameters: ni private + # Faraday 2.9.2 and later only treat a request URL as absolute when it starts + # with a lower-case "http://" or "https://". def uri_for(endpoint, node) - "#{node[:protocol]}://#{node[:host]}:#{node[:port]}#{endpoint}" + "#{node[:protocol].to_s.downcase}://#{node[:host]}:#{node[:port]}#{endpoint}" end - # Returns [connection, request_path]. When keep-alive is enabled, the connection - # is cached per (thread, node) and the path is appended at request time. When it + # Returns [connection, request_url]. When keep-alive is enabled, the connection + # is cached per (thread, node) and each request gets the full URL. Faraday merges + # a relative path into the base URL, and that merge drops "." and ".." segments. + # A delete of document ".." would then delete its whole collection. When keep-alive # is disabled, the original behaviour is preserved: a fresh Faraday is built for # the full per-request URL, so existing callers and stubs see no change. - def connection_and_path_for(endpoint, node) + def connection_and_url_for(endpoint, node) if @keep_alive_connections - [connection_for(node), endpoint] + [connection_for(node), uri_for(endpoint, node)] else [build_one_shot_connection(endpoint, node), nil] end diff --git a/spec/typesense/api_call_spec.rb b/spec/typesense/api_call_spec.rb index 959d901..b7f311d 100644 --- a/spec/typesense/api_call_spec.rb +++ b/spec/typesense/api_call_spec.rb @@ -376,6 +376,88 @@ end end + describe 'request paths with stubbed nodes' do + let(:endpoints) do + [ + '/collections/c/documents/..', + '/collections/c/documents/.', + '/collections/../documents/x', + '/collections/c/documents/a+b', + '/collections/c/documents/a%20b', + '/collections/c/documents/a%2Fb' + ] + end + let(:expected_targets) { endpoints.map { |endpoint| "#{endpoint}?q=a+b" } } + let(:request_targets) { [] } + + before do + stub_request(:any, /node0:8108/) + .to_return(status: 200, body: JSON.dump('ok' => true), headers: { 'Content-Type' => 'application/json' }) + + # WebMock normalizes the URI it records and drops dot segments. Read the + # request target that Faraday hands to Net::HTTP instead (the 4th argument). + allow(Net::HTTPGenericRequest).to receive(:new).and_wrap_original do |original, *args| + request_targets << args[3] + original.call(*args) + end + end + + def request_targets_for(keep_alive:, protocol: 'http') + client = Typesense::Client.new( + api_key: 'abcd', + nodes: [{ host: 'node0', port: 8108, protocol: protocol }], + log_level: Logger::ERROR, + keep_alive_connections: keep_alive + ) + api_call = described_class.new(client.configuration) + endpoints.each { |endpoint| api_call.delete(endpoint, 'q' => 'a b') } + request_targets + end + + it 'sends the endpoint and query unchanged with one-shot connections' do + expect(request_targets_for(keep_alive: false)).to eq(expected_targets) + end + + it 'sends the endpoint and query unchanged with keep-alive connections' do + expect(request_targets_for(keep_alive: true)).to eq(expected_targets) + end + + it 'sends the endpoint and query unchanged with keep-alive connections and an upper-case protocol' do + expect(request_targets_for(keep_alive: true, protocol: 'HTTP')).to eq(expected_targets) + end + end + + describe 'keep-alive requests on a real Typesense server' do + let(:client) do + Typesense::Client.new( + api_key: 'xyz', + nodes: [{ host: 'localhost', port: 8108, protocol: 'http' }], + log_level: Logger::ERROR, + keep_alive_connections: true + ) + end + let(:collection) { client.collections['api_call_dot_segments'] } + + def delete_collection + collection.delete + rescue Typesense::Error::ObjectNotFound + # Already gone + end + + before do + delete_collection + client.collections.create('name' => 'api_call_dot_segments', 'fields' => [{ 'name' => 'title', 'type' => 'string' }]) + %w[x ..].each { |id| collection.documents.create('id' => id, 'title' => id) } + end + + after { delete_collection } + + it 'deletes only the document whose id is ".."' do + expect(collection.documents['..'].delete['id']).to eq('..') + expect(collection.documents['x'].retrieve['id']).to eq('x') + end + end + describe 'keep-alive disabled (default)' do it 'is off by default on the configuration' do expect(typesense.configuration.keep_alive_connections).to be(false)