├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── jsonrpc-client.gemspec ├── lib ├── jsonrpc-client.rb └── jsonrpc │ ├── client.rb │ ├── error.rb │ ├── request.rb │ ├── response.rb │ └── version.rb └── spec ├── client_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | coverage 3 | .rvmrc 4 | Gemfile.lock 5 | *.swp 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in 9 | all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 17 | THE SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSONRPC::Client 2 | 3 | Simple JSON-RPC 2.0 client implementation. See the [specification](http://www.jsonrpc.org/specification). 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | gem 'jsonrpc-client' 10 | 11 | And then execute: 12 | 13 | $ bundle 14 | 15 | Or install it yourself as: 16 | 17 | $ gem install jsonrpc-client 18 | 19 | ## Usage 20 | 21 | ``` 22 | client = JSONRPC::Client.new('http://example.com') 23 | client.add_numbers(1, 2, 3) 24 | ``` 25 | 26 | ### Passing a customized connection 27 | 28 | By default, the client uses a plain Faraday connection with Faraday's default adapter to connect to the JSON-RPC endpoint. If you wish to customize this connection, you can pass your own Faraday object into the constructor. In this example, SSL verification is disabled and HTTP Basic Authentication is used: 29 | 30 | ```ruby 31 | connection = Faraday.new { |connection| 32 | connection.adapter Faraday.default_adapter 33 | connection.ssl.verify = false # This is a baaaad idea! 34 | connection.basic_auth('username', 'password') 35 | } 36 | client = JSONRPC::Client.new("http://example.com", { connection: connection }) 37 | ``` 38 | 39 | More information about Faraday is available at [that project's GitHub page](https://github.com/lostisland/faraday). 40 | 41 | 42 | ## Contributing 43 | 44 | 1. Fork it 45 | 2. Create your feature branch (`git checkout -b my-new-feature`) 46 | 3. Commit your changes (`git commit -am 'Add some feature'`) 47 | 4. Push to the branch (`git push origin my-new-feature`) 48 | 5. Create new Pull Request 49 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rspec/core/rake_task' 3 | require 'bundler/gem_tasks' 4 | 5 | desc "Run all specs" 6 | RSpec::Core::RakeTask.new(:rspec) do |spec| 7 | spec.pattern = 'spec/**/*_spec.rb' 8 | end 9 | 10 | RSpec::Core::RakeTask.new(:rcov) do |spec| 11 | spec.pattern = 'spec/**/*_spec.rb' 12 | spec.rcov = true 13 | end 14 | 15 | task :default => :rspec 16 | 17 | begin 18 | require 'rdoc/task' 19 | rescue LoadError 20 | require 'rake/rdoctask' 21 | end 22 | 23 | Rake::RDocTask.new do |rdoc| 24 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 25 | 26 | rdoc.rdoc_dir = 'rdoc' 27 | rdoc.title = "jimson #{version}" 28 | rdoc.rdoc_files.include('README*') 29 | rdoc.rdoc_files.include('lib/**/*.rb') 30 | end 31 | -------------------------------------------------------------------------------- /jsonrpc-client.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'jsonrpc/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "jsonrpc-client" 8 | gem.version = JSONRPC::VERSION 9 | gem.authors = ["Pavel Forkert"] 10 | gem.email = ["fxposter@gmail.com"] 11 | gem.description = %q{Simple JSON-RPC 2.0 client implementation} 12 | gem.summary = %q{JSON-RPC 2.0 client} 13 | gem.homepage = "https://github.com/fxposter/jsonrpc-client" 14 | 15 | gem.files = `git ls-files`.split($/) 16 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 17 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 18 | gem.require_paths = ["lib"] 19 | 20 | gem.add_dependency 'faraday' 21 | gem.add_dependency 'multi_json', '>= 1.1.0' 22 | gem.add_development_dependency 'rspec' 23 | gem.add_development_dependency 'rake' 24 | end 25 | -------------------------------------------------------------------------------- /lib/jsonrpc-client.rb: -------------------------------------------------------------------------------- 1 | require 'jsonrpc/client' 2 | -------------------------------------------------------------------------------- /lib/jsonrpc/client.rb: -------------------------------------------------------------------------------- 1 | require 'multi_json' 2 | require 'faraday' 3 | require 'uri' 4 | require 'jsonrpc/request' 5 | require 'jsonrpc/response' 6 | require 'jsonrpc/error' 7 | require 'jsonrpc/version' 8 | 9 | module JSONRPC 10 | def self.logger=(logger) 11 | @logger = logger 12 | end 13 | 14 | def self.logger 15 | @logger 16 | end 17 | 18 | def self.decode_options=(options) 19 | @decode_options = options 20 | end 21 | 22 | def self.decode_options 23 | @decode_options 24 | end 25 | 26 | @decode_options = {} 27 | 28 | class Helper 29 | def initialize(options) 30 | @options = options 31 | @options[:content_type] ||= 'application/json' 32 | @connection = @options.delete(:connection) 33 | end 34 | 35 | def options(additional_options = nil) 36 | if additional_options 37 | additional_options.merge(@options) 38 | else 39 | @options 40 | end 41 | end 42 | 43 | def connection 44 | @connection || ::Faraday.new { |connection| 45 | connection.response :logger, ::JSONRPC.logger 46 | connection.adapter ::Faraday.default_adapter 47 | } 48 | end 49 | end 50 | 51 | class Base < BasicObject 52 | JSON_RPC_VERSION = '2.0' 53 | 54 | def self.make_id 55 | rand(10**12) 56 | end 57 | 58 | def initialize(url, opts = {}) 59 | @url = ::URI.parse(url).to_s 60 | @helper = ::JSONRPC::Helper.new(opts) 61 | end 62 | 63 | def to_s 64 | inspect 65 | end 66 | 67 | def inspect 68 | "#<#{self.class.name}:0x00%08x>" % (__id__ * 2) 69 | end 70 | 71 | def class 72 | (class << self; self end).superclass 73 | end 74 | 75 | private 76 | def raise(*args) 77 | ::Kernel.raise(*args) 78 | end 79 | end 80 | 81 | class BatchClient < Base 82 | attr_reader :batch 83 | 84 | def initialize(url, opts = {}) 85 | super 86 | @batch = [] 87 | @alive = true 88 | yield self 89 | send_batch 90 | @alive = false 91 | end 92 | 93 | def method_missing(sym, *args, &block) 94 | if @alive 95 | request = ::JSONRPC::Request.new(sym.to_s, args) 96 | push_batch_request(request) 97 | else 98 | super 99 | end 100 | end 101 | 102 | private 103 | def send_batch_request(batch) 104 | post_data = ::MultiJson.encode(batch) 105 | resp = @helper.connection.post(@url, post_data, @helper.options) 106 | if resp.nil? || resp.body.nil? || resp.body.empty? 107 | raise ::JSONRPC::Error::InvalidResponse.new 108 | end 109 | 110 | resp.body 111 | end 112 | 113 | def process_batch_response(responses) 114 | responses.each do |resp| 115 | saved_response = @batch.map { |r| r[1] }.select { |r| r.id == resp['id'] }.first 116 | raise ::JSONRPC::Error::InvalidResponse.new if saved_response.nil? 117 | saved_response.populate!(resp) 118 | end 119 | end 120 | 121 | def push_batch_request(request) 122 | request.id = ::JSONRPC::Base.make_id 123 | response = ::JSONRPC::Response.new(request.id) 124 | @batch << [request, response] 125 | response 126 | end 127 | 128 | def send_batch 129 | batch = @batch.map(&:first) # get the requests 130 | response = send_batch_request(batch) 131 | 132 | begin 133 | responses = ::MultiJson.decode(response, ::JSONRPC.decode_options) 134 | rescue 135 | raise ::JSONRPC::Error::InvalidJSON.new(json) 136 | end 137 | 138 | process_batch_response(responses) 139 | @batch = [] 140 | end 141 | end 142 | 143 | class Client < Base 144 | def method_missing(method, *args, &block) 145 | invoke(method, args) 146 | end 147 | 148 | def invoke(method, args, options = nil) 149 | resp = send_single_request(method.to_s, args, options) 150 | 151 | begin 152 | data = ::MultiJson.decode(resp, ::JSONRPC.decode_options) 153 | rescue 154 | raise ::JSONRPC::Error::InvalidJSON.new(resp) 155 | end 156 | 157 | process_single_response(data) 158 | rescue => e 159 | e.extend(::JSONRPC::Error) 160 | raise 161 | end 162 | 163 | private 164 | def send_single_request(method, args, options) 165 | post_data = ::MultiJson.encode({ 166 | 'jsonrpc' => ::JSONRPC::Base::JSON_RPC_VERSION, 167 | 'method' => method, 168 | 'params' => args, 169 | 'id' => ::JSONRPC::Base.make_id 170 | }) 171 | resp = @helper.connection.post(@url, post_data, @helper.options(options)) 172 | 173 | if resp.nil? || resp.body.nil? || resp.body.empty? 174 | raise ::JSONRPC::Error::InvalidResponse.new 175 | end 176 | 177 | resp.body 178 | end 179 | 180 | def process_single_response(data) 181 | raise ::JSONRPC::Error::InvalidResponse.new unless valid_response?(data) 182 | 183 | if data['error'] 184 | code = data['error']['code'] 185 | msg = data['error']['message'] 186 | raise ::JSONRPC::Error::ServerError.new(code, msg) 187 | end 188 | 189 | data['result'] 190 | end 191 | 192 | def valid_response?(data) 193 | return false if !data.is_a?(::Hash) 194 | return false if data['jsonrpc'] != ::JSONRPC::Base::JSON_RPC_VERSION 195 | return false if !data.has_key?('id') 196 | return false if data.has_key?('error') && data.has_key?('result') 197 | 198 | if data.has_key?('error') 199 | if !data['error'].is_a?(::Hash) || !data['error'].has_key?('code') || !data['error'].has_key?('message') 200 | return false 201 | end 202 | 203 | if !data['error']['code'].is_a?(::Integer) || !data['error']['message'].is_a?(::String) 204 | return false 205 | end 206 | end 207 | 208 | true 209 | rescue 210 | false 211 | end 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /lib/jsonrpc/error.rb: -------------------------------------------------------------------------------- 1 | module JSONRPC 2 | module Error 3 | class InvalidResponse < StandardError 4 | def initialize() 5 | super('Invalid or empty response from server.') 6 | end 7 | end 8 | 9 | class InvalidJSON < StandardError 10 | def initialize(json) 11 | super("Couldn't parse JSON string received from server:\n#{json}") 12 | end 13 | end 14 | 15 | class ServerError < StandardError 16 | attr_reader :code, :response_error 17 | 18 | def initialize(code, message) 19 | @code = code 20 | @response_error = message 21 | super("Server error #{code}: #{message}") 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/jsonrpc/request.rb: -------------------------------------------------------------------------------- 1 | module JSONRPC 2 | class Request 3 | 4 | attr_accessor :method, :params, :id 5 | def initialize(method, params, id = nil) 6 | @method = method 7 | @params = params 8 | @id = id 9 | end 10 | 11 | def to_h 12 | h = { 13 | 'jsonrpc' => '2.0', 14 | 'method' => @method 15 | } 16 | h.merge!('params' => @params) if !!@params && !params.empty? 17 | h.merge!('id' => id) 18 | end 19 | 20 | def to_json(*a) 21 | MultiJson.encode(self.to_h) 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/jsonrpc/response.rb: -------------------------------------------------------------------------------- 1 | module JSONRPC 2 | class Response 3 | attr_accessor :result, :error, :id 4 | 5 | def initialize(id) 6 | @id = id 7 | end 8 | 9 | def to_h 10 | h = {'jsonrpc' => '2.0'} 11 | h.merge!('result' => @result) if !!@result 12 | h.merge!('error' => @error) if !!@error 13 | h.merge!('id' => @id) 14 | end 15 | 16 | def is_error? 17 | !!@error 18 | end 19 | 20 | def succeeded? 21 | !!@result 22 | end 23 | 24 | def populate!(data) 25 | @error = data['error'] if !!data['error'] 26 | @result = data['result'] if !!data['result'] 27 | end 28 | 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/jsonrpc/version.rb: -------------------------------------------------------------------------------- 1 | module JSONRPC 2 | VERSION = '0.1.4' 3 | end 4 | -------------------------------------------------------------------------------- /spec/client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module JSONRPC 4 | describe Client do 5 | BOILERPLATE = {'jsonrpc' => '2.0', 'id' => 1} 6 | 7 | let(:connection) { double('connection') } 8 | 9 | before(:each) do 10 | @resp_mock = double('http_response') 11 | Base.stub(:make_id).and_return(1) 12 | end 13 | 14 | after(:each) do 15 | end 16 | 17 | describe "hidden methods" do 18 | it "should reveal inspect" do 19 | Client.new(SPEC_URL).inspect.should match /JSONRPC::Client/ 20 | end 21 | 22 | it "should reveal to_s" do 23 | Client.new(SPEC_URL).to_s.should match /JSONRPC::Client/ 24 | end 25 | end 26 | 27 | describe "#invoke" do 28 | let(:expected) { MultiJson.encode({ 29 | 'jsonrpc' => '2.0', 30 | 'method' => 'foo', 31 | 'params' => [1,2,3], 32 | 'id' => 1 33 | }) 34 | } 35 | 36 | before(:each) do 37 | response = MultiJson.encode(BOILERPLATE.merge({'result' => 42})) 38 | @resp_mock.should_receive(:body).at_least(:once).and_return(response) 39 | @client = Client.new(SPEC_URL, :connection => connection) 40 | end 41 | 42 | context "when using an array of args" do 43 | it "sends a request with the correct method and args" do 44 | connection.should_receive(:post).with(SPEC_URL, expected, {:content_type => 'application/json'}).and_return(@resp_mock) 45 | @client.invoke('foo', [1, 2, 3]).should == 42 46 | end 47 | end 48 | 49 | context "with headers" do 50 | it "adds additional headers" do 51 | connection.should_receive(:post).with(SPEC_URL, expected, {:content_type => 'application/json', "X-FOO" => "BAR"}).and_return(@resp_mock) 52 | @client.invoke('foo', [1, 2, 3], "X-FOO" => "BAR").should == 42 53 | end 54 | end 55 | end 56 | 57 | describe "sending a single request" do 58 | context "when using positional parameters" do 59 | before(:each) do 60 | @expected = MultiJson.encode({ 61 | 'jsonrpc' => '2.0', 62 | 'method' => 'foo', 63 | 'params' => [1,2,3], 64 | 'id' => 1 65 | }) 66 | end 67 | it "sends a valid JSON-RPC request and returns the result" do 68 | response = MultiJson.encode(BOILERPLATE.merge({'result' => 42})) 69 | connection.should_receive(:post).with(SPEC_URL, @expected, {:content_type => 'application/json'}).and_return(@resp_mock) 70 | @resp_mock.should_receive(:body).at_least(:once).and_return(response) 71 | client = Client.new(SPEC_URL, :connection => connection) 72 | client.foo(1,2,3).should == 42 73 | end 74 | 75 | it "sends a valid JSON-RPC request with custom options" do 76 | response = MultiJson.encode(BOILERPLATE.merge({'result' => 42})) 77 | connection.should_receive(:post).with(SPEC_URL, @expected, {:content_type => 'application/json', :timeout => 10000}).and_return(@resp_mock) 78 | @resp_mock.should_receive(:body).at_least(:once).and_return(response) 79 | client = Client.new(SPEC_URL, :timeout => 10000, :connection => connection) 80 | client.foo(1,2,3).should == 42 81 | end 82 | 83 | it "sends a valid JSON-RPC request with custom content_type" do 84 | response = MultiJson.encode(BOILERPLATE.merge({'result' => 42})) 85 | connection.should_receive(:post).with(SPEC_URL, @expected, {:content_type => 'application/json-rpc', :timeout => 10000}).and_return(@resp_mock) 86 | @resp_mock.should_receive(:body).at_least(:once).and_return(response) 87 | client = Client.new(SPEC_URL, :timeout => 10000, :content_type => 'application/json-rpc', :connection => connection) 88 | client.foo(1,2,3).should == 42 89 | end 90 | end 91 | end 92 | 93 | describe "sending a batch request" do 94 | it "sends a valid JSON-RPC batch request and puts the results in the response objects" do 95 | batch = MultiJson.encode([ 96 | {"jsonrpc" => "2.0", "method" => "sum", "params" => [1,2,4], "id" => "1"}, 97 | {"jsonrpc" => "2.0", "method" => "subtract", "params" => [42,23], "id" => "2"}, 98 | {"jsonrpc" => "2.0", "method" => "foo_get", "params" => [{"name" => "myself"}], "id" => "5"}, 99 | {"jsonrpc" => "2.0", "method" => "get_data", "id" => "9"} 100 | ]) 101 | 102 | response = MultiJson.encode([ 103 | {"jsonrpc" => "2.0", "result" => 7, "id" => "1"}, 104 | {"jsonrpc" => "2.0", "result" => 19, "id" => "2"}, 105 | {"jsonrpc" => "2.0", "error" => {"code" => -32601, "message" => "Method not found."}, "id" => "5"}, 106 | {"jsonrpc" => "2.0", "result" => ["hello", 5], "id" => "9"} 107 | ]) 108 | 109 | Base.stub(:make_id).and_return('1', '2', '5', '9') 110 | connection.should_receive(:post).with(SPEC_URL, batch, {:content_type => 'application/json'}).and_return(@resp_mock) 111 | @resp_mock.should_receive(:body).at_least(:once).and_return(response) 112 | client = Client.new(SPEC_URL, :connection => connection) 113 | 114 | sum = subtract = foo = data = nil 115 | client = BatchClient.new(SPEC_URL, :connection => connection) do |batch| 116 | sum = batch.sum(1,2,4) 117 | subtract = batch.subtract(42,23) 118 | foo = batch.foo_get('name' => 'myself') 119 | data = batch.get_data 120 | end 121 | 122 | sum.succeeded?.should be_true 123 | sum.is_error?.should be_false 124 | sum.result.should == 7 125 | 126 | subtract.result.should == 19 127 | 128 | foo.is_error?.should be_true 129 | foo.succeeded?.should be_false 130 | foo.error['code'].should == -32601 131 | 132 | data.result.should == ['hello', 5] 133 | 134 | 135 | expect { client.sum(1, 2) }.to raise_error(NoMethodError) 136 | end 137 | end 138 | 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'jsonrpc-client' 3 | require 'multi_json' 4 | 5 | SPEC_URL = 'http://localhost:8999' 6 | --------------------------------------------------------------------------------