es-readmodel 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ce71465c7b242c0f599a132a464e568187dcbf1d
4
+ data.tar.gz: d03c5db89e11c92f309a614585a1e25bec1e8756
5
+ SHA512:
6
+ metadata.gz: 4b318c8c2eda6b6f3aeef8a246084389d14985cf135c1352486848154d07cc85bfebb62ffd7d798741fa0a4f04486c7bb9c9c1ec651206cfbbb5ef3a4a24a060
7
+ data.tar.gz: 03555d442fbc240cd190579ae02f01718ab4b0981bf4b0b87f442a61f843aeb70f089fe65a828c71085bf170e936672a38bb5cad0bc9602b479ad9695c1e6007
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ *.gem
2
+ Gemfile.lock
3
+
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'http://rubygems.org'
2
+
3
+ gemspec
4
+
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Kevin Rutherford
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # EsReadModel
2
+
3
+ An opinionated read model framework for EventStore.
4
+
5
+ Your reducer can be anything that responds to #call.
6
+ It will receive two arguments -- the current state and the event.
7
+ The current state will be nil if no events have bee processed yet.
8
+ The reducer function must return the new state.
9
+
10
+ ## Exmple usage
11
+
12
+ ```[ruby]
13
+ require 'rack/cors'
14
+ require_relative './lib/es_readmodel'
15
+ require_relative './active_users'
16
+ require_relative './list_users'
17
+ require_relative './get_user_details'
18
+
19
+ ENV['RACK_ENV'] = 'none'
20
+ ENV['readmodel.name'] = 'users'
21
+
22
+ use Rack::Cors do
23
+ allow do
24
+ origins '*'
25
+ resource '*', headers: :any, methods: :any, max_age: 0
26
+ end
27
+ end
28
+
29
+ use EsReadModel::Subscriber,
30
+ es_host: ENV['ES_HOST'],
31
+ es_port: ENV['ES_PORT'],
32
+ es_username: ENV['ES_USERNAME'],
33
+ es_password: ENV['ES_PASSWORD'],
34
+ reducer: ActiveUsers.new,
35
+ listener: EsReadModel::Logger.new
36
+
37
+ run EsReadModel::Api.new(
38
+ '/users' => ListUsers.new,
39
+ '/users/:user_id' => GetUserDetails.new
40
+ )
41
+
42
+ ```
43
+
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'es-readmodel'
5
+ spec.version = '0.0.1'
6
+ spec.licenses = ['MIT']
7
+ spec.authors = ['Kevin Rutherford']
8
+ spec.email = ['kevin@rutherford-software.com']
9
+
10
+ spec.summary = %q{An opinionated read model framework for use with EventStore}
11
+ spec.description = %q{An opinionated read model framework for use with EventStore}
12
+ spec.homepage = "https://github.com/kevinrutherford/es-readmodel"
13
+
14
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) }
15
+ spec.require_paths = ['lib']
16
+
17
+ spec.add_development_dependency 'rspec', '3.5.0'
18
+
19
+ spec.add_runtime_dependency 'faraday', '~> 0.13'
20
+ spec.add_runtime_dependency 'faraday_middleware', '~> 0.12'
21
+ spec.add_runtime_dependency 'hashie', '~> 3.5'
22
+ spec.add_runtime_dependency 'json', '~> 2.1'
23
+ spec.add_runtime_dependency 'mustermann', '~> 1.0'
24
+
25
+ end
26
+
@@ -0,0 +1,45 @@
1
+ require 'json'
2
+ require 'mustermann'
3
+
4
+ module EsReadModel
5
+
6
+ class Api
7
+
8
+ def initialize(routes)
9
+ @routes = routes
10
+ end
11
+
12
+ def call(env)
13
+ @request = Rack::Request.new(env)
14
+ path = @request.path_info
15
+ @routes.each do |route, handler|
16
+ pattern = Mustermann.new(route)
17
+ args = pattern.params(path)
18
+ if args
19
+ return json_response(503, {status: env['readmodel.status']}) unless env['readmodel.available'] == true
20
+ result = handler.call(@request.env['readmodel.state'], @request.params.merge(args))
21
+ return result ? json_response(200, result) : json_response(404, {error: 'not found'})
22
+ end
23
+ end
24
+ return json_response(404, {error: 'not found'})
25
+ end
26
+
27
+ private
28
+
29
+ def json_response(status_code, body)
30
+ result = body.merge({
31
+ _links: { self: @request.fullpath }
32
+ })
33
+ [
34
+ status_code,
35
+ {
36
+ 'Content-Type' => 'application/json'
37
+ },
38
+ [result.to_json]
39
+ ]
40
+ end
41
+
42
+ end
43
+
44
+ end
45
+
@@ -0,0 +1,45 @@
1
+ require 'faraday'
2
+ require 'faraday_middleware'
3
+ require 'json'
4
+ require 'base64'
5
+
6
+ module EsReadModel
7
+
8
+ class Connection
9
+
10
+ def initialize(endpoint, username=nil, password=nil)
11
+ @endpoint = endpoint
12
+ @headers = {
13
+ 'Accept' => 'application/json',
14
+ 'Content-Type' => 'application/json'
15
+ }
16
+ if username && password
17
+ token = Base64.encode64("#{username}:#{password}")[0..-2]
18
+ @headers.merge!({ 'Authorization' => "Basic #{token}" })
19
+ end
20
+ end
21
+
22
+ def get(uri, etag)
23
+ connection = Faraday.new(url: @endpoint) do |faraday|
24
+ faraday.options[:timeout] = 2
25
+ faraday.response :json, content_type: 'application/json'
26
+ faraday.response :mashify
27
+ faraday.adapter Faraday.default_adapter
28
+ end
29
+ response = connection.get(uri) do |req|
30
+ req.headers = @headers
31
+ req.headers.merge({ 'If-None-Match' => etag }) if etag
32
+ req.body = {}.to_json
33
+ req.params['embed'] = 'body'
34
+ end
35
+ response
36
+ end
37
+
38
+ def to_s
39
+ @endpoint
40
+ end
41
+
42
+ end
43
+
44
+ end
45
+
@@ -0,0 +1,27 @@
1
+ require 'json'
2
+
3
+ module EsReadModel
4
+
5
+ class Event < Struct.new(:id, :type, :data, :updated, :number, :uri, :stream_id)
6
+
7
+ def self.load_from(hash)
8
+ return nil unless hash['data']
9
+ data = JSON.parse(hash['data'], symbolize_names: true)
10
+ event = Event.new(hash['eventId'], hash['eventType'], data, hash['updated'], hash['eventNumber'].to_i, hash['id'], hash['streamId'])
11
+ event
12
+ end
13
+
14
+ def occurred_at
15
+ data[:occurredAt]
16
+ end
17
+
18
+ private
19
+
20
+ def initialize(id, type, data, updated=nil, number=nil, uri=nil, stream_id=nil)
21
+ super
22
+ end
23
+
24
+ end
25
+
26
+ end
27
+
@@ -0,0 +1,24 @@
1
+ module EsReadModel
2
+
3
+ class Logger
4
+
5
+ def call(ctx)
6
+ ctx = {
7
+ time: Time.now
8
+ }.merge(ctx)
9
+ extras = ENV.select {|k,v| k =~ /^readmodel/i }
10
+ ctx = ctx.merge(extras)
11
+ STDERR.puts ctx.map {|k,v| format(k, v.to_s) }.join(' ')
12
+ end
13
+
14
+ private
15
+
16
+ def format(k, v)
17
+ value = (v =~ / /) ? "\"#{v}\"" : v
18
+ "#{k}=#{value}"
19
+ end
20
+
21
+ end
22
+
23
+ end
24
+
@@ -0,0 +1,42 @@
1
+ require_relative './event'
2
+
3
+ module EsReadModel
4
+
5
+ class Page
6
+
7
+ def initialize(body)
8
+ @body = body
9
+ end
10
+
11
+ def first_event_uri
12
+ find_link('last')
13
+ end
14
+
15
+ def newer_events_uri
16
+ find_link('previous')
17
+ end
18
+
19
+ def empty?
20
+ @body['entries'].nil? || @body['entries'].empty?
21
+ end
22
+
23
+ def each_event(&block)
24
+ @body['entries']
25
+ .reverse!
26
+ .map {|e| Event.load_from(e)}
27
+ .compact
28
+ .select {|e| e.type !~ /^\$/ }
29
+ .each {|e| yield e }
30
+ end
31
+
32
+ private
33
+
34
+ def find_link(rel)
35
+ link = @body['links'].detect { |l| l['relation'] == rel }
36
+ link.nil? ? nil : link['uri']
37
+ end
38
+
39
+ end
40
+
41
+ end
42
+
@@ -0,0 +1,69 @@
1
+ require_relative './page'
2
+
3
+ module EsReadModel
4
+
5
+ class Stream
6
+
7
+ def Stream.open(name, connection, listener)
8
+ Stream.new("/streams/#{name}", connection, listener)
9
+ end
10
+
11
+ def initialize(head_uri, connection, listener)
12
+ @connection = connection
13
+ @listener = listener
14
+ @current_etag = nil
15
+ @listener.call({
16
+ level: 'info',
17
+ tag: 'connecting',
18
+ msg: "Connecting to #{head_uri} on #{connection}"
19
+ })
20
+ fetch_first_page(head_uri)
21
+ end
22
+
23
+ def wait_for_new_events
24
+ while @current_page.empty?
25
+ sleep 1
26
+ fetch(@current_uri)
27
+ end
28
+ end
29
+
30
+ def each_event(&blk)
31
+ while !@current_page.empty?
32
+ @current_page.each_event(&blk)
33
+ fetch(@current_page.newer_events_uri) if @current_page.newer_events_uri
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def fetch_first_page(uri)
40
+ back_off = 1
41
+ loop do
42
+ begin
43
+ fetch(uri)
44
+ last = @current_page.first_event_uri
45
+ fetch(last) if last
46
+ return
47
+ rescue Exception => ex
48
+ @listener.call({
49
+ level: 'error',
50
+ tag: 'connection.error',
51
+ msg: "#{ex.class}: #{ex.message}. Retry in #{back_off}s."
52
+ })
53
+ sleep back_off
54
+ back_off *= 2
55
+ end
56
+ end
57
+ end
58
+
59
+ def fetch(uri)
60
+ response = @connection.get(uri, @current_etag)
61
+ @current_page = Page.new(response.body)
62
+ @current_uri = uri
63
+ @current_etag = response.headers['etag']
64
+ end
65
+
66
+ end
67
+
68
+ end
69
+
@@ -0,0 +1,102 @@
1
+ require 'rack'
2
+ require 'json'
3
+ require_relative './connection'
4
+ require_relative './stream'
5
+
6
+ module EsReadModel
7
+
8
+ class Subscriber
9
+
10
+ attr_reader :status
11
+
12
+ def initialize(app, options)
13
+ @app = app
14
+ @listener = options[:listener]
15
+ url = "http://#{options[:es_host]}:#{options[:es_port]}"
16
+ @status = {
17
+ available: false,
18
+ startedAt: Time.now,
19
+ eventsReceived: 0,
20
+ eventStore: {
21
+ url: url,
22
+ connected: true,
23
+ disconnects: 0
24
+ }
25
+ }
26
+ @connection = Connection.new(url, options[:es_username], options[:es_password])
27
+ @reducer = options[:reducer]
28
+ Thread.new { subscribe }
29
+ end
30
+
31
+ def call(env)
32
+ @request = Rack::Request.new(env)
33
+ if env['PATH_INFO'] == '/status'
34
+ status, headers, body = json_response(200, @status)
35
+ else
36
+ env['readmodel.state'] = @state
37
+ env['readmodel.available'] = @status[:available]
38
+ env['readmodel.status'] = 'OK'
39
+ status, headers, body = @app.call(env)
40
+ end
41
+ @listener.call({
42
+ level: 'info',
43
+ tag: 'http.request',
44
+ msg: "#{env['REQUEST_METHOD']} #{@request.fullpath}",
45
+ status: status
46
+ })
47
+ [status, headers, body]
48
+ end
49
+
50
+ private
51
+
52
+ def subscribe
53
+ loop do
54
+ begin
55
+ @status[:available] = false
56
+ @status[:eventStore][:connected] = false
57
+ @state = nil
58
+ @stream = Stream.open("$all", @connection, @listener)
59
+ @status[:eventStore][:connected] = true
60
+ @status[:eventStore][:lastConnect] = Time.now
61
+ subscribe_to_all_events
62
+ rescue Exception => ex
63
+ @listener.call({
64
+ level: 'error',
65
+ tag: 'connection.error',
66
+ msg: "#{ex.class}: #{ex.message}"
67
+ })
68
+ @status[:eventStore][:disconnects] = @status[:eventStore][:disconnects] + 1
69
+ @status[:eventStore][:lastDisconnect] = Time.now
70
+ end
71
+ end
72
+ end
73
+
74
+ def subscribe_to_all_events
75
+ loop do
76
+ @status[:available] = true
77
+ @stream.wait_for_new_events
78
+ @status[:available] = false
79
+ @stream.each_event do |evt|
80
+ @state = @reducer.call(@state, evt)
81
+ @status[:eventsReceived] = @status[:eventsReceived] + 1
82
+ end
83
+ end
84
+ end
85
+
86
+ def json_response(status_code, body)
87
+ result = body.merge({
88
+ _links: { self: @request.fullpath }
89
+ })
90
+ [
91
+ status_code,
92
+ {
93
+ 'Content-Type' => 'application/json'
94
+ },
95
+ [result.to_json]
96
+ ]
97
+ end
98
+
99
+ end
100
+
101
+ end
102
+
@@ -0,0 +1,7 @@
1
+ require_relative './es_readmodel/subscriber'
2
+ require_relative './es_readmodel/api'
3
+ require_relative './es_readmodel/logger'
4
+
5
+ module EsReadModel
6
+ end
7
+
metadata ADDED
@@ -0,0 +1,141 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: es-readmodel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Kevin Rutherford
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-11-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 3.5.0
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 3.5.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.13'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday_middleware
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.12'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.12'
55
+ - !ruby/object:Gem::Dependency
56
+ name: hashie
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.5'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.5'
69
+ - !ruby/object:Gem::Dependency
70
+ name: json
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.1'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.1'
83
+ - !ruby/object:Gem::Dependency
84
+ name: mustermann
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.0'
97
+ description: An opinionated read model framework for use with EventStore
98
+ email:
99
+ - kevin@rutherford-software.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - Gemfile
106
+ - LICENSE
107
+ - README.md
108
+ - es-readmodel.gemspec
109
+ - lib/es_readmodel.rb
110
+ - lib/es_readmodel/api.rb
111
+ - lib/es_readmodel/connection.rb
112
+ - lib/es_readmodel/event.rb
113
+ - lib/es_readmodel/logger.rb
114
+ - lib/es_readmodel/page.rb
115
+ - lib/es_readmodel/stream.rb
116
+ - lib/es_readmodel/subscriber.rb
117
+ homepage: https://github.com/kevinrutherford/es-readmodel
118
+ licenses:
119
+ - MIT
120
+ metadata: {}
121
+ post_install_message:
122
+ rdoc_options: []
123
+ require_paths:
124
+ - lib
125
+ required_ruby_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
130
+ required_rubygems_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ requirements: []
136
+ rubyforge_project:
137
+ rubygems_version: 2.6.8
138
+ signing_key:
139
+ specification_version: 4
140
+ summary: An opinionated read model framework for use with EventStore
141
+ test_files: []