servicy 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/service.rb ADDED
@@ -0,0 +1,200 @@
1
+ require 'timeout'
2
+
3
+ module Servicy
4
+ class Service
5
+ include Comparable
6
+ attr_reader :name, :version, :host, :port, :heartbeat_port, :protocol,
7
+ :api, :heartbeat_check_rate, :latencies, :heartbeats
8
+ attr_accessor :heartbeat_last_check
9
+
10
+ NAME_REGEX = /[^\.]+\.([^\.]+\.?)+/
11
+ VERSION_REGEX = /\d+\.\d+\.\d+(-p?\d+)?/
12
+
13
+ # Create a new service.
14
+ # (see Servicy::Server#register)
15
+ def initialize(args={})
16
+ args = args.inject({}) { |h,(k,v)| h[k.to_sym] = v; h }
17
+ raise ArgumentError.new("Must provide a service name") unless args[:name]
18
+ raise ArgumentError.new("Must provide a service host") unless args[:host]
19
+ raise ArgumentError.new("Must provide a service port") unless args[:port]
20
+
21
+ unless args[:name] =~ NAME_REGEX
22
+ raise ArgumentError.new("Service name must be in inverse-domain format")
23
+ end
24
+
25
+ args[:version] ||= '1.0.0'
26
+ unless args[:version] =~ VERSION_REGEX
27
+ raise ArgumentError.new("Service version must be in formation M.m.r(-p)?")
28
+ end
29
+
30
+ if !args[:port].is_a?(Fixnum) || args[:port] < 1 || args[:port] > 65535
31
+ raise ArgumentError.new("Service port must be an integer betwee 1-65535")
32
+ end
33
+
34
+ args[:heartbeat_port] ||= args[:port]
35
+ if !args[:heartbeat_port].is_a?(Fixnum) || args[:heartbeat_port] < 1 || args[:heartbeat_port] > 65535
36
+ raise ArgumentError.new("Service heartbeat port must be an integer betwee 1-65535")
37
+ end
38
+
39
+ if args[:api]
40
+ raise ArgumentError.new("Service api not correctly defiend") unless check_api(args[:api])
41
+ end
42
+
43
+ args[:protocol] ||= 'HTTP/S'
44
+
45
+ @name = args[:name]
46
+ @version = args[:version]
47
+ @host = args[:host]
48
+ @protocol = args[:protocol]
49
+ @port = args[:port]
50
+ @heartbeat_port = args[:heartbeat_port]
51
+ @api = args[:api] || {}
52
+ @heartbeat_check_rate = args[:heartbeat_check_rate] || 1
53
+ @heartbeat_last_check = 0
54
+ @latencies = []
55
+ @heartbeats = []
56
+ end
57
+
58
+ # Get a configuration value
59
+ # This method exists mostly so that I wouldn't have to change a bunch of
60
+ # early tests, and because I know some people like to access things like a
61
+ # hash.
62
+ # @param[Symbol] thing The config value to get.
63
+ # @return [Object,nil] The value if found, nil otherwise.
64
+ def [](thing)
65
+ return self.send(thing) if self.respond_to?(thing)
66
+ nil
67
+ end
68
+
69
+ # Build a hash of the configuration values for this object.
70
+ # Used to dump json configurations.
71
+ # @return [Hash] Configuration data for the Service
72
+ def as_json
73
+ {
74
+ name: name,
75
+ version: version,
76
+ host: host,
77
+ protocol: protocol,
78
+ port: port,
79
+ heartbeat_port: heartbeat_port,
80
+ api: api
81
+ }
82
+ end
83
+
84
+ # Used by the Comparable mixin for comparing two services.
85
+ # (see Comparable)
86
+ def <=>(other)
87
+ self.as_json <=> other.as_json
88
+ end
89
+
90
+ # Returns the version of the current service as a number that we can use
91
+ # for comparisons to other version numbers.
92
+ # @return [Integer] A number representing the version
93
+ def version_as_number
94
+ self.class.version_as_number(version)
95
+ end
96
+
97
+ # (see #version_as_number)
98
+ # @param [String] A version string as per {Servicy::Server#register}
99
+ def self.version_as_number(version)
100
+ parts = version.split('.')
101
+ parts.last.gsub(/\D/, '')
102
+ parts[0].to_i * 1000 + parts[1].to_i * 100 + parts[2].to_i * 10 + (parts[3] || 0)
103
+ end
104
+
105
+ # Check weather or not a service is up, based on connecting to the hearbeat
106
+ # port, and reading a single byte.
107
+ def up?
108
+ t1 = Time.now
109
+ s = TCPSocket.new(host, heartbeat_port)
110
+ Timeout.timeout(5) do
111
+ s.recvfrom(1)
112
+ end
113
+ record_heartbeat(1)
114
+ return true
115
+ rescue
116
+ record_heartbeat(0)
117
+ return false
118
+ ensure
119
+ s.close rescue nil
120
+ t2 = Time.now
121
+ record_latency(t1, t2)
122
+ end
123
+
124
+ # Returns what the avg latency for a service is, based on the timings of
125
+ # their heartbeat connections.
126
+ # @return [Float] avg latency is ms
127
+ def avg_latency
128
+ latencies.reduce(0.0, &:+) / latencies.length.to_f
129
+ end
130
+
131
+ # Returns the uptime for a service based on heartbeat checks as a float
132
+ # between 0 and 1 -- a percentage.
133
+ # @return [Float] avg uptime as a percentage (0..1)
134
+ def uptime
135
+ heartbeats.reduce(0, &:+) / heartbeats.length.to_f
136
+ end
137
+
138
+ # Get a nice, printable name
139
+ # return [String]
140
+ def to_s
141
+ name + "#" + host
142
+ end
143
+
144
+ # Returns a hash with the configuration options needed for registration and
145
+ # remote service operation.
146
+ def to_h
147
+ {
148
+ name: name,
149
+ version: version,
150
+ host: host,
151
+ port: port,
152
+ heartbeat_port: heartbeat_port,
153
+ protocol: protocol,
154
+ api: api
155
+ }
156
+ end
157
+
158
+ # Return the api for a given method (instance, or class) or nil if not
159
+ # found.
160
+ def api_for(method_type, method)
161
+ api[method_type] && api[method_type].select { |a| a[:name] == method }.first
162
+ end
163
+
164
+ private
165
+
166
+ # These are just to keep us from filling up memory.
167
+ def record_latency(t1, t2)
168
+ @latencies << t2 - t1
169
+ @latencies = @latencies[0...10] if @latencies.length > 10
170
+ end
171
+
172
+ def record_heartbeat(h)
173
+ @heartbeats << h
174
+ @heartbeats = @heartbeats[0...10] if @heartbeats.length > 10
175
+ end
176
+
177
+ # The api is broken into two kinds of methods; instance and class. Each can
178
+ # define method name, argument number and types, and return types.
179
+ def check_api(api_def)
180
+ raise ArgumentError.new("API can only define instance and class methods") unless api_def.contains_only?(:instance, :class)
181
+
182
+ api_def.each do |(_, methods)|
183
+ # Each method is itself a hash of name, args, return type, and docs.
184
+ methods.each do |method|
185
+ raise ArgumentError.new("Methods can only contain name, args, return, and docs") unless method.contains_only?(:name, :args, :return, :docs)
186
+ raise ArgumentError.new("Methods must define a name") unless method.include? :name
187
+
188
+ method[:args].each do |arg|
189
+ # Each argument is an optional name, and optional type, and an
190
+ # option "required" flag.
191
+ raise ArgumentError.new("Arguments can only define name, type, and required") unless arg.contains_only?(:name, :type, :required)
192
+ end
193
+
194
+ return false if method[:return] && !method[:return].contains_only?(:type)
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+
data/lib/servicy.rb ADDED
@@ -0,0 +1,9 @@
1
+ $: << File.expand_path(File.join(File.dirname(__FILE__)))
2
+
3
+ # NOTE: You will almost never need both of these things. In all likeliness, you
4
+ # will want to do something like `require 'servicy/client'` yourself. This is
5
+ # here mostly as a convinience to rspec.
6
+ require 'hash'
7
+ require 'server'
8
+ require 'client'
9
+ require 'api'
@@ -0,0 +1,44 @@
1
+ require 'transports'
2
+ require 'socket'
3
+ require 'tempfile'
4
+
5
+ module Servicy
6
+ # This is a simple transport implementation that is to be used primarily for
7
+ # testing, so that I don't have to have a bunch of servers running just to
8
+ # run rspec...
9
+ class InMemoryTransport < Servicy::Transport
10
+ def send(message)
11
+ socket = UNIXSocket.new socket_path
12
+ socket.puts message.body
13
+ message = Message.new(socket.gets)
14
+ socket.close
15
+ message
16
+ end
17
+
18
+ def start
19
+ @server = UNIXServer.open(socket_path)
20
+ while s = @server.accept
21
+ Thread.new do
22
+ message = Message.new s.gets
23
+ result = yield message
24
+ s.puts result.body
25
+ s.close
26
+ end
27
+ end
28
+ end
29
+
30
+ def stop
31
+ @server && @server.close
32
+ end
33
+
34
+ def socket_path
35
+ @socket_path ||= begin
36
+ file = Tempfile.new(@config[:path] || 'servicy-in-memory-transport')
37
+ file.close
38
+ path = file.path
39
+ file.delete
40
+ path
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,141 @@
1
+ module Servicy
2
+ class Transport
3
+ # The Message class contains messages passed between client and server, as
4
+ # well as utility methods for making messages to be sent.
5
+ class Message
6
+ include Comparable
7
+
8
+ attr_reader :struct
9
+
10
+ # Create a new message that will be sent over some transport.
11
+ # @param [Hash] stuff A hash that will constitute the message. You should
12
+ # likely never call this directy (i.e. Message.new), and instead use the
13
+ # class methods {#registration}, {#query}, etc.
14
+ def initialize(stuff)
15
+ if stuff.is_a?(Hash)
16
+ @struct = stuff
17
+ else
18
+ @struct = JSON.parse(stuff)
19
+ end
20
+ end
21
+
22
+ def method_missing(name, *args, &block)
23
+ return struct[name] if struct.keys.include?(name)
24
+ return struct[name.to_s] if struct.keys.include?(name.to_s)
25
+ super
26
+ end
27
+
28
+ # The body of the message for cases where you send the message over the
29
+ # wire, or through a database.
30
+ # @return [String] A JSON representation of the message
31
+ def body
32
+ struct.to_json
33
+ end
34
+
35
+ # The response code.
36
+ # @return [Integer] 200 if successful, 404 if not-found, 500 if error.
37
+ def response_code
38
+ !!struct[:success] ? 200 : struct[:response_code]
39
+ end
40
+
41
+ # Create a service registration message
42
+ # @param [{Servicy::Service}] service The service that is being registered.
43
+ # @return [{Message}] A {Message} that can be sent through a
44
+ # {Servicy::Transport}
45
+ def self.registration(service)
46
+ new({
47
+ message: "registration",
48
+ service: service.as_json
49
+ })
50
+ end
51
+
52
+ # Create a service discovery message.
53
+ # @param [Hash] args A hash that defines the query. (see Servicy::Server#find)
54
+ # @return [{Message}] A {Message} that can be sent through a
55
+ # {Servicy::Transport}
56
+ def self.query(args, only_one=false)
57
+ raise ArgumentError.new("Invalid search query") unless query_valid?(args)
58
+ new({
59
+ message: "query",
60
+ query: args,
61
+ only_one: only_one
62
+ })
63
+ end
64
+
65
+ # Create a service discover message where I only want one provider.
66
+ # (see .query)
67
+ def self.query_one(args)
68
+ query(args, true)
69
+ end
70
+
71
+ # Create a services search response message
72
+ # @param [Array<{Servicy::Service}>] services The services that were
73
+ # found based on a {Servicy::Server} query
74
+ # @return [{Message}] A {Message} that can be sent through a
75
+ # {Servicy::Transport}
76
+ def self.services(services)
77
+ new({
78
+ success: true,
79
+ message: "services",
80
+ services: services.map { |s| s.as_json }
81
+ })
82
+ end
83
+
84
+ # Create an error response message
85
+ # @param [String] error_message The error message to report
86
+ # @param [Integer] response_code The response code for the error.
87
+ # Defaults to 500
88
+ # @return [{Message}] A {Message} that can be sent through a
89
+ # {Servicy::Transport}
90
+ def self.error(error_message, response_code=500)
91
+ new({
92
+ success: false,
93
+ error: error_message
94
+ })
95
+ end
96
+
97
+ # A utility method for 404 Not-Found error message
98
+ # (see .error)
99
+ def self.not_found
100
+ error "No results found", 404
101
+ end
102
+
103
+ # A generic success message
104
+ def self.success
105
+ new({
106
+ success: true
107
+ })
108
+ end
109
+
110
+ # A message sent to a server to gather statistics about the server, also
111
+ # used by the server to send the response back to the client.
112
+ # @param [Hash,nil] stats The stats to report, if any.
113
+ def self.statistics(stats=nil)
114
+ new({
115
+ message: "stats",
116
+ stats: stats
117
+ })
118
+ end
119
+
120
+ # Allow comparing of messages. This doesn't make for useful sorting, at
121
+ # the moment, but does make it easy to know if two things are saying the
122
+ # same thing.
123
+ def <=>(b)
124
+ self.struct == b.struct ? 0 : -1
125
+ end
126
+
127
+ private
128
+
129
+ def self.query_valid?(args)
130
+ return false if !args[:name] && !args[:api]
131
+ if args[:min_version] && args[:max_version]
132
+ min = Servicy::Service.version_as_number(args[:min_version])
133
+ max = Servicy::Service.version_as_number(args[:max_version])
134
+ return false if min > max
135
+ end
136
+ return false if args[:version] && (args[:min_version] || args[:max_version])
137
+ true
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,50 @@
1
+ require 'transports'
2
+ require 'socket'
3
+
4
+ module Servicy
5
+ # This is a transport that is almost an exact duplicate of the UNIXSocket
6
+ # transport, but can actually go over the wire. That whole, "Everything is a
7
+ # file" business works out well...
8
+ class TCPTransport < Servicy::Transport
9
+ HELLO = "Servicy says HI!"
10
+ def send(message)
11
+ socket = TCPSocket.new host, port
12
+ hello = socket.gets
13
+ raise "Not a servicy server" unless hello.strip == HELLO
14
+ socket.send message.body + "\n", 0
15
+ # FIXME: For some reason, this stalls when using the command-line client.
16
+ # Wireshark isn't helpful while I'm on the vpn, so I will have to fix
17
+ # this later.
18
+ message = Message.new(socket.gets)
19
+ socket.close
20
+ message
21
+ end
22
+
23
+ def start(&block)
24
+ @server = TCPServer.open(port)
25
+ while s = @server.accept
26
+ Thread.new do
27
+ s.puts HELLO
28
+ message = Message.new s.gets
29
+ result = yield message
30
+ s.puts result.body
31
+ s.close
32
+ end
33
+ end
34
+ end
35
+
36
+ def stop
37
+ @server && @server.close
38
+ end
39
+
40
+ private
41
+
42
+ def host
43
+ @config[:host]
44
+ end
45
+
46
+ def port
47
+ @config[:port]
48
+ end
49
+ end
50
+ end
data/lib/transports.rb ADDED
@@ -0,0 +1,54 @@
1
+ require 'service'
2
+ require 'transport/messages'
3
+
4
+ module Servicy
5
+ # The Transport class is an abstract class whos implementations handle the
6
+ # actual communication over the wire or in memory between server and client.
7
+ # However, in the abstract, it doesn't matter how things are transported,
8
+ # only that they are and conform to the format described here.
9
+ class Transport
10
+ attr_reader :config
11
+
12
+ # @param [Hash] config Configuration options for the transport
13
+ def initialize(config={})
14
+ @config = config
15
+ end
16
+
17
+ # Override this method to send a message from {Client} to {Server}
18
+ def send(messge)
19
+ raise 'Not implemented'
20
+ end
21
+
22
+ # Override this method to yield a message when received on the {Server}. It
23
+ # should yield to the provided block the message received as a
24
+ # {Transport::Message} object, and send back the return value of the block
25
+ # to the client.
26
+ def start(&block)
27
+ raise 'Not implemented'
28
+ end
29
+
30
+ # Called when a transport is stopped
31
+ def stop
32
+ end
33
+
34
+ # This attempts to load a service based on the name.
35
+ def self.method_missing(name, *args, &block)
36
+ class_name = "#{name.to_s}Transport"
37
+ begin
38
+ c = Module.const_get(class_name)
39
+ return c if c && c.ancestors.include?(Servicy::Transport)
40
+ rescue NameError
41
+ c = Servicy.const_get(class_name)
42
+ return c if c && c.ancestors.include?(Servicy::Transport)
43
+ end
44
+ rescue
45
+ super
46
+ end
47
+ end
48
+ end
49
+
50
+ # Load all the transports
51
+ Dir[File.expand_path(File.join(File.dirname(__FILE__), 'transport', '*.rb'))].each do |file|
52
+ next if file.start_with?('.') || File.directory?(file)
53
+ require File.expand_path(file)
54
+ end
data/test.rb ADDED
@@ -0,0 +1,74 @@
1
+ require './lib/servicy'
2
+
3
+ class Foo
4
+ def initialize(thing)
5
+ @thing = thing
6
+ end
7
+
8
+ # Do some foobar work
9
+ # @param [String] a
10
+ # @param [Integer] b
11
+ def foobar(a,b)
12
+ p a
13
+ p b
14
+ end
15
+
16
+ # Do something on the class
17
+ # @return [Hash{Integer => Array<String>}]
18
+ def self.barbaz
19
+ puts "OH MY GOD!"
20
+ { 1 => ['foo', 'bar'] }
21
+ end
22
+ end
23
+
24
+ # Create a new class that wraps foo. It will be called FooAPI, and will behave
25
+ # just like foo, but with contract checking. It will be the place that in the
26
+ # future we extend to behave more like an API
27
+ api = Servicy::Client.create_api_for Foo
28
+ # p api
29
+
30
+ # Instance-y-stuff
31
+ foo = api.new(:something)
32
+ foo.foobar('string',1)
33
+
34
+ # Class-y stuff
35
+ api.barbaz
36
+
37
+ # Now we should get some explosions for failing to comply with the contracts
38
+ begin
39
+ foo.foobar(:not_a_string, 'or an integer')
40
+ rescue => e
41
+ puts e
42
+ end
43
+
44
+ ###############################################################################
45
+ # Making a working api with an api object. This is just some ideas to outline
46
+ # how I would like the interface to look, not really any working code just yet.
47
+ #
48
+
49
+ # Finding a registered service and using a remote service instead of a local
50
+ # object. This would take the API object, gather information about it, make the
51
+ # query to the service discovery portal, and return a new api model (that is
52
+ # the same specifications as the original) that talks with the remote service
53
+ # instead of the local object, raising an error if something goes wrong.
54
+ transport = Servicy::Transport.TCP.new(port: 1234)
55
+ api = Servicy::Client.find_service_provider_for(api, transport)
56
+
57
+ # This should also be able to work with non-API objects given to it. In the
58
+ # case of a library that you don't have installed locally.
59
+ service = Servicy::Client.find_service(name: 'foobar') # This alredy exists
60
+ api = service.api
61
+
62
+ # Starting a server with a given client/api object. Ideally, you can start
63
+ # multipl servers.
64
+ api.start_server('HTTP', 80)
65
+ api.start_server('HTTPS', 443)
66
+ api.start_server('TCP', 1235)
67
+
68
+ # Maybe it would be better to do it with Transport objects...
69
+ t = Transport.tcp.new(1235)
70
+ api.start_server t
71
+
72
+ # Combining registration and server starting
73
+ api.register_with('127.0.0.1:1234', Transport.TCP.new(1235))
74
+
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: servicy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Thomas Luce
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-08-11 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A service registration and discovery framework, as a server and client
14
+ library.
15
+ email: thomas.luce@gmail.com
16
+ executables:
17
+ - servicy
18
+ extensions: []
19
+ extra_rdoc_files:
20
+ - README.md
21
+ files:
22
+ - README.md
23
+ - Servicy.gemspec
24
+ - VERSION
25
+ - bin/servicy
26
+ - lib/api.rb
27
+ - lib/client.rb
28
+ - lib/hash.rb
29
+ - lib/load_balancer.rb
30
+ - lib/load_balancer/random.rb
31
+ - lib/load_balancer/round_robin.rb
32
+ - lib/server.rb
33
+ - lib/server/server.rb
34
+ - lib/server/service_searcher.rb
35
+ - lib/service.rb
36
+ - lib/servicy.rb
37
+ - lib/transport/in_memory_transport.rb
38
+ - lib/transport/messages.rb
39
+ - lib/transport/tcp_transport.rb
40
+ - lib/transports.rb
41
+ - test.rb
42
+ homepage: https://github.com/thomasluce/servicy
43
+ licenses: []
44
+ metadata: {}
45
+ post_install_message:
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - '>='
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubyforge_project:
61
+ rubygems_version: 2.2.2
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: A service registration and discovery framework
65
+ test_files: []