├── .rspec ├── lib ├── jrpc │ ├── version.rb │ ├── error │ │ ├── error.rb │ │ ├── client_error.rb │ │ ├── unknown_error.rb │ │ ├── internal_server_error.rb │ │ ├── parse_error.rb │ │ ├── connection_closed_error.rb │ │ ├── internal_error.rb │ │ ├── invalid_params.rb │ │ ├── invalid_request.rb │ │ ├── method_not_found.rb │ │ ├── server_error.rb │ │ └── connection_error.rb │ ├── utils.rb │ ├── error.rb │ ├── transport │ │ ├── socket_base.rb │ │ └── socket_tcp.rb │ ├── base_client.rb │ └── tcp_client.rb └── jrpc.rb ├── .travis.yml ├── spec ├── spec_helper.rb ├── fake_transport.rb └── tcp_client_spec.rb ├── Gemfile ├── Rakefile ├── .gitignore ├── bin ├── setup ├── console ├── jrpc └── jrpc-shell ├── README.md ├── CHANGELOG.md ├── jrpc.gemspec └── LICENSE.txt /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /lib/jrpc/version.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | VERSION = '1.1.8' 3 | end 4 | -------------------------------------------------------------------------------- /lib/jrpc/error/error.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class Error < RuntimeError 3 | 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.2.2 4 | before_install: gem install bundler -v 1.10.6 5 | -------------------------------------------------------------------------------- /lib/jrpc/error/client_error.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class ClientError < Error 3 | 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'jrpc' 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in jrpc.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/jrpc/error/unknown_error.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class UnknownError < ServerError 3 | 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/jrpc/error/internal_server_error.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class InternalServerError < ServerError 3 | 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .rake_tasks~ 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/jrpc/error/parse_error.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class ParseError < ServerError 3 | 4 | def initialize(message) 5 | super(message, -32700) 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/jrpc/error/connection_closed_error.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class ConnectionClosedError < Error 3 | def initialize 4 | super('socket was closed unexpectedly') 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/jrpc/error/internal_error.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class InternalError < ServerError 3 | 4 | def initialize(message) 5 | super(message, -32603) 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/jrpc/error/invalid_params.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class InvalidParams < ServerError 3 | 4 | def initialize(message) 5 | super(message, -32602) 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/jrpc/error/invalid_request.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class InvalidRequest < ServerError 3 | 4 | def initialize(message) 5 | super(message, -32600) 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/jrpc/error/method_not_found.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class MethodNotFound < ServerError 3 | 4 | def initialize(message) 5 | super(message, -32601) 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/jrpc/utils.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class Utils 3 | 4 | def self.truncate(string, length, ommiter = '...') 5 | "#{string[0..length]}#{ommiter if string.length > length}" 6 | end 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/jrpc/error/server_error.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class ServerError < Error 3 | attr_reader :code 4 | 5 | def initialize(message, code) 6 | @code = code 7 | super(message) 8 | end 9 | 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/jrpc/error/connection_error.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | class ConnectionError < Error 3 | attr_reader :original 4 | 5 | def initialize(msg, original=$!) 6 | super(msg) 7 | @original = original 8 | end 9 | 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/jrpc.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | require 'json' 3 | require 'jrpc/version' 4 | require 'jrpc/error' 5 | require 'jrpc/utils' 6 | require 'jrpc/base_client' 7 | require 'jrpc/transport/socket_base' 8 | require 'jrpc/transport/socket_tcp' 9 | require 'jrpc/tcp_client' 10 | 11 | module JRPC 12 | JSON_RPC_VERSION = '2.0' 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'jrpc' 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require 'irb' 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /lib/jrpc/error.rb: -------------------------------------------------------------------------------- 1 | require 'jrpc/error/error' 2 | require 'jrpc/error/connection_error' 3 | require 'jrpc/error/connection_closed_error' 4 | require 'jrpc/error/client_error' 5 | require 'jrpc/error/server_error' 6 | require 'jrpc/error/internal_error' 7 | require 'jrpc/error/internal_server_error' 8 | require 'jrpc/error/invalid_params' 9 | require 'jrpc/error/invalid_request' 10 | require 'jrpc/error/method_not_found' 11 | require 'jrpc/error/parse_error' 12 | require 'jrpc/error/unknown_error' 13 | -------------------------------------------------------------------------------- /spec/fake_transport.rb: -------------------------------------------------------------------------------- 1 | class FakeTransport 2 | attr_reader :read_timeout, :write_timeout 3 | 4 | def initialize(params = {}) 5 | @read_timeout = params.fetch(:read_timeout, 60.0).to_f 6 | @write_timeout = params.fetch(:write_timeout, 60.0).to_f 7 | end 8 | 9 | def connect; end 10 | 11 | def write(_, write_timeout = nil) 12 | @write_timeout = write_timeout if write_timeout 13 | end 14 | 15 | def read(_, read_timeout = nil) 16 | @read_timeout = read_timeout if read_timeout 17 | @response 18 | end 19 | 20 | def response=(str) 21 | @response = str + ',' 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JRPC 2 | 3 | JSON RPC TCP client 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'jrpc' 11 | ``` 12 | 13 | And then execute: 14 | 15 | $ bundle 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install jrpc 20 | 21 | ## Usage 22 | 23 | TODO: Write usage instructions here 24 | 25 | ## Contributing 26 | 27 | Bug reports and pull requests are welcome on GitHub at https://github.com/didww/jrpc. 28 | 29 | 30 | ## License 31 | 32 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 33 | 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### Unreleased 4 | 5 | ### 1.1.8 6 | * handling FIN signal for TCP socket [didww/jrpc#19](https://github.com/didww/jrpc/pull/19) 7 | * add gem executables [didww/jrpc#19](https://github.com/didww/jrpc/pull/19) 8 | 9 | ### 1.1.7 10 | * connect ot socket in nonblock mode 11 | 12 | ### 1.1.6 13 | * update oj version to ~> 3.0 14 | 15 | ### 1.1.5 16 | * update oj version to ~> 2.0 17 | 18 | ### 1.1.4 19 | * handle EOF on read 20 | * fix jrpc error require 21 | * use JRPC::Error as base class for JRPC::Transport::SocketBase::Error 22 | 23 | ### 1.1.3 24 | * close socket when clearing socket if it's not closed 25 | 26 | ### 1.1.2 27 | * reset socket when broken pipe error appears 28 | 29 | ### 1.1.1 30 | * fix rescuing error in TcpClient initializer 31 | 32 | ### 1.1.0 33 | * use own socket wrapper 34 | 35 | ### 1.0.1 36 | * Net::TCPClient#read method process data with buffer variable 37 | 38 | ### 1.0.0 39 | * stable release 40 | -------------------------------------------------------------------------------- /jrpc.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'jrpc/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'jrpc' 8 | spec.version = JRPC::VERSION 9 | spec.authors = ['Denis Talakevich'] 10 | spec.email = ['senid231@gmail.com'] 11 | 12 | spec.summary = 'JSON RPC client' 13 | spec.description = 'JSON RPC client over TCP' 14 | spec.homepage = 'https://github.com/didww/jrpc' 15 | spec.license = 'MIT' 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.require_paths = ['lib'] 19 | 20 | spec.add_dependency 'netstring', '~> 0' 21 | spec.add_dependency 'oj', '~> 3.0' 22 | 23 | spec.executables << 'jrpc' 24 | spec.executables << 'jrpc-shell' 25 | 26 | spec.add_development_dependency 'bundler' 27 | spec.add_development_dependency 'rake', '~> 13.0' 28 | spec.add_development_dependency 'rspec', '~> 3.0' 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Denis Talakevich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/jrpc/transport/socket_base.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | module Transport 3 | class SocketBase 4 | 5 | class Error < ::JRPC::Error 6 | end 7 | 8 | class TimeoutError < Error 9 | def initialize 10 | super(self.class.to_s.split('::').last) 11 | end 12 | end 13 | 14 | class ReadTimeoutError < TimeoutError 15 | end 16 | 17 | class WriteTimeoutError < TimeoutError 18 | end 19 | 20 | class ConnectionTimeoutError < TimeoutError 21 | end 22 | 23 | class ConnectionFailedError < Error 24 | end 25 | 26 | class WriteFailedError < Error 27 | end 28 | 29 | class ReadFailedError < Error 30 | end 31 | 32 | attr_reader :options, :read_timeout, :write_timeout 33 | 34 | def self.connect(options) 35 | connection = new(options) 36 | yield(connection) 37 | ensure 38 | connection.close if connection 39 | end 40 | 41 | def initialize(options) 42 | @server = options.fetch(:server) 43 | @read_timeout = options.fetch(:read_timeout, nil) 44 | @write_timeout = options.fetch(:write_timeout, nil) 45 | @connect_timeout = options.fetch(:connect_timeout, nil) 46 | @connect_retry_count = options.fetch(:connect_retry_count, 0) 47 | @options = options 48 | end 49 | 50 | def connect 51 | retries = @connect_retry_count 52 | 53 | while retries >= 0 54 | begin 55 | connect_socket 56 | break 57 | rescue Error => e 58 | retries -= 1 59 | raise e if retries < 0 60 | end 61 | end 62 | end 63 | 64 | def read(_length, _timeout = @read_timeout) 65 | raise NotImplementedError 66 | end 67 | 68 | def write(_data, _timeout = @write_timeout) 69 | raise NotImplementedError 70 | end 71 | 72 | def close 73 | raise NotImplementedError 74 | end 75 | 76 | def closed? 77 | raise NotImplementedError 78 | end 79 | 80 | private 81 | 82 | def connect_socket 83 | raise NotImplementedError 84 | end 85 | 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /bin/jrpc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # coding: utf-8 3 | 4 | require 'optparse' 5 | require 'jrpc' 6 | 7 | Options = Struct.new( 8 | :host, 9 | :port, 10 | :type, 11 | :method, 12 | :params, 13 | :id, 14 | :debug, 15 | :namespace, 16 | :timeout 17 | ) 18 | 19 | class Parser 20 | def self.parse(argv) 21 | args = Options.new 22 | args.host = '127.0.0.1' 23 | args.port = 7080 24 | args.type = 'request' 25 | args.timeout = 5 26 | 27 | opt_parser = OptionParser.new do |opts| 28 | opts.banner = 'Usage: jrpc [options] method [params, ...]' 29 | 30 | opts.on('--host=HOST', 'host (default 127.0.0.1)') do |host| 31 | args.host = host 32 | end 33 | 34 | opts.on('-p=PORT', '--port=PORT', 'port (default 7080)') do |port| 35 | args.port = port 36 | end 37 | 38 | opts.on('-r', '--request', 'Sets type to request (default true)') do 39 | args.type = 'request' 40 | end 41 | 42 | opts.on('-n', '--notification', 'Sets type to is notification (default false)') do 43 | args.type = 'notification' 44 | end 45 | 46 | opts.on('--namespace=NAMESPACE', 'Sets method namespace') do |namespace| 47 | args.namespace = namespace 48 | end 49 | 50 | opts.on('--id=ID', 'Request ID (will be generated randomly by default)') do |id| 51 | args.id = id 52 | end 53 | 54 | opts.on('--timeout=TIMEOUT', 'timeout for socket') do |timeout| 55 | args.timeout = timeout 56 | end 57 | 58 | opts.on('-d', '--debug', 'Debug output') do 59 | args.debug = true 60 | end 61 | 62 | opts.on('-h', '--help', 'Prints this help and exit') do 63 | puts opts 64 | exit 65 | end 66 | 67 | opts.on('-v', '--version', 'Prints version and exit') do 68 | puts "JRPC version: #{JRPC::VERSION}" 69 | exit 70 | end 71 | end 72 | 73 | opt_parser.parse!(argv) 74 | args.method = argv.first 75 | args.params = argv[1..-1] 76 | # puts "PARSED:\n#{args.inspect}\n#{argv.inspect}" 77 | return args 78 | end 79 | end 80 | 81 | options = Parser.parse(ARGV.dup) 82 | 83 | logger = Logger.new($stdout) 84 | logger.level = options.debug ? Logger::DEBUG : Logger::INFO 85 | addr = "#{options.host}:#{options.port}" 86 | logger.debug { "Connecting to #{addr} ..." } 87 | client = JRPC::TcpClient.new(addr, namespace: options.namespace, timeout: options.timeout, logger: logger) 88 | 89 | logger.debug { "Sending #{options.type} #{options.method} #{options.params} ..." } 90 | response = client.perform_request(options.method, params: options.params, type: options.type.to_sym) 91 | 92 | if options.type == 'request' 93 | logger.debug { "Request was sent. Response: #{response.inspect}" } 94 | puts JSON.pretty_generate(response) 95 | else 96 | logger.debug 'Notification was sent.' 97 | end 98 | 99 | client.close 100 | logger.debug 'Exited' 101 | -------------------------------------------------------------------------------- /bin/jrpc-shell: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # coding: utf-8 3 | 4 | require 'readline' 5 | require 'singleton' 6 | require 'jrpc' 7 | 8 | class Command 9 | include Singleton 10 | 11 | def self.call(command, args) 12 | meth = "cmd_#{command}" 13 | if instance.respond_to?(meth) 14 | instance.public_send(meth, *args) 15 | else 16 | "ERROR: invalid command #{command.inspect}\n#{instance.help_usage}" 17 | end 18 | rescue ArgumentError => e 19 | "ERROR: ArgumentError #{e.message}\n#{instance.help_usage}" 20 | end 21 | 22 | attr_accessor :logger, :client, :help_usage 23 | instance.logger = Logger.new(STDOUT) 24 | instance.logger.level = Logger::INFO 25 | instance.help_usage = [ 26 | 'Usage:', 27 | ' connect host port', 28 | ' disconnect', 29 | ' request method param1 param2', 30 | ' request method {"param1": 1. "param2": 2}', 31 | ' notification method param1 param2', 32 | ' notification method {"param1": 1. "param2": 2}', 33 | ' help', 34 | ' version' 35 | ].join("\n") 36 | 37 | def cmd_help 38 | help_usage 39 | end 40 | 41 | def cmd_version 42 | "JRPC version: #{JRPC::VERSION}" 43 | end 44 | 45 | def cmd_connect(host, port) 46 | client&.close 47 | self.client = JRPC::TcpClient.new("#{host}:#{port}", namespace: '', timeout: 5, logger: logger) 48 | 'Connected.' 49 | rescue JRPC::Error => e 50 | "ERROR: JRPC #{e.message}\n#{help_usage}" 51 | end 52 | 53 | def cmd_disconnect 54 | return "ERROR: Not connected\n#{help_usage}" if client.nil? 55 | 56 | client.close 57 | self.client = nil 58 | 'Disconnected' 59 | end 60 | 61 | def cmd_request(method, *params) 62 | return "ERROR: Not connected\n#{help_usage}" if client.nil? 63 | 64 | params = JSON.parse(params.first) if params.size == 1 && params[0] == '{' 65 | 66 | response = client.perform_request(method, params: params) 67 | JSON.pretty_generate(response) 68 | rescue JRPC::Error => e 69 | "ERROR: JRPC #{e.message}\n#{help_usage}" 70 | end 71 | 72 | def cmd_notification(method, *params) 73 | return "ERROR: Not connected\n#{help_usage}" if client.nil? 74 | 75 | params = JSON.parse(params.first) if params.size == 1 && params[0] == '{' 76 | 77 | response = client.perform_request(method, params: params, type: :notification) 78 | JSON.pretty_generate(response) 79 | rescue JRPC::Error => e 80 | "ERROR: JRPC #{e.message}\n#{help_usage}" 81 | end 82 | end 83 | 84 | puts 'Welcome to JRPC shell' 85 | while input = Readline.readline('> ', true) 86 | if %w[exit close quit].include?(input) 87 | break 88 | elsif input == 'hist' 89 | puts Readline::HISTORY.to_a 90 | elsif input == '' 91 | # Remove blank lines from history 92 | Readline::HISTORY.pop 93 | else 94 | command, *args = input.split(' ') 95 | puts Command.call(command, args) 96 | end 97 | end 98 | 99 | puts 'Shell Exited' 100 | -------------------------------------------------------------------------------- /lib/jrpc/base_client.rb: -------------------------------------------------------------------------------- 1 | require 'oj' 2 | require 'forwardable' 3 | module JRPC 4 | class BaseClient 5 | extend Forwardable 6 | 7 | attr_reader :uri, :options 8 | 9 | ID_CHARACTERS = (('a'..'z').to_a + ('0'..'9').to_a + ('A'..'Z').to_a).freeze 10 | REQUEST_TYPES = [:request, :notification].freeze 11 | 12 | def self.connect(uri, options) 13 | client = new(uri, options) 14 | yield(client) 15 | ensure 16 | client.close if client 17 | end 18 | 19 | def initialize(uri, options) 20 | @uri = uri 21 | @options = options 22 | end 23 | 24 | def method_missing(method, *params) 25 | invoke_request(method, *params) 26 | end 27 | 28 | def perform_request(method, params: nil, type: :request, read_timeout: nil, write_timeout: nil) 29 | validate_request(params, type) 30 | request = create_message(method.to_s, params) 31 | if type == :request 32 | id = generate_id 33 | request['id'] = id 34 | response = send_command serialize_request(request), read_timeout: read_timeout, write_timeout: write_timeout 35 | response = deserialize_response(response) 36 | 37 | validate_response(response, id) 38 | parse_error(response['error']) if response.has_key?('error') 39 | 40 | response['result'] 41 | else 42 | send_notification serialize_request(request), write_timeout: write_timeout 43 | nil 44 | end 45 | end 46 | 47 | def invoke_request(method, *params) 48 | warn '[DEPRECATION] `invoke_request` is deprecated. Please use `perform_request` instead.' 49 | params = nil if params.empty? 50 | perform_request(method, params: params) 51 | end 52 | 53 | def invoke_notification(method, *params) 54 | warn '[DEPRECATION] `invoke_request` is deprecated. Please use `perform_request` instead.' 55 | params = nil if params.empty? 56 | perform_request(method, params: params, type: :notification) 57 | end 58 | 59 | private 60 | 61 | def serialize_request(request) 62 | Oj.dump(request, mode: :compat) 63 | end 64 | 65 | def deserialize_response(response) 66 | Oj.load(response) 67 | end 68 | 69 | def validate_response(response, id) 70 | raise ClientError, 'Wrong response structure' unless response.is_a?(Hash) 71 | raise ClientError, 'Wrong version' if response['jsonrpc'] != JRPC::JSON_RPC_VERSION 72 | if id != response['id'] 73 | raise ClientError, "ID response mismatch. expected #{id.inspect} got #{response['id'].inspect}" 74 | end 75 | end 76 | 77 | def validate_request(params, type) 78 | raise ClientError, 'invalid type' unless REQUEST_TYPES.include?(type) 79 | raise ClientError, 'invalid params' if !params.nil? && !params.is_a?(Array) && !params.is_a?(Hash) 80 | end 81 | 82 | def parse_error(error) 83 | case error['code'] 84 | when -32700 85 | raise ParseError.new(error['message']) 86 | when -32600 87 | raise InvalidRequest.new(error['message']) 88 | when -32601 89 | raise MethodNotFound.new(error['message']) 90 | when -32602 91 | raise InvalidParams.new(error['message']) 92 | when -32603 93 | raise InternalError.new(error['message']) 94 | when -32099..-32000 95 | raise InternalServerError.new(error['message'], error['code']) 96 | else 97 | raise UnknownError.new(error['message'], error['code']) 98 | end 99 | end 100 | 101 | def send_command(json, options={}) 102 | raise NotImplementedError 103 | end 104 | 105 | def send_notification(json, options={}) 106 | raise NotImplementedError 107 | end 108 | 109 | def generate_id 110 | size = ID_CHARACTERS.size 111 | (0...32).map { ID_CHARACTERS.to_a[rand(size)] }.join 112 | end 113 | 114 | def create_message(method, params) 115 | message = { 116 | 'jsonrpc' => JSON_RPC_VERSION, 117 | 'method' => method 118 | } 119 | message['params'] = params unless params.nil? 120 | message 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /lib/jrpc/tcp_client.rb: -------------------------------------------------------------------------------- 1 | require 'netstring' 2 | require 'logger' 3 | require 'benchmark' 4 | module JRPC 5 | class TcpClient < BaseClient 6 | attr_reader :namespace, :transport 7 | attr_accessor :logger 8 | def_delegators :@transport, :close, :closed?, :connect 9 | 10 | MAX_LOGGED_MESSAGE_LENGTH = 255 11 | 12 | def initialize(uri, options = {}) 13 | super 14 | @logger = @options.delete(:logger) || Logger.new($null) 15 | @namespace = @options.delete(:namespace).to_s 16 | 17 | timeout = @options.fetch(:timeout, 5) 18 | connect_timeout = @options.fetch(:connect_timeout, timeout) 19 | read_timeout = @options.fetch(:read_timeout, timeout) 20 | write_timeout = @options.fetch(:write_timeout, 60) # default 60 21 | connect_retry_count = @options.fetch(:connect_retry_count, 10) # default 10 22 | @close_after_sent = @options.fetch(:close_after_sent, false) 23 | 24 | @transport = JRPC::Transport::SocketTcp.new server: @uri, 25 | connect_retry_count: connect_retry_count, 26 | connect_timeout: connect_timeout, 27 | read_timeout: read_timeout, 28 | write_timeout: write_timeout 29 | connect_transport! 30 | end 31 | 32 | private 33 | 34 | def connect_transport! 35 | @transport.connect 36 | rescue JRPC::Transport::SocketTcp::Error 37 | raise ConnectionError, "Can't connect to #{@uri}" 38 | end 39 | 40 | def ensure_connected 41 | if @transport.closed? 42 | logger.debug { 'Connecting transport...' } 43 | connect_transport! 44 | logger.debug { 'Connected.' } 45 | end 46 | end 47 | 48 | def send_command(request, options = {}) 49 | ensure_connected 50 | read_timeout = options.fetch(:read_timeout) 51 | write_timeout = options.fetch(:write_timeout) 52 | response = nil 53 | t = Benchmark.realtime do 54 | logger.debug { "Request address: #{uri}" } 55 | logger.debug { "Request message: #{Utils.truncate(request, MAX_LOGGED_MESSAGE_LENGTH)}" } 56 | logger.debug { "Request read_timeout: #{read_timeout}" } 57 | logger.debug { "Request write_timeout: #{write_timeout}" } 58 | send_request(request, write_timeout) 59 | response = receive_response(read_timeout) 60 | end 61 | logger.debug do 62 | "(#{'%.2f' % (t * 1000)}ms) Response message: #{Utils.truncate(response, MAX_LOGGED_MESSAGE_LENGTH)}" 63 | end 64 | response 65 | ensure 66 | @transport.close if @close_after_sent 67 | end 68 | 69 | def send_notification(request, options = {}) 70 | ensure_connected 71 | write_timeout = options.fetch(:write_timeout) 72 | logger.debug { "Request address: #{uri}" } 73 | logger.debug { "Request message: #{Utils.truncate(request, MAX_LOGGED_MESSAGE_LENGTH)}" } 74 | logger.debug { "Request write_timeout: #{write_timeout}" } 75 | send_request(request, write_timeout) 76 | logger.debug { 'No response required' } 77 | ensure 78 | @transport.close if @close_after_sent 79 | end 80 | 81 | def create_message(method, params) 82 | super("#{namespace}#{method}", params) 83 | end 84 | 85 | def send_request(request, timeout) 86 | timeout ||= @transport.write_timeout 87 | @transport.write Netstring.dump(request.to_s), timeout 88 | rescue ::SocketError 89 | raise ConnectionError, "Can't send request to #{uri}" 90 | rescue JRPC::ConnectionClosedError 91 | raise ConnectionError, "Connection to #{uri} was closed unexpectedly" 92 | end 93 | 94 | def receive_response(timeout) 95 | timeout ||= @transport.read_timeout 96 | length = get_msg_length(timeout) 97 | response = @transport.read(length + 1, timeout) 98 | raise ClientError.new('invalid response. missed comma as terminator') if response[-1] != ',' 99 | response.chomp(',') 100 | rescue ::SocketError 101 | raise ConnectionError, "Can't receive response from #{uri}" 102 | rescue JRPC::ConnectionClosedError 103 | raise ConnectionError, "Connection to #{uri} was closed unexpectedly" 104 | end 105 | 106 | def get_msg_length(timeout) 107 | length = '' 108 | while true do 109 | character = @transport.read(1, timeout) 110 | break if character == ':' 111 | length += character 112 | end 113 | 114 | Integer(length) 115 | end 116 | 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/jrpc/transport/socket_tcp.rb: -------------------------------------------------------------------------------- 1 | module JRPC 2 | module Transport 3 | class SocketTcp < SocketBase 4 | 5 | # @raise [JRPC::ConnectionClosedError] if socket was closed during data read. 6 | def read(length, timeout = @read_timeout) 7 | received = '' 8 | length_to_read = length 9 | while length_to_read > 0 10 | io_read, = IO.select([socket], [], [], timeout) 11 | raise ReadTimeoutError unless io_read 12 | check_socket_state! 13 | chunk = io_read[0].read_nonblock(length_to_read) 14 | received += chunk 15 | length_to_read -= chunk.bytesize 16 | end 17 | received 18 | rescue Errno::EPIPE, EOFError => e 19 | # EPIPE, in this case, means that the data connection was unexpectedly terminated. 20 | close 21 | raise ReadFailedError, "#{e.class} #{e.message}" 22 | rescue => e 23 | close 24 | raise e 25 | end 26 | 27 | # @raise [JRPC::ConnectionClosedError] if socket was closed during data write. 28 | def write(data, timeout = @write_timeout) 29 | length_written = 0 30 | data_to_write = data 31 | while data_to_write.bytesize > 0 32 | _, io_write, = IO.select([], [socket], [], timeout) 33 | raise WriteTimeoutError unless io_write 34 | check_socket_state! 35 | chunk_length = io_write[0].write_nonblock(data_to_write) 36 | length_written += chunk_length 37 | data_to_write = data.byteslice(length_written, data.length) 38 | end 39 | length_written 40 | rescue Errno::EPIPE => e 41 | # EPIPE, in this case, means that the data connection was unexpectedly terminated. 42 | close 43 | raise WriteFailedError, "#{e.class} #{e.message}" 44 | rescue => e 45 | close 46 | raise e 47 | end 48 | 49 | # Socket implementation allows client to send data to server after FIN event, 50 | # but server will never receive this data. 51 | # So we consider socket closed when it have FIN event 52 | # and close it correctly from client side. 53 | def closed? 54 | return true if @socket.nil? 55 | 56 | if socket.closed? || fin_signal? 57 | close 58 | return true 59 | end 60 | 61 | false 62 | end 63 | 64 | # @raise [JRPC::ConnectionClosedError] if socket is closed or FIN event received. 65 | def check_socket_state! 66 | raise JRPC::ConnectionClosedError if closed? 67 | end 68 | 69 | def socket 70 | @socket ||= build_socket 71 | end 72 | 73 | # When socket is closed we need to cleanup internal @socket object, 74 | # because we will receive "IOError closed stream" if we try to reconnect via same socket. 75 | def close 76 | return if @socket.nil? 77 | @socket.close unless @socket.closed? 78 | @socket = nil 79 | end 80 | 81 | private 82 | 83 | # when recv_nonblock(1) responds with empty string means that FIN event was received. 84 | # in other cases it will return 1 byte string or raise EAGAINWaitReadable. 85 | # MSG_PEEK means we do not move pointer when reading data. 86 | # see https://apidock.com/ruby/BasicSocket/recv_nonblock 87 | def fin_signal? 88 | begin 89 | resp = socket.recv_nonblock(1, Socket::MSG_PEEK) 90 | rescue IO::EAGAINWaitReadable => _ 91 | resp = nil 92 | end 93 | resp == '' 94 | end 95 | 96 | def set_timeout_to(socket, type, value) 97 | secs = Integer(value) 98 | u_secs = Integer((value - secs) * 1_000_000) 99 | opt_val = [secs, u_secs].pack('l_2') 100 | socket.setsockopt Socket::SOL_SOCKET, type, opt_val 101 | end 102 | 103 | def build_socket 104 | host = @server.split(':').first 105 | addr = Socket.getaddrinfo(host, nil) 106 | Socket.new(Socket.const_get(addr[0][0]), Socket::SOCK_STREAM, 0) 107 | end 108 | 109 | def connect_socket 110 | host, port = @server.split(':') 111 | addr = Socket.getaddrinfo(host, nil) 112 | full_addr = Socket.pack_sockaddr_in(port, addr[0][3]) 113 | 114 | begin 115 | socket.connect_nonblock(full_addr) 116 | rescue IO::WaitWritable => _ 117 | if IO.select(nil, [socket], nil, @connect_timeout) 118 | socket.connect_nonblock(full_addr) 119 | else 120 | close 121 | raise ConnectionFailedError, "Can't connect during #{@connect_timeout}" 122 | end 123 | end 124 | 125 | rescue Errno::EISCONN => _ 126 | # already connected 127 | rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::EPIPE => e 128 | close 129 | raise ConnectionFailedError, "#{e.class} #{e.message}" 130 | rescue => e 131 | close 132 | raise e 133 | end 134 | 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/tcp_client_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'logger' 3 | require 'fake_transport' 4 | 5 | describe JRPC::TcpClient do 6 | 7 | let(:socket_stub) { FakeTransport.new(read_timeout: 30, write_timeout: 60) } 8 | 9 | shared_examples :sends_request_and_receive_response do |options = {}| 10 | method = options.fetch(:method) 11 | without_params = options.fetch(:without_params, false) 12 | result = options.fetch(:result, 1) 13 | 14 | if without_params 15 | params = nil 16 | else 17 | params = options.fetch(:params) 18 | end 19 | 20 | let(:expected_request) do 21 | if without_params 22 | { 23 | jsonrpc: JRPC::JSON_RPC_VERSION, 24 | method: method, 25 | id: stubbed_generated_id 26 | } 27 | else 28 | { 29 | jsonrpc: JRPC::JSON_RPC_VERSION, 30 | method: method, 31 | params: params, 32 | id: stubbed_generated_id 33 | } 34 | end 35 | end 36 | 37 | let(:expected_result) do 38 | { 39 | jsonrpc: JRPC::JSON_RPC_VERSION, 40 | result: result, 41 | id: stubbed_generated_id 42 | } 43 | end 44 | 45 | it "sends request #{method} #{without_params ? 'without params' : params.inspect} and receives #{result.inspect}" do 46 | json_request = expected_request.to_json 47 | raw_expected_request = "#{json_request.size}:#{json_request}," 48 | expect(socket_stub).to receive(:write).with(raw_expected_request, 60).once 49 | 50 | json_result = expected_result.to_json 51 | socket_stub.response = json_result 52 | expect(socket_stub).to receive(:read).with(1, 30).exactly(json_result.size.to_s.size).times. 53 | and_return( 54 | *(json_result.size.to_s.split('') + [':']) 55 | ) 56 | expect(socket_stub).to receive(:read).with(json_result.size + 1, 30).and_return(json_result + ',').and_call_original 57 | 58 | expect(subject).to eq JSON.parse(json_result)['result'] 59 | end 60 | end 61 | 62 | shared_examples :sends_notification do |options = {}| 63 | method = options.fetch(:method) 64 | without_params = options.fetch(:without_params, false) 65 | 66 | if without_params 67 | params = nil 68 | else 69 | params = options.fetch(:params) 70 | end 71 | 72 | let(:expected_request) do 73 | if without_params 74 | { 75 | jsonrpc: JRPC::JSON_RPC_VERSION, 76 | method: method 77 | } 78 | else 79 | { 80 | jsonrpc: JRPC::JSON_RPC_VERSION, 81 | method: method, 82 | params: params 83 | } 84 | end 85 | end 86 | 87 | it "sends notification #{method} #{without_params ? 'without params' : params.inspect}" do 88 | json_request = expected_request.to_json 89 | raw_expected_request = "#{json_request.size}:#{json_request}," 90 | expect(socket_stub).to receive(:write).with(raw_expected_request, 60).once 91 | 92 | expect(socket_stub).to_not receive(:read) 93 | 94 | expect(subject).to be_nil 95 | end 96 | end 97 | 98 | shared_examples :raises_client_error do |msg| 99 | it "raises ClientError with #{msg.inspect}" do 100 | expect { subject }.to raise_error(JRPC::ClientError, msg) 101 | end 102 | end 103 | 104 | describe '#invoke_request' do 105 | subject do 106 | client.invoke_request(invoke_request_method, *invoke_request_params) 107 | end 108 | 109 | let(:client) { JRPC::TcpClient.new('127.0.0.1:1234', client_options) } 110 | let(:client_options) { {} } 111 | let(:invoke_request_method) { 'sum' } 112 | let(:invoke_request_params) { [1, 2] } 113 | 114 | before do 115 | allow(JRPC::Transport::SocketTcp).to receive(:new).with(any_args).once.and_return(socket_stub) 116 | end 117 | 118 | it 'calls perform_request("sum", params: [1, 2])' do 119 | expect(client).to receive(:perform_request).with( 120 | invoke_request_method, 121 | params: invoke_request_params 122 | ).once.and_return(1) 123 | expect(subject).to eq(1) 124 | end 125 | 126 | context 'without params' do 127 | let(:invoke_request_params) { [] } 128 | 129 | it 'calls perform_request("sum", params: nil)' do 130 | expect(client).to receive(:perform_request).with( 131 | invoke_request_method, 132 | params: nil 133 | ).once.and_return(1) 134 | expect(subject).to eq(1) 135 | end 136 | end 137 | 138 | end # invoke_request 139 | 140 | describe '#invoke_notification' do 141 | subject do 142 | client.invoke_notification(invoke_notification_method, *invoke_notification_params) 143 | end 144 | 145 | let(:client) { JRPC::TcpClient.new('127.0.0.1:1234', client_options) } 146 | let(:client_options) { {} } 147 | let(:invoke_notification_method) { 'sum' } 148 | let(:invoke_notification_params) { [1, 2] } 149 | 150 | before do 151 | allow(JRPC::Transport::SocketTcp).to receive(:new).with(any_args).once.and_return(socket_stub) 152 | end 153 | 154 | it 'calls perform_request("sum", params: [1, 2], type: :notification)' do 155 | expect(client).to receive(:perform_request).with( 156 | invoke_notification_method, 157 | params: invoke_notification_params, 158 | type: :notification 159 | ).once.and_return(nil) 160 | expect(subject).to eq(nil) 161 | end 162 | 163 | context 'without params' do 164 | let(:invoke_notification_params) { [] } 165 | 166 | it 'calls perform_request("sum", params: nil, type: :notification)' do 167 | expect(client).to receive(:perform_request).with( 168 | invoke_notification_method, 169 | params: nil, 170 | type: :notification 171 | ).once.and_return(1) 172 | expect(subject).to eq(1) 173 | end 174 | end 175 | 176 | end # invoke_notification 177 | 178 | describe '#perform_request' do 179 | subject do 180 | client.perform_request(perform_request_method, perform_request_options) 181 | end 182 | 183 | let(:client) { JRPC::TcpClient.new('127.0.0.1:1234', client_options) } 184 | let(:client_options) { {} } 185 | let(:stubbed_generated_id) { 'rspec-generated-id' } 186 | 187 | before do 188 | allow_any_instance_of(JRPC::TcpClient).to receive(:generate_id).with(no_args).and_return(stubbed_generated_id) 189 | allow(JRPC::Transport::SocketTcp).to receive(:new).with(any_args).once.and_return(socket_stub) 190 | allow(socket_stub).to receive(:closed?).and_return(false) 191 | end 192 | 193 | context 'with array params' do 194 | let(:perform_request_method) { 'trigger' } 195 | let(:perform_request_options) { { params: [1, 2] } } 196 | 197 | include_examples :sends_request_and_receive_response, 198 | method: 'trigger', 199 | params: [1, 2] 200 | 201 | context 'params are empty' do 202 | let(:perform_request_options) { { params: [] } } 203 | 204 | include_examples :sends_request_and_receive_response, 205 | method: 'trigger', 206 | params: [] 207 | end 208 | 209 | context 'type notification' do 210 | let(:perform_request_options) { super().merge type: :notification } 211 | 212 | include_examples :sends_notification, 213 | method: 'trigger', 214 | params: [1, 2] 215 | end 216 | 217 | end 218 | 219 | context 'with object params' do 220 | let(:perform_request_method) { 'trigger' } 221 | let(:perform_request_options) { { params: { src: 1, dst: 2 } } } 222 | 223 | include_examples :sends_request_and_receive_response, 224 | method: 'trigger', 225 | params: { src: 1, dst: 2 } 226 | 227 | context 'params is an empty object' do 228 | let(:perform_request_options) { { params: {} } } 229 | 230 | include_examples :sends_request_and_receive_response, 231 | method: 'trigger', 232 | params: {} 233 | end 234 | 235 | context 'type notification' do 236 | let(:perform_request_options) { super().merge type: :notification } 237 | 238 | include_examples :sends_notification, 239 | method: 'trigger', 240 | params: { src: 1, dst: 2 } 241 | end 242 | 243 | end 244 | 245 | context 'without params' do 246 | let(:perform_request_method) { 'ping' } 247 | let(:perform_request_options) { {} } 248 | 249 | include_examples :sends_request_and_receive_response, 250 | method: 'ping', 251 | without_params: true 252 | 253 | context 'type notification' do 254 | let(:perform_request_options) { super().merge type: :notification } 255 | 256 | include_examples :sends_notification, 257 | method: 'ping', 258 | without_params: true 259 | end 260 | 261 | end 262 | 263 | context 'when params is not a hash and not and array' do 264 | let(:perform_request_method) { 'trigger' } 265 | let(:perform_request_options) { { params: 1 } } 266 | 267 | include_examples :raises_client_error, 'invalid params' 268 | end 269 | 270 | context 'with wrong type' do 271 | let(:perform_request_method) { 'trigger' } 272 | let(:perform_request_options) { { params: [1, 2], type: :test } } 273 | 274 | include_examples :raises_client_error, 'invalid type' 275 | end 276 | 277 | end # perform_request 278 | 279 | end 280 | --------------------------------------------------------------------------------