diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a6be0..5245f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ Changelog ========= +## v1.0.2 07/19/2022 +* Adds an access_mode setting to cypher queries + ## v1.0.1 05/24/2022 * Expand documentation configuration settings diff --git a/Gemfile.lock b/Gemfile.lock index a8500b8..87d2d90 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,16 +1,17 @@ PATH remote: . specs: - neo4j-http (1.0.1) + neo4j-http (1.0.2) activesupport (>= 5.2) faraday (< 2) faraday-retry faraday_middleware + pry GEM remote: https://rubygems.org/ specs: - activesupport (7.0.3) + activesupport (7.0.3.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -35,8 +36,8 @@ GEM faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.3) - multipart-post (>= 1.2, < 3) + faraday-multipart (1.0.4) + multipart-post (~> 2) faraday-net_http (1.0.1) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) @@ -44,11 +45,11 @@ GEM faraday-retry (1.0.3) faraday_middleware (1.2.0) faraday (~> 1.0) - i18n (1.10.0) + i18n (1.12.0) concurrent-ruby (~> 1.0) method_source (1.0.0) - minitest (5.15.0) - multipart-post (2.1.1) + minitest (5.16.2) + multipart-post (2.2.3) parallel (1.22.1) parser (3.1.1.0) ast (~> 2.4.1) @@ -100,7 +101,6 @@ PLATFORMS DEPENDENCIES neo4j-http! - pry rake (~> 12.0) rspec (~> 3.0) standard diff --git a/README.md b/README.md index db48843..6ffb6a7 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The client is configured by default via a set of environment variables from [Neo * `NEO4J_DATABASE` - The database name to be used when connecting. By default this will be `nil`. * `NEO4J_HTTP_USER_AGENT` - The user agent name provided in the request - defaults to `"Ruby Neo4j Http Client"` * `NEO4J_REQUEST_TIMEOUT_IN_SECONDS` - The number of seconds for the http request to time out if provided - defaults to `nil` +* `ACCESS_MODE` - "WRITE", or "READ" for read only instances of Neo4j clients - defaults to `"WRITE"` These configuration values can also be set during initalization, and take precedence over the environment variables: @@ -44,6 +45,7 @@ Neo4j::Http.configure do |config| config.database_name = nil config.user_agent = "Ruby Neo4j Http Client" config.request_timeout_in_seconds = nil + config.access_mode = "WRITE" end ``` diff --git a/lib/neo4j/http/configuration.rb b/lib/neo4j/http/configuration.rb index d1a71c5..f4f946b 100644 --- a/lib/neo4j/http/configuration.rb +++ b/lib/neo4j/http/configuration.rb @@ -9,6 +9,7 @@ class Configuration attr_accessor :uri attr_accessor :user attr_accessor :user_agent + attr_accessor :access_mode def initialize(options = ENV) @uri = options.fetch("NEO4J_URL", "http://localhost:7474") @@ -17,6 +18,7 @@ def initialize(options = ENV) @database_name = options.fetch("NEO4J_DATABASE", nil) @user_agent = options.fetch("NEO4J_HTTP_USER_AGENT", "Ruby Neo4j Http Client") @request_timeout_in_seconds = options.fetch("NEO4J_REQUEST_TIMEOUT_IN_SECONDS", nil) + @access_mode = options.fetch("NEO4J_ACCESS_MODE", "WRITE") end # https://neo4j.com/developer/manage-multiple-databases/ diff --git a/lib/neo4j/http/cypher_client.rb b/lib/neo4j/http/cypher_client.rb index ac37f69..cad3935 100644 --- a/lib/neo4j/http/cypher_client.rb +++ b/lib/neo4j/http/cypher_client.rb @@ -12,26 +12,36 @@ def self.default @default ||= new(Neo4j::Http.config) end - def initialize(configuration) + def initialize(configuration, injected_connection = nil) @configuration = configuration + @injected_connection = injected_connection end + # Executes a cypher query, passing in the cypher statement, with parameters as an optional hash + # e.g. Neo4j::Http::Cypherclient.execute_cypher("MATCH (n { foo: $foo }) LIMIT 1 RETURN n", { foo: "bar" }) def execute_cypher(cypher, parameters = {}) + # By default the access mode is set to "WRITE", but can be set to "READ" + # for improved routing performance on read only queries + access_mode = parameters.delete(:access_mode) || @configuration.access_mode + request_body = { statements: [ - {statement: cypher, - parameters: parameters.as_json} + { + statement: cypher, + parameters: parameters.as_json + } ] } - response = connection.post(transaction_path, request_body) + @connection = @injected_connection || connection(access_mode) + response = @connection.post(transaction_path, request_body) results = check_errors!(cypher, response, parameters) Neo4j::Http::Results.parse(results&.first || {}) end - def connection - build_connection + def connection(access_mode) + build_connection(access_mode) end protected @@ -39,6 +49,10 @@ def connection delegate :auth_token, :transaction_path, to: :@configuration def check_errors!(cypher, response, parameters) raise Neo4j::Http::Errors::InvalidConnectionUrl, response.status if response.status == 404 + if response.body["errors"].any? { |error| error["message"][/Routing WRITE queries is not supported/] } + raise Neo4j::Http::Errors::ReadOnlyError + end + body = response.body || {} errors = body.fetch("errors", []) return body.fetch("results", {}) unless errors.present? @@ -61,8 +75,10 @@ def find_error_class(code) Neo4j::Http::Errors::Neo4jCodedError end - def build_connection - Faraday.new(url: @configuration.uri, headers: build_http_headers, request: build_request_options) do |f| + def build_connection(access_mode) + # https://neo4j.com/docs/http-api/current/actions/transaction-configuration/ + headers = build_http_headers.merge({"access-mode" => access_mode}) + Faraday.new(url: @configuration.uri, headers: headers, request: build_request_options) do |f| f.request :json # encode req bodies as JSON f.request :retry # retry transient failures f.response :json # decode response bodies as JSON diff --git a/lib/neo4j/http/errors.rb b/lib/neo4j/http/errors.rb index 1ce4d77..f96ce97 100644 --- a/lib/neo4j/http/errors.rb +++ b/lib/neo4j/http/errors.rb @@ -4,6 +4,7 @@ module Errors Neo4jError = Class.new(StandardError) InvalidConnectionUrl = Class.new(Neo4jError) Neo4jCodedError = Class.new(Neo4jError) + ReadOnlyError = Class.new(Neo4jError) # These are specific Errors Neo4j can raise module Neo diff --git a/lib/neo4j/http/node_client.rb b/lib/neo4j/http/node_client.rb index e0d75b4..33003bd 100644 --- a/lib/neo4j/http/node_client.rb +++ b/lib/neo4j/http/node_client.rb @@ -41,15 +41,16 @@ def delete_node(node) def find_node_by(label:, **attributes) selectors = attributes.map { |key, value| "#{key}: $attributes.#{key}" }.join(", ") cypher = "MATCH (node:#{label} { #{selectors} }) RETURN node LIMIT 1" - results = @cypher_client.execute_cypher(cypher, attributes: attributes) + results = @cypher_client.execute_cypher(cypher, attributes: attributes.merge(access_mode: "READ")) return if results.empty? + results.first&.fetch("node") end def find_nodes_by(label:, attributes:, limit: 100) selectors = build_selectors(attributes) cypher = "MATCH (node:#{label}) where #{selectors} RETURN node LIMIT #{limit}" - results = @cypher_client.execute_cypher(cypher, attributes: attributes) + results = @cypher_client.execute_cypher(cypher, attributes: attributes.merge(access_mode: "READ")) results.map { |result| result["node"] } end diff --git a/lib/neo4j/http/relationship_client.rb b/lib/neo4j/http/relationship_client.rb index 9d52bb4..1b52531 100644 --- a/lib/neo4j/http/relationship_client.rb +++ b/lib/neo4j/http/relationship_client.rb @@ -52,7 +52,13 @@ def find_relationship(from:, relationship:, to:) RETURN from, to, relationship CYPHER - results = @cypher_client.execute_cypher(cypher, from: from, to: to, relationship: relationship) + results = @cypher_client.execute_cypher( + cypher, + from: from, + to: to, + relationship: relationship, + access_mode: "READ" + ) results&.first end diff --git a/lib/neo4j/http/results.rb b/lib/neo4j/http/results.rb index ef72777..5f152bd 100644 --- a/lib/neo4j/http/results.rb +++ b/lib/neo4j/http/results.rb @@ -4,7 +4,10 @@ module Neo4j module Http class Results # Example result set: - # [{"columns"=>["n"], "data"=>[{"row"=>[{"name"=>"Foo", "uuid"=>"8c7dcfda-d848-4937-a91a-2e6debad2dd6"}], "meta"=>[{"id"=>242, "type"=>"node", "deleted"=>false}]}]}] + # [{"columns"=>["n"], + # "data"=> + # [{"row"=>[{"name"=>"Foo", "uuid"=>"8c7dcfda-d848-4937-a91a-2e6debad2dd6"}], + # "meta"=>[{"id"=>242, "type"=>"node", "deleted"=>false}]}]}] # def self.parse(results) columns = results["columns"] diff --git a/lib/neo4j/http/version.rb b/lib/neo4j/http/version.rb index 68bbc03..9aa62ae 100644 --- a/lib/neo4j/http/version.rb +++ b/lib/neo4j/http/version.rb @@ -1,5 +1,5 @@ module Neo4j module Http - VERSION = "1.0.1" + VERSION = "1.0.2" end end diff --git a/neo4j-http.gemspec b/neo4j-http.gemspec index 34983d3..0a3628f 100644 --- a/neo4j-http.gemspec +++ b/neo4j-http.gemspec @@ -26,6 +26,5 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "faraday", "< 2" spec.add_runtime_dependency "faraday_middleware" spec.add_runtime_dependency "faraday-retry" - - spec.add_development_dependency "pry" + spec.add_runtime_dependency "pry" end diff --git a/spec/neo4j/http/cypher_client_spec.rb b/spec/neo4j/http/cypher_client_spec.rb index ac22bc6..ebc1147 100644 --- a/spec/neo4j/http/cypher_client_spec.rb +++ b/spec/neo4j/http/cypher_client_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require "faraday" RSpec.describe Neo4j::Http::CypherClient, type: :uses_neo4j do subject(:client) { described_class.default } @@ -10,11 +11,11 @@ config = Neo4j::Http::Configuration.new config.request_timeout_in_seconds = 42 client = described_class.new(config) - expect(client.connection.options.timeout).to eq(42) + expect(client.connection("READ").options.timeout).to eq(42) end it "defaults to having no request timeout" do - expect(client.connection.options.timeout).to be_nil + expect(client.connection("READ").options.timeout).to be_nil end end @@ -49,5 +50,38 @@ results = client.execute_cypher("MATCH (node:Test {uuid: 'Uuid1'}) return node") expect(results.length).to eq(0) end + + describe "with injected connection" do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:injected_connection) do + Faraday.new do |f| + f.adapter(:test, stubs) + f.response :json + end + end + let(:client) { described_class.new(Neo4j::Http.config, injected_connection) } + + it "raises a ReadOnlyError when access control is set to read" do + stubs.post("/db/data/transaction/commit") do + [ + 200, + {"Content-Type": "application/json"}, + '{ + "results": [], + "errors": [ + { + "code": "Neo.ClientError.Request.Invalid", + "message": "Routing WRITE queries is not supported in clusters where Server-Side Routing is disabled." + } + ] + }' + ] + end + + expect { client.execute_cypher("CREATE (n) RETURN n", access_mode: "READ") } + .to raise_error(Neo4j::Http::Errors::ReadOnlyError) + stubs.verify_stubbed_calls + end + end end end diff --git a/spec/neo4j/http/relationship_client_spec.rb b/spec/neo4j/http/relationship_client_spec.rb index dbc2a64..07bea74 100644 --- a/spec/neo4j/http/relationship_client_spec.rb +++ b/spec/neo4j/http/relationship_client_spec.rb @@ -100,7 +100,12 @@ end def verify_relationship(from, relationship, to) - results = Neo4j::Http::CypherClient.default.execute_cypher("MATCH (from:Bot {uuid: $from})-[relationship:#{relationship}]-(to:Bot {uuid: $to}) RETURN from, to, relationship", from: from.key_value, to: to.key_value) + results = Neo4j::Http::CypherClient.default.execute_cypher( + "MATCH (from:Bot {uuid: $from})-[relationship:#{relationship}]-(to:Bot {uuid: $to}) + RETURN from, to, relationship", + from: from.key_value, + to: to.key_value + ) result = results.first expect(result.keys).to eq(%w[from to relationship]) diff --git a/vendor/cache/activesupport-7.0.3.1.gem b/vendor/cache/activesupport-7.0.3.1.gem new file mode 100644 index 0000000..0c3757a Binary files /dev/null and b/vendor/cache/activesupport-7.0.3.1.gem differ diff --git a/vendor/cache/activesupport-7.0.3.gem b/vendor/cache/activesupport-7.0.3.gem deleted file mode 100644 index 03e576b..0000000 Binary files a/vendor/cache/activesupport-7.0.3.gem and /dev/null differ diff --git a/vendor/cache/faraday-multipart-1.0.3.gem b/vendor/cache/faraday-multipart-1.0.3.gem deleted file mode 100644 index a3e5866..0000000 Binary files a/vendor/cache/faraday-multipart-1.0.3.gem and /dev/null differ diff --git a/vendor/cache/faraday-multipart-1.0.4.gem b/vendor/cache/faraday-multipart-1.0.4.gem new file mode 100644 index 0000000..27647d2 Binary files /dev/null and b/vendor/cache/faraday-multipart-1.0.4.gem differ diff --git a/vendor/cache/i18n-1.10.0.gem b/vendor/cache/i18n-1.10.0.gem deleted file mode 100644 index a64602b..0000000 Binary files a/vendor/cache/i18n-1.10.0.gem and /dev/null differ diff --git a/vendor/cache/i18n-1.12.0.gem b/vendor/cache/i18n-1.12.0.gem new file mode 100644 index 0000000..c64c068 Binary files /dev/null and b/vendor/cache/i18n-1.12.0.gem differ diff --git a/vendor/cache/minitest-5.15.0.gem b/vendor/cache/minitest-5.15.0.gem deleted file mode 100644 index bb3c247..0000000 Binary files a/vendor/cache/minitest-5.15.0.gem and /dev/null differ diff --git a/vendor/cache/minitest-5.16.2.gem b/vendor/cache/minitest-5.16.2.gem new file mode 100644 index 0000000..78b6273 Binary files /dev/null and b/vendor/cache/minitest-5.16.2.gem differ diff --git a/vendor/cache/multipart-post-2.1.1.gem b/vendor/cache/multipart-post-2.1.1.gem deleted file mode 100644 index 027956d..0000000 Binary files a/vendor/cache/multipart-post-2.1.1.gem and /dev/null differ diff --git a/vendor/cache/multipart-post-2.2.3.gem b/vendor/cache/multipart-post-2.2.3.gem new file mode 100644 index 0000000..f1d8da1 Binary files /dev/null and b/vendor/cache/multipart-post-2.2.3.gem differ