Skip to content

Commit

Permalink
Merge pull request #8 from doximity/MOFONETPROFILES-135-add-routing-h…
Browse files Browse the repository at this point in the history
…eader-for-query-vs-write

Mofonetprofiles 135 add routing header for query vs write
  • Loading branch information
bsimpson authored Jul 19, 2022
2 parents c77eda5 + 8f450eb commit 2df0560
Show file tree
Hide file tree
Showing 23 changed files with 98 additions and 26 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
16 changes: 8 additions & 8 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -35,20 +36,20 @@ 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)
faraday-rack (1.0.0)
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)
Expand Down Expand Up @@ -100,7 +101,6 @@ PLATFORMS

DEPENDENCIES
neo4j-http!
pry
rake (~> 12.0)
rspec (~> 3.0)
standard
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
```

Expand Down
2 changes: 2 additions & 0 deletions lib/neo4j/http/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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/
Expand Down
32 changes: 24 additions & 8 deletions lib/neo4j/http/cypher_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,47 @@ 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

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?
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/neo4j/http/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions lib/neo4j/http/node_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion lib/neo4j/http/relationship_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion lib/neo4j/http/results.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion lib/neo4j/http/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module Neo4j
module Http
VERSION = "1.0.1"
VERSION = "1.0.2"
end
end
3 changes: 1 addition & 2 deletions neo4j-http.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 36 additions & 2 deletions spec/neo4j/http/cypher_client_spec.rb
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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

Expand Down Expand Up @@ -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
7 changes: 6 additions & 1 deletion spec/neo4j/http/relationship_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Binary file added vendor/cache/activesupport-7.0.3.1.gem
Binary file not shown.
Binary file removed vendor/cache/activesupport-7.0.3.gem
Binary file not shown.
Binary file removed vendor/cache/faraday-multipart-1.0.3.gem
Binary file not shown.
Binary file added vendor/cache/faraday-multipart-1.0.4.gem
Binary file not shown.
Binary file removed vendor/cache/i18n-1.10.0.gem
Binary file not shown.
Binary file added vendor/cache/i18n-1.12.0.gem
Binary file not shown.
Binary file removed vendor/cache/minitest-5.15.0.gem
Binary file not shown.
Binary file added vendor/cache/minitest-5.16.2.gem
Binary file not shown.
Binary file removed vendor/cache/multipart-post-2.1.1.gem
Binary file not shown.
Binary file added vendor/cache/multipart-post-2.2.3.gem
Binary file not shown.

0 comments on commit 2df0560

Please sign in to comment.