Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Initial commit

  • Loading branch information...
commit 075751c95d9af911b5567ebaa019d6762b8a71f7 0 parents
@therubymug authored
Showing with 1,540 additions and 0 deletions.
  1. +26 −0 .gitignore
  2. +4 −0 Gemfile
  3. +22 −0 LICENSE.md
  4. +86 −0 README.md
  5. +123 −0 Rakefile
  6. +94 −0 keymaker.gemspec
  7. +182 −0 keymaker_integration_spec.rb
  8. +43 −0 lib/keymaker.rb
  9. +9 −0 lib/keymaker/add_node_to_index_request.rb
  10. +19 −0 lib/keymaker/batch_get_nodes_request.rb
  11. +90 −0 lib/keymaker/configuration.rb
  12. +11 −0 lib/keymaker/create_node_request.rb
  13. +26 −0 lib/keymaker/create_relationship_request.rb
  14. +17 −0 lib/keymaker/delete_relationship_request.rb
  15. +9 −0 lib/keymaker/execute_cypher_request.rb
  16. +9 −0 lib/keymaker/execute_gremlin_request.rb
  17. +34 −0 lib/keymaker/indexing.rb
  18. +111 −0 lib/keymaker/node.rb
  19. +34 −0 lib/keymaker/path_traverse_request.rb
  20. +9 −0 lib/keymaker/remove_node_from_index_request.rb
  21. +34 −0 lib/keymaker/request.rb
  22. +38 −0 lib/keymaker/response.rb
  23. +46 −0 lib/keymaker/serialization.rb
  24. +147 −0 lib/keymaker/service.rb
  25. +15 −0 lib/keymaker/update_node_properties_request.rb
  26. +189 −0 spec/lib/keymaker_integration_spec.rb
  27. +7 −0 spec/spec_helper.rb
  28. +106 −0 spec/support/keymaker.rb
26 .gitignore
@@ -0,0 +1,26 @@
+## PROJECT::GENERAL
+*.rbc
+.config
+.yardoc
+.rspec
+InstalledFiles
+_yardoc
+coverage
+coverage
+doc/
+lib/bundler/man
+pkg
+pkg/*
+rdoc
+rdoc
+spec/reports
+tmp
+
+## BUNDLER
+*.gem
+.bundle
+.rbenv*
+.rvmrc
+Gemfile.lock
+doc
+log
4 Gemfile
@@ -0,0 +1,4 @@
+source 'https://rubygems.org'
+
+# Specify your gem's dependencies in keymaker.gemspec
+gemspec
22 LICENSE.md
@@ -0,0 +1,22 @@
+Copyright (c) 2012 Rogelio J. Samour
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
86 README.md
@@ -0,0 +1,86 @@
+# Keymaker
+
+## NOTICE OF WORK IN PROGRESS
+
+A multi-layer REST API Ruby wrapper for the neo4j graph database.
+
+```
+Oracle: Our time is up. Listen to me, Neo.
+ You can save Zion if you reach The Source,
+ but to do that you will need the Keymaker.
+Neo: The Keymaker?
+```
+
+## Installation
+
+Add this line to your application's Gemfile:
+
+ gem 'keymaker'
+
+And then execute:
+
+ $ bundle
+
+Or install it yourself as:
+
+ $ gem install keymaker
+
+## Usage
+
+### Configuration
+
+```
+Coming soon
+```
+
+### Nodes
+
+```
+Coming soon
+```
+
+### Relationships
+
+```
+Coming soon
+```
+
+### Indices
+
+```
+Coming soon
+```
+
+### Querying
+
+```
+Coming soon
+```
+
+## Contributing
+
+1. Fork it
+2. Create a feature branch (`git checkout -b my_new_feature`)
+3. Write passing tests!
+3. Commit your changes (`git commit -v`)
+4. Push to the branch (`git push origin my_new_feature`)
+5. Create new Pull Request
+
+## TODO:
+
+- Test coverage
+- Helper rake tasks for development
+- Contributing documentation (installing neo4j, etc).
+- Documentation
+
+## Acknowledgements
+
+- Avdi Grimm
+- Micah Cooper
+- Stephen Caudill
+
+## Copyright
+Copyright (c) 2012 [Rogelio J. Samour](mailto:rogelio@therubymug.com)
+See [LICENSE][] for details.
+
+[license]: https://github.com/therubymug/keymaker/blob/master/LICENSE.md
123 Rakefile
@@ -0,0 +1,123 @@
+
+#############################################################################
+#
+# Helper functions
+#
+#############################################################################
+
+def name
+ @name ||= Dir['*.gemspec'].first.split('.').first
+end
+
+def version
+ line = File.read("lib/#{name}.rb")[/^\s*VERSION\s*=\s*.*/]
+ line.match(/.*VERSION\s*=\s*['"](.*)['"]/)[1]
+end
+
+def date
+ Date.today.to_s
+end
+
+def gemspec_file
+ "#{name}.gemspec"
+end
+
+def gem_file
+ "#{name}-#{version}.gem"
+end
+
+def replace_header(head, header_name)
+ head.sub!(/(\.#{header_name}\s*= ').*'/) { "#{$1}#{send(header_name)}'"}
+end
+
+#############################################################################
+#
+# Standard tasks
+#
+#############################################################################
+
+require 'rspec'
+require 'rspec/core/rake_task'
+
+desc "Run all specs"
+task RSpec::Core::RakeTask.new('spec')
+
+task :default => "spec"
+
+desc "Open an irb session preloaded with this library"
+task :console do
+ sh "irb -rubygems -r ./lib/#{name}.rb"
+end
+
+#############################################################################
+#
+# Custom tasks (add your own tasks here)
+#
+#############################################################################
+
+
+#############################################################################
+#
+# Packaging tasks
+#
+#############################################################################
+
+desc "Create tag v#{version} and build and push #{gem_file} to Rubygems"
+task :release => :build do
+ unless `git branch` =~ /^\* master$/
+ puts "You must be on the master branch to release!"
+ exit!
+ end
+ sh "git commit --allow-empty -a -m 'Release #{version}'"
+ sh "git tag v#{version}"
+ sh "git push origin master"
+ sh "git push origin v#{version}"
+ sh "gem push pkg/#{name}-#{version}.gem"
+end
+
+desc "Build #{gem_file} into the pkg directory"
+task :build => :gemspec do
+ sh "mkdir -p pkg"
+ sh "gem build #{gemspec_file}"
+ sh "mv #{gem_file} pkg"
+end
+
+desc "Generate #{gemspec_file}"
+task :gemspec => :validate do
+ # read spec file and split out manifest section
+ spec = File.read(gemspec_file)
+ head, manifest, tail = spec.split(" # = MANIFEST =\n")
+
+ # replace name version and date
+ replace_header(head, :name)
+ replace_header(head, :version)
+ replace_header(head, :date)
+
+ # determine file list from git ls-files
+ files = `git ls-files`.
+ split("\n").
+ sort.
+ reject { |file| file =~ /^\./ }.
+ reject { |file| file =~ /^(rdoc|pkg)/ }.
+ map { |file| " #{file}" }.
+ join("\n")
+
+ # piece file back together and write
+ manifest = " s.files = %w[\n#{files}\n ]\n"
+ spec = [head, manifest, tail].join(" # = MANIFEST =\n")
+ File.open(gemspec_file, 'w') { |io| io.write(spec) }
+ puts "Updated #{gemspec_file}"
+end
+
+desc "Validate #{gemspec_file}"
+task :validate do
+ libfiles = Dir['lib/*'] - ["lib/#{name}.rb", "lib/#{name}"]
+ unless libfiles.empty?
+ puts "Directory `lib` should only contain a `#{name}.rb` file and `#{name}` dir."
+ exit!
+ end
+ unless Dir['VERSION*'].empty?
+ puts "A `VERSION` file at root level violates Gem best practices."
+ exit!
+ end
+end
94 keymaker.gemspec
@@ -0,0 +1,94 @@
+# -*- encoding: utf-8 -*-
+## This is the rakegem gemspec template. Make sure you read and understand
+## all of the comments. Some sections require modification, and others can
+## be deleted if you don't need them. Once you understand the contents of
+## this file, feel free to delete any comments that begin with two hash marks.
+## You can find comprehensive Gem::Specification documentation, at
+## http://docs.rubygems.org/read/chapter/20
+Gem::Specification.new do |s|
+ s.specification_version = 2 if s.respond_to? :specification_version=
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
+ s.rubygems_version = '1.3.5'
+
+ ## Leave these as is they will be modified for you by the rake gemspec task.
+ ## If your rubyforge_project name is different, then edit it and comment out
+ ## the sub! line in the Rakefile
+ s.name = 'keymaker'
+ s.version = '0.0.1'
+ s.date = '2012-06-06'
+
+ ## Make sure your summary is short. The description may be as long
+ ## as you like.
+ s.description = %q{A multi-layer REST API wrapper for neo4j.}
+ s.summary = %q{A multi-layer REST API wrapper for neo4j.}
+
+ ## List the primary authors. If there are a bunch of authors, it's probably
+ ## better to set the email to an email list or something. If you don't have
+ ## a custom homepage, consider using your GitHub URL or the like.
+ s.authors = ["Rogelio J. Samour", "Travis L. Anderson"]
+ s.email = ["rogelio@therubymug.com", "travis@travisleeanderson.com"]
+ s.homepage = "https://github.com/therubymug/keymaker"
+
+ ## This gets added to the $LOAD_PATH so that 'lib/NAME.rb' can be required as
+ ## require 'NAME.rb' or'/lib/NAME/file.rb' can be as require 'NAME/file.rb'
+ s.require_paths = %w[lib]
+
+ ## Specify any RDoc options here. You'll want to add your README and
+ ## LICENSE files to the extra_rdoc_files list.
+ ## s.rdoc_options = ["--charset=UTF-8"]
+ s.extra_rdoc_files = %w[README.md LICENSE.md]
+
+ ## List your runtime dependencies here. Runtime dependencies are those
+ ## that are needed for an end user to actually USE your code.
+ ## s.add_dependency('DEPNAME', [">= 1.1.0", "< 2.0.0"])
+ s.add_dependency 'addressable'
+ s.add_dependency 'faraday'
+ s.add_dependency 'faraday_middleware'
+ s.add_dependency 'activemodel'
+
+ ## List your development dependencies here. Development dependencies are
+ ## those that are only needed during development
+ ## s.add_development_dependency('DEVDEPNAME', [">= 1.1.0", "< 2.0.0"])
+ s.add_development_dependency 'rake'
+ s.add_development_dependency 'rspec'
+ s.add_development_dependency 'ruby-debug19'
+
+ ## Leave this section as-is. It will be automatically generated from the
+ ## contents of your Git repository via the gemspec task. DO NOT REMOVE
+ ## THE MANIFEST COMMENTS, they are used as delimiters by the task.
+ # = MANIFEST =
+ s.files = %w[
+ Gemfile
+ LICENSE.md
+ README.md
+ Rakefile
+ keymaker.gemspec
+ keymaker_integration_spec.rb
+ lib/keymaker.rb
+ lib/keymaker/add_node_to_index_request.rb
+ lib/keymaker/batch_get_nodes_request.rb
+ lib/keymaker/configuration.rb
+ lib/keymaker/create_node_request.rb
+ lib/keymaker/create_relationship_request.rb
+ lib/keymaker/delete_relationship_request.rb
+ lib/keymaker/execute_cypher_request.rb
+ lib/keymaker/execute_gremlin_request.rb
+ lib/keymaker/indexing.rb
+ lib/keymaker/node.rb
+ lib/keymaker/path_traverse_request.rb
+ lib/keymaker/remove_node_from_index_request.rb
+ lib/keymaker/request.rb
+ lib/keymaker/response.rb
+ lib/keymaker/serialization.rb
+ lib/keymaker/service.rb
+ lib/keymaker/update_node_properties_request.rb
+ spec/lib/keymaker_integration_spec.rb
+ spec/spec_helper.rb
+ spec/support/keymaker.rb
+ ]
+ # = MANIFEST =
+
+ ## Test files will be grabbed from the file list. Make sure the path glob
+ ## matches what you actually use.
+ s.test_files = s.files.grep(%r{^spec/})
+end
182 keymaker_integration_spec.rb
@@ -0,0 +1,182 @@
+require 'spec_helper'
+
+describe Keymaker do
+
+ include_context "John and Sarah nodes"
+
+ context "indeces" do
+ include_context "John and Sarah indexed nodes"
+
+ context "given a bad port number" do
+
+ let(:url) { john_node_url.dup.gsub("7475", "49152") }
+
+ after { service.connection = connection }
+
+ def do_it
+ connection.get(url)
+ end
+
+ it "raises an error" do
+ expect { do_it }.to raise_error
+ end
+
+ end
+
+ context "given an explicit connection" do
+
+ let(:url) { john_node_url }
+
+ before { service.connection = test_connection }
+ after { service.connection = connection }
+
+ def do_it
+ service.connection.get(url)
+ end
+
+ it "uses the connection for requests" do
+ faraday_stubs.get(Addressable::URI.parse(url).path) do
+ [200, {}, "{}"]
+ end
+ do_it
+ faraday_stubs.verify_stubbed_calls
+ end
+
+ end
+
+ describe "#add_node_to_index(index_name, key, value, node_id)" do
+
+ def do_it
+ service.add_node_to_index(:users, :email, email, node_id)
+ end
+
+ context "given existing values" do
+
+ let(:email) { john_email }
+ let(:node_id) { john_node_id }
+
+ it "adds the node to the index" do
+ do_it
+ connection.get(index_query_for_john_url).body.should be_present
+ end
+
+ it "returns a status of 201" do
+ do_it.status.should == 201
+ end
+
+ end
+
+ context "given an invalid node id" do
+
+ let(:email) { john_email }
+ let(:node_id) { -22 }
+
+ it "returns a 500 status" do
+ do_it.status.should == 500
+ end
+
+ end
+
+ end
+
+ describe "#remove_node_from_index(index_name, key, value, node_id)" do
+
+ def do_it
+ service.remove_node_from_index(:users, :email, email, node_id)
+ end
+
+ context "given existing values" do
+
+ let(:email) { john_email }
+ let(:node_id) { john_node_id }
+
+ it "removes the node from the index" do
+ do_it
+ connection.get(index_query_for_john_url).body.should be_empty
+ end
+
+ it "returns a status of 204" do
+ do_it.status.should == 204
+ end
+
+ it "keeps the other node indices" do
+ do_it
+ connection.get(index_query_for_sarah_url).body.should_not be_empty
+ end
+
+ end
+
+ context "given unmatched values" do
+
+ let(:email) { "unknown@example.com" }
+ let(:node_id) { -22 }
+
+ it "returns a 404 status" do
+ do_it.status.should == 404
+ end
+
+ end
+
+ end
+ end
+
+ describe "#execute_query" do
+
+ def do_it
+ service.execute_query("START user=node:users(email={email} RETURN user", email: john_email)
+ end
+
+ context "given existing values" do
+
+ before { service.add_node_to_index(:users, :email, john_email, john_node_id) }
+
+ it "performs the cypher query and responds" do
+ do_it.should be_present
+ end
+
+ end
+
+ end
+
+ context "nodes" do
+
+ include_context "Keymaker connections"
+
+ describe "#create_node" do
+
+ let(:properties) { { first_name: "john", last_name: "connor", email: "john@resistance.net" } }
+
+ def do_it
+ new_node_id = service.create_node(properties).neo4j_id
+ connection.get("/db/data/node/#{new_node_id}/properties").body
+ end
+
+ it "creates a node with properties" do
+ do_it.should == {"first_name"=>"john", "email"=>"john@resistance.net", "last_name"=>"connor"}
+ end
+
+ end
+
+ end
+
+ context "relationships" do
+
+ include_context "Keymaker connections"
+
+ describe "#create_relationship" do
+
+ def do_it
+ service.create_relationship(:loves, john_node_id, sarah_node_id).neo4j_id
+ connection.get("/db/data/node/#{john_node_id}/relationships/all/loves").body.first
+ end
+
+ it "creates the relationship between the two nodes" do
+ do_it["start"].should == john_node_url
+ do_it["end"].should == sarah_node_url
+ end
+
+ end
+
+ end
+
+end
43 lib/keymaker.rb
@@ -0,0 +1,43 @@
+require 'faraday'
+require 'faraday_middleware'
+require 'active_model'
+
+require 'keymaker/request'
+require 'keymaker/response'
+require 'keymaker/configuration'
+require 'keymaker/service'
+
+require 'keymaker/add_node_to_index_request'
+require 'keymaker/batch_get_nodes_request'
+require 'keymaker/create_node_request'
+require 'keymaker/create_relationship_request'
+require 'keymaker/delete_relationship_request'
+require 'keymaker/execute_cypher_request'
+require 'keymaker/execute_gremlin_request'
+require 'keymaker/path_traverse_request'
+require 'keymaker/remove_node_from_index_request'
+require 'keymaker/update_node_properties_request'
+
+require 'keymaker/indexing'
+require 'keymaker/serialization'
+require 'keymaker/node'
+
+module Keymaker
+
+ VERSION = "0.0.1"
+
+ def self.service
+ @service ||= Keymaker::Service.new(Keymaker::Configuration.new)
+ end
+
+ def self.configure
+ @configuration = Keymaker::Configuration.new
+ yield @configuration
+ @service = Keymaker::Service.new(@configuration)
+ end
+
+ def self.configuration
+ @configuration
+ end
+
+end
9 lib/keymaker/add_node_to_index_request.rb
@@ -0,0 +1,9 @@
+module Keymaker
+ class AddNodeToIndexRequest < Request
+
+ def submit
+ service.post(node_index_path(opts[:index_name]), {key: opts[:key], value: opts[:value], uri: node_uri(opts[:node_id])})
+ end
+
+ end
+end
19 lib/keymaker/batch_get_nodes_request.rb
@@ -0,0 +1,19 @@
+module Keymaker
+
+ class BatchGetNodesRequest < Request
+
+ def submit
+ service.post(batch_path, batch_get_nodes_properties)
+ end
+
+ def batch_get_nodes_properties
+ [].tap do |batch_request|
+ opts.each_with_index do |node_id, request_id|
+ batch_request << {id: request_id, to: node_uri(node_id), method: "GET"}
+ end
+ end
+ end
+
+ end
+
+end
90 lib/keymaker/configuration.rb
@@ -0,0 +1,90 @@
+require "addressable/uri"
+
+module Keymaker
+ class Configuration
+
+ attr_accessor :protocol, :server, :port,
+ :data_directory, :cypher_path, :gremlin_path,
+ :log_file, :log_enabled, :logger,
+ :authentication, :username, :password
+
+ def initialize(attrs={})
+ self.protocol = attrs.fetch(:protocol) {'http'}
+ self.server = attrs.fetch(:server) {'localhost'}
+ self.port = attrs.fetch(:port) {7474}
+ self.data_directory = attrs.fetch(:data_directory) {'db/data'}
+ self.cypher_path = attrs.fetch(:cypher_path) {'cypher'}
+ self.gremlin_path = attrs.fetch(:gremlin_path) {'ext/GremlinPlugin/graphdb/execute_script'}
+ self.authentication = attrs.fetch(:authentication) {{}}
+ self.username = attrs.fetch(:username) {nil}
+ self.password = attrs.fetch(:password) {nil}
+ end
+
+ def service_root
+ service_root_url.to_s
+ end
+
+ def service_root_url
+ Addressable::URI.new(url_opts)
+ end
+
+ def url_opts
+ {}.tap do |url_opts|
+ url_opts[:scheme] = protocol
+ url_opts[:host] = server
+ url_opts[:port] = port
+ url_opts[:user] = username if username
+ url_opts[:password] = password if password
+ end
+ end
+
+ def full_cypher_path
+ [service_root, data_directory, cypher_path].join("/")
+ end
+
+ def full_gremlin_path
+ [service_root, data_directory, gremlin_path].join("/")
+ end
+
+ def node_path
+ [data_directory, "node"].join("/")
+ end
+
+ def node_properties_path(node_id)
+ [node_path, node_id.to_s, "properties"].join("/")
+ end
+
+ def path_traverse_node_path(node_id)
+ [node_path, node_id.to_s, "traverse", "path"].join("/")
+ end
+
+ def batch_node_path(node_id)
+ ["/node", node_id.to_s].join("/")
+ end
+
+ def batch_path
+ [data_directory, "batch"].join("/")
+ end
+
+ def relationship_path(relationship_id)
+ [data_directory, "relationship", relationship_id.to_s].join("/")
+ end
+
+ def relationships_path_for_node(node_id)
+ [node_path, node_id.to_s, "relationships"].join("/")
+ end
+
+ def node_full_index_path(index_name, key, value, node_id)
+ [node_index_path(index_name), key, value, node_id].join("/")
+ end
+
+ def node_index_path(index_name)
+ [data_directory, "index", "node", index_name.to_s].join("/")
+ end
+
+ def node_uri(node_id)
+ [service_root, node_path, node_id.to_s].join("/")
+ end
+
+ end
+end
11 lib/keymaker/create_node_request.rb
@@ -0,0 +1,11 @@
+module Keymaker
+
+ class CreateNodeRequest < Request
+
+ def submit
+ service.post(node_path, opts)
+ end
+
+ end
+
+end
26 lib/keymaker/create_relationship_request.rb
@@ -0,0 +1,26 @@
+module Keymaker
+
+ # Example request
+
+ # POST http://localhost:7474/db/data/node/85/relationships
+ # Accept: application/json
+ # Content-Type: application/json
+ # {"to" : "http://localhost:7474/db/data/node/84", "type" : "LOVES", "data" : {"foo" : "bar"}}
+
+ class CreateRelationshipRequest < Request
+
+ def submit
+ service.post(relationships_path_for_node(opts[:node_id]), rel_properties)
+ end
+
+ def rel_properties
+ {}.tap do |properties|
+ properties[:to] = node_uri(opts[:end_node_id])
+ properties[:type] = opts[:rel_type]
+ properties[:data] = opts[:data] if opts[:data]
+ end
+ end
+
+ end
+
+end
17 lib/keymaker/delete_relationship_request.rb
@@ -0,0 +1,17 @@
+module Keymaker
+
+ # Example request
+
+ # DELETE http://localhost:7474/db/data/relationship/85
+ # Accept: application/json
+ # Content-Type: application/json
+
+ class DeleteRelationshipRequest < Request
+
+ def submit
+ service.delete(relationship_path(opts[:relationship_id]))
+ end
+
+ end
+
+end
9 lib/keymaker/execute_cypher_request.rb
@@ -0,0 +1,9 @@
+module Keymaker
+ class ExecuteCypherRequest < Request
+
+ def submit
+ service.post(full_cypher_path, opts).body
+ end
+
+ end
+end
9 lib/keymaker/execute_gremlin_request.rb
@@ -0,0 +1,9 @@
+module Keymaker
+ class ExecuteGremlinRequest < Request
+
+ def submit
+ service.post(full_gremlin_path, opts).body
+ end
+
+ end
+end
34 lib/keymaker/indexing.rb
@@ -0,0 +1,34 @@
+module Keymaker
+
+ module Indexing
+
+ def self.included(base)
+ base.extend(ClassMethods)
+ end
+
+ module ClassMethods
+
+ def index_row(index_name)
+ indices_traits[index_name] = indices_traits.fetch(index_name, [])
+ end
+
+ # index :threds, on: :name, with: :sanitized_name
+ # data structure:
+ # { threds: [{ :index_key => :name, :value => :sanitized_name }], users: [{ :index_key => :email, :value => :email }, { :index_key => :username, :value => :username }] }
+ def index(index_name,options)
+ index_row(index_name.to_s) << { :index_key => options[:on].to_s, :value => options.fetch(:with, options[:on]) }
+ end
+ end
+
+ def update_indices
+ self.class.indices_traits.each do |index,traits|
+ traits.each do |trait|
+ neo_service.remove_node_from_index(index, trait[:index_key], send(trait[:value]), node_id)
+ neo_service.add_node_to_index(index, trait[:index_key], send(trait[:value]), node_id)
+ end
+ end
+ end
+
+ end
+
+end
111 lib/keymaker/node.rb
@@ -0,0 +1,111 @@
+require 'forwardable'
+
+module Keymaker
+
+ module Node
+
+ def self.included(base)
+ base.extend ActiveModel::Callbacks
+ base.extend ClassMethods
+
+ base.class_eval do
+ attr_writer :new_node
+ include ActiveModel::MassAssignmentSecurity
+
+ include Keymaker::Indexing
+ include Keymaker::Serialization
+
+ attr_protected :created_at, :updated_at
+ end
+
+ base.after_save :update_indices
+
+ base.class_attribute :property_traits
+ base.class_attribute :indices_traits
+
+ base.property_traits = {}
+ base.indices_traits = {}
+
+ base.property :active_record_id, Integer
+ base.property :node_id, Integer
+ base.property :created_at, DateTime
+ base.property :updated_at, DateTime
+ end
+
+ module ClassMethods
+
+ extend Forwardable
+
+ def_delegator :Keymaker, :service, :neo_service
+
+ def properties
+ property_traits.keys
+ end
+
+ def property(attribute,type=String)
+ property_traits[attribute] = type
+ attr_accessor attribute
+ end
+
+ def execute_cypher(query, params={}, return_type=:results_only)
+ executed_query = neo_service.execute_query(query, params)
+ if executed_query.present?
+ case return_type
+ when :results_only
+ executed_query["data"].flatten
+ # TODO: Make this less specific
+ when :full_user
+ {"user" => executed_query["data"].flatten[0]["data"], "neo_id" => executed_query["data"].flatten[1]}
+ end
+ else
+ return []
+ end
+ end
+
+ end
+
+ def initialize(attrs)
+ @new_node = true
+ process_attrs(attrs) if attrs.present?
+ end
+
+ def neo_service
+ self.class.neo_service
+ end
+
+ def new?
+ @new_node
+ end
+
+ def sanitize(attrs)
+ serializable_hash(except: :node_id).merge(attrs.except('node_id')).reject {|k,v| v.blank?}
+ end
+
+ def save
+ create_or_update
+ end
+
+ def create_or_update
+ run_callbacks :save do
+ new? ? create : update(attributes)
+ end
+ end
+
+ def create
+ run_callbacks :create do
+ neo_service.create_node(sanitize(attributes)).on_success do |response|
+ self.node_id = response.neo4j_id
+ self.new_node = false
+ self
+ end
+ end
+ end
+
+ def update(attrs)
+ process_attrs(sanitize(attrs.merge(updated_at: Time.now.utc.to_i)))
+ neo_service.update_node_properties(node_id, sanitize(attributes))
+ end
+
+ end
+
+end
34 lib/keymaker/path_traverse_request.rb
@@ -0,0 +1,34 @@
+module Keymaker
+ class PathTraverseRequest < Request
+
+ # Example request
+
+ # POST http://localhost:7474/db/data/node/9/traverse/path
+ # Accept: application/json
+ # Content-Type: application/json
+ # {"order":"breadth_first","uniqueness":"none","return_filter":{"language":"builtin","name":"all"}}
+
+ def submit
+ service.post(path_traverse_node_path(opts[:node_id]), path_traverse_properties)
+ end
+
+ def path_traverse_properties
+ # :order - breadth_first or depth_first
+ # :relationships - all, in, or out
+ # :uniqueness - node_global, none, relationship_global, node_path, or relationship_path
+ # :prune_evaluator
+ # :return_filter
+ # :max_depth
+
+ {}.tap do |properties|
+ properties[:order] = opts.fetch(:order, "breadth_first")
+ properties[:relationships] = opts.fetch(:relationships, "all")
+ properties[:uniqueness] = opts.fetch(:uniqueness, "relationship_global")
+ properties[:prune_evaluator] = opts[:prune_evaluator] if opts[:prune_evaluator]
+ properties[:return_filter] = opts[:return_filter] if opts[:return_filter]
+ properties[:max_depth] = opts[:max_depth] if opts[:max_depth]
+ end
+ end
+
+ end
+end
9 lib/keymaker/remove_node_from_index_request.rb
@@ -0,0 +1,9 @@
+module Keymaker
+ class RemoveNodeFromIndexRequest < Request
+
+ def submit
+ service.delete(node_full_index_path(opts[:index_name], opts[:key], opts[:value], opts[:node_id]))
+ end
+
+ end
+end
34 lib/keymaker/request.rb
@@ -0,0 +1,34 @@
+module Keymaker
+ class Request
+
+ extend Forwardable
+
+
+ def_delegator :config, :batch_node_path
+ def_delegator :config, :batch_path
+ def_delegator :config, :full_cypher_path
+ def_delegator :config, :full_gremlin_path
+ def_delegator :config, :node_full_index_path
+ def_delegator :config, :node_index_path
+ def_delegator :config, :node_path
+ def_delegator :config, :node_properties_path
+ def_delegator :config, :node_uri
+ def_delegator :config, :path_traverse_node_path
+ def_delegator :config, :relationship_path
+ def_delegator :config, :relationships_path_for_node
+
+ def_delegator :response, :body
+ def_delegator :response, :status
+ def_delegator :response, :faraday_response
+ def_delegator :response, :faraday_response=
+
+ attr_accessor :service, :config, :opts
+
+ def initialize(service, options)
+ self.config = service.config
+ self.opts = options
+ self.service = service
+ end
+
+ end
+end
38 lib/keymaker/response.rb
@@ -0,0 +1,38 @@
+module Keymaker
+
+ class Response
+
+ attr_accessor :request
+ attr_accessor :service
+ attr_accessor :faraday_response
+
+ def initialize(service, faraday_response)
+ self.service = service
+ self.faraday_response = faraday_response
+ end
+
+ def body
+ faraday_response.body || {}
+ end
+
+ def status
+ faraday_response.status
+ end
+
+ def neo4j_id
+ body["self"] && body["self"][/\d+$/].to_i
+ end
+
+ def on_success
+ if success?
+ yield self
+ end
+ end
+
+ def success?
+ (200..207).include?(status)
+ end
+
+ end
+
+end
46 lib/keymaker/serialization.rb
@@ -0,0 +1,46 @@
+module Keymaker::Serialization
+ include ActiveModel::Serialization
+
+ def self.included(base)
+ base.define_model_callbacks :save, :create
+ end
+
+ COERCION_PROCS = Hash.new(->(v){v}).tap do |procs|
+ procs[Integer] = ->(v){ v.to_i }
+ procs[DateTime] = ->(v) do
+ case v
+ when Time
+ Time.at(v)
+ when String
+ DateTime.strptime(v).to_time
+ else
+ Time.now.utc
+ end
+ end
+ end
+
+ def process_attrs(attrs)
+ attrs.symbolize_keys!
+ self.class.properties.delete_if{|p| p == :node_id}.each do |property|
+ if property == :active_record_id
+ process_attr(property, attrs[:id].present? ? attrs[:id] : attrs[:active_record_id])
+ else
+ process_attr(property, attrs[property])
+ end
+ end
+ end
+
+ def process_attr(key, value)
+ send("#{key}=", coerce(value,self.class.property_traits[key]))
+ end
+
+ def coerce(value,type)
+ COERCION_PROCS[type].call(value)
+ end
+
+ def attributes
+ Hash.new{|h,k| h[k] = send(k) }.tap do |hash|
+ self.class.properties.each{|property| hash[property.to_s] }
+ end
+ end
+end
147 lib/keymaker/service.rb
@@ -0,0 +1,147 @@
+require "addressable/uri"
+
+module Keymaker
+
+ class Service
+
+ attr_accessor :config
+
+ def initialize(config)
+ self.config = config
+ end
+
+ def connection=(connection)
+ @connection = connection
+ end
+
+ def connection
+ @connection ||= Faraday.new(url: config.service_root) do |conn|
+ conn.request :json
+ conn.use FaradayMiddleware::ParseJson, content_type: /\bjson$/
+ conn.adapter :net_http
+ end
+ end
+
+ # Create Node
+ def create_node(attrs)
+ create_node_request(attrs)
+ end
+
+ def create_node_request(opts)
+ CreateNodeRequest.new(self, opts).submit
+ end
+
+ # Update Node properties
+ def update_node_properties(node_id, attrs)
+ update_node_properties_request({node_id: node_id}.merge(attrs))
+ end
+
+ def update_node_properties_request(opts)
+ UpdateNodePropertiesRequest.new(self, opts).submit
+ end
+
+ # Create Relationship
+ def create_relationship(rel_type, start_node_id, end_node_id, data={})
+ create_relationship_request({node_id: start_node_id, rel_type: rel_type, end_node_id: end_node_id, data: data})
+ end
+
+ def create_relationship_request(opts)
+ CreateRelationshipRequest.new(self, opts).submit
+ end
+
+ # Delete Relationship
+ def delete_relationship(relationship_id)
+ delete_relationship_request(relationship_id: relationship_id)
+ end
+
+ def delete_relationship_request(opts)
+ DeleteRelationshipRequest.new(self, opts).submit
+ end
+
+ # Add Node to Index
+ def add_node_to_index(index_name, key, value, node_id)
+ add_node_to_index_request(index_name: index_name, key: key, value: value, node_id: node_id)
+ end
+
+ def add_node_to_index_request(opts)
+ AddNodeToIndexRequest.new(self, opts).submit
+ end
+
+ # Remove Node from Index
+ def remove_node_from_index(index_name, key, value, node_id)
+ remove_node_from_index_request(index_name: index_name, key: key, value: value, node_id: node_id)
+ end
+
+ def remove_node_from_index_request(opts)
+ RemoveNodeFromIndexRequest.new(self, opts).submit
+ end
+
+ # Path Traverse
+ def path_traverse(start_node_id, data={})
+ path_traverse_request({node_id: start_node_id}.merge(data))
+ end
+
+ def path_traverse_request(opts)
+ PathTraverseRequest.new(self, opts).submit
+ end
+
+ # Batch
+ ## GET Nodes
+ def batch_get_nodes(node_ids)
+ batch_get_nodes_request(node_ids)
+ end
+
+ def batch_get_nodes_request(opts)
+ BatchGetNodesRequest.new(self, opts).submit
+ end
+
+ # Cypher Query
+ def execute_query(query, params)
+ execute_cypher_request({query: query, params: params})
+ end
+
+ def execute_cypher_request(opts)
+ ExecuteCypherRequest.new(self, opts).submit
+ end
+
+ # Gremlin Script
+ def execute_script(script, params={})
+ execute_gremlin_request({script: script, params: params})
+ end
+
+ def execute_gremlin_request(opts)
+ ExecuteGremlinRequest.new(self, opts).submit
+ end
+
+ # HTTP Verbs
+
+ def get(url, body)
+ faraday_response = connection.get(parse_url(url), body)
+ Keymaker::Response.new(self, faraday_response)
+ end
+
+ def delete(url)
+ faraday_response = connection.delete(parse_url(url))
+ Keymaker::Response.new(self, faraday_response)
+ end
+
+ def post(url, body)
+ faraday_response = connection.post(parse_url(url), body)
+ Keymaker::Response.new(self, faraday_response)
+ end
+
+ def put(url, body)
+ faraday_response = connection.put(parse_url(url), body)
+ Keymaker::Response.new(self, faraday_response)
+ end
+
+ def parse_url(url)
+ connection.build_url(url).tap do |uri|
+ if uri.port != config.port
+ raise RuntimeError, "bad port"
+ end
+ end
+ end
+
+ end
+end
15 lib/keymaker/update_node_properties_request.rb
@@ -0,0 +1,15 @@
+module Keymaker
+
+ class UpdateNodePropertiesRequest < Request
+
+ def submit
+ service.put(node_properties_path(opts[:node_id]), node_properties)
+ end
+
+ def node_properties
+ opts.except(:node_id)
+ end
+
+ end
+
+end
189 spec/lib/keymaker_integration_spec.rb
@@ -0,0 +1,189 @@
+require 'spec_helper'
+require "addressable/uri"
+require 'keymaker'
+
+describe Keymaker do
+
+ include_context "John and Sarah nodes"
+
+ context "indices" do
+ include_context "John and Sarah indexed nodes"
+
+ context "given a bad port number" do
+
+ let(:url) { john_node_url.dup.gsub("7475", "49152") }
+
+ after { service.connection = connection }
+
+ def do_it
+ connection.get(url) do |req|
+ req.options[:timeout] = 0
+ req.options[:open_timeout] = 0
+ end
+ end
+
+ it "raises an error" do
+ expect { do_it }.to raise_error
+ end
+
+ end
+
+ context "given an explicit connection" do
+
+ let(:url) { john_node_url }
+
+ before { service.connection = test_connection }
+ after { service.connection = connection }
+
+ def do_it
+ service.connection.get(url)
+ end
+
+ it "uses the connection for requests" do
+ faraday_stubs.get(Addressable::URI.parse(url).path) do
+ [200, {}, "{}"]
+ end
+ do_it
+ faraday_stubs.verify_stubbed_calls
+ end
+
+ end
+
+ describe "#add_node_to_index(index_name, key, value, node_id)" do
+
+ def do_it
+ service.add_node_to_index(:users, :email, email, node_id)
+ end
+
+ context "given existing values" do
+
+ let(:email) { john_email }
+ let(:node_id) { john_node_id }
+ let(:index_result) { connection.get(index_query_for_john_url).body[0]["self"] }
+
+ it "adds the node to the index" do
+ do_it
+ index_result.should == john_node_url
+ end
+
+ it "returns a status of 201" do
+ do_it.status.should == 201
+ end
+
+ end
+
+ context "given an invalid node id" do
+
+ let(:email) { john_email }
+ let(:node_id) { -22 }
+
+ it "returns a 500 status" do
+ do_it.status.should == 500
+ end
+
+ end
+
+ end
+
+ describe "#remove_node_from_index(index_name, key, value, node_id)" do
+
+ def do_it
+ service.remove_node_from_index(:users, :email, email, node_id)
+ end
+
+ context "given existing values" do
+
+ let(:email) { john_email }
+ let(:node_id) { john_node_id }
+
+ it "removes the node from the index" do
+ do_it
+ connection.get(index_query_for_john_url).body.should be_empty
+ end
+
+ it "returns a status of 204" do
+ do_it.status.should == 204
+ end
+
+ it "keeps the other node indices" do
+ do_it
+ connection.get(index_query_for_sarah_url).body.should_not be_empty
+ end
+
+ end
+
+ context "given unmatched values" do
+
+ let(:email) { "unknown@example.com" }
+ let(:node_id) { -22 }
+
+ it "returns a 404 status" do
+ do_it.status.should == 404
+ end
+
+ end
+
+ end
+ end
+
+ describe "#execute_query" do
+
+ def do_it
+ service.execute_query("START user=node:users(email={email}) RETURN user", email: john_email)
+ end
+
+ context "given existing values" do
+
+ before { service.add_node_to_index(:users, :email, john_email, john_node_id) }
+ let(:query_result) { do_it["data"][0][0]["self"] }
+
+ it "performs the cypher query and responds" do
+ query_result.should == john_node_url
+ end
+
+ end
+
+ end
+
+ context "nodes" do
+
+ include_context "Keymaker connections"
+
+ describe "#create_node" do
+
+ let(:properties) { { first_name: "john", last_name: "connor", email: "john@resistance.net" } }
+
+ def do_it
+ new_node_id = service.create_node(properties).neo4j_id
+ connection.get("/db/data/node/#{new_node_id}/properties").body
+ end
+
+ it "creates a node with properties" do
+ do_it.should == {"first_name"=>"john", "email"=>"john@resistance.net", "last_name"=>"connor"}
+ end
+
+ end
+
+ end
+
+ context "relationships" do
+
+ include_context "Keymaker connections"
+
+ describe "#create_relationship" do
+
+ def do_it
+ service.create_relationship(:loves, john_node_id, sarah_node_id).neo4j_id
+ connection.get("/db/data/node/#{john_node_id}/relationships/all/loves").body.first
+ end
+
+ it "creates the relationship between the two nodes" do
+ do_it["start"].should == john_node_url
+ do_it["end"].should == sarah_node_url
+ end
+
+ end
+
+ end
+
+end
7 spec/spec_helper.rb
@@ -0,0 +1,7 @@
+ROOT = File.expand_path('../..', __FILE__)
+Dir[File.join(ROOT, 'spec/support/**/*.rb')].each {|f| require f}
+$LOAD_PATH.unshift(File.expand_path('lib', ROOT))
+
+RSpec.configure do |config|
+ config.mock_with :rspec
+end
106 spec/support/keymaker.rb
@@ -0,0 +1,106 @@
+require 'keymaker'
+
+Keymaker.configure do |c|
+ c.server = "localhost"
+ c.port = 7475
+end
+
+shared_context "Keymaker connections" do
+ let(:connection) do
+ Faraday.new({url: "http://localhost:7475"}) do |conn|
+ conn.request :json
+ conn.use FaradayMiddleware::ParseJson, content_type: /\bjson$/
+ conn.adapter :net_http
+ end
+ end
+ let(:faraday_stubs) do
+ Faraday::Adapter::Test::Stubs.new
+ end
+ let(:test_connection) do
+ Faraday.new do |conn|
+ conn.adapter :test, faraday_stubs
+ end
+ end
+ let(:service) { Keymaker.service }
+end
+
+shared_context "John and Sarah nodes" do
+ include_context "Keymaker connections"
+
+ let!(:john_node_url) do
+ connection.post("/db/data/node") do |request|
+ request.body = {email: john_email}
+ end.body["self"]
+ end
+
+ let!(:sarah_node_url) do
+ connection.post("/db/data/node") do |request|
+ request.body = {email: sarah_email}
+ end.body["self"]
+ end
+
+ let(:john_node_id) { john_node_url.split("/").last }
+ let(:sarah_node_id) { sarah_node_url.split("/").last }
+
+ let(:index_query_for_john_url) { "/db/data/index/node/users/email/#{john_email}" }
+ let(:index_query_for_sarah_url) { "/db/data/index/node/users/email/#{sarah_email}" }
+ let(:delete_index_path_for_john_url) { "/db/data/index/node/users/#{john_node_id}" }
+
+ let(:john_email) { "john@resistance.net" }
+ let(:sarah_email) { "sarah@resistance.net" }
+end
+
+shared_context "John and Sarah indexed nodes" do
+
+ include_context "John and Sarah nodes"
+
+ let!(:indexed_john) do
+ connection.post("db/data/index/node/users") do |request|
+ request.body = {
+ key: "email",
+ value: john_email,
+ uri: john_node_url
+ }
+ end.body
+ end
+
+ let!(:indexed_sarah) do
+ connection.post("db/data/index/node/users") do |request|
+ request.body = {
+ key: "email",
+ value: sarah_email,
+ uri: sarah_node_url
+ }
+ end.body
+ end
+
+end
+
+def clear_graph
+ raw_connection.post("/db/data/ext/GremlinPlugin/graphdb/execute_script", {script: "g.clear()\;g.V()"})
+end
+
+def clear_users_index
+ raw_connection.delete("http://localhost:7475/db/data/index/node/users")
+end
+
+def raw_connection
+ Faraday.new({url: "http://localhost:7475"}) do |conn|
+ conn.request :json
+ conn.use FaradayMiddleware::ParseJson, content_type: /\bjson$/
+ conn.adapter :net_http
+ end
+end
+
+RSpec.configure do |config|
+ config.before(:all) do
+ clear_graph
+ end
+ config.before(:each) do
+ clear_graph
+ clear_users_index
+ end
+ config.after(:all) do
+ clear_graph
+ end
+end
Please sign in to comment.
Something went wrong with that request. Please try again.