ru.Bee 1.11.1 → 2.1.0

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.
@@ -0,0 +1,18 @@
1
+ module Rubee
2
+ module PubSub
3
+ module Publisher
4
+ Error = Class.new(StandardError)
5
+
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def pub(channel, args = {}, &block)
12
+ Rubee::Configuration.pubsub_container.pub(channel, args, &block)
13
+ true
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,99 @@
1
+ require "connection_pool"
2
+ require "redis"
3
+ require "singleton"
4
+ require "json"
5
+
6
+ module Rubee
7
+ module PubSub
8
+ class Redis < Container
9
+ include Singleton
10
+
11
+ DEFAULT_POOL_SIZE = 5
12
+ DEFAULT_TIMEOUT = 5
13
+
14
+ def initialize
15
+ redis_url = Rubee::Configuration.get_redis_url
16
+ @pool = ConnectionPool.new(size: DEFAULT_POOL_SIZE, timeout: DEFAULT_TIMEOUT) do
17
+ if redis_url&.empty?
18
+ ::Redis.new
19
+ else
20
+ ::Redis.new(url: redis_url)
21
+ end
22
+ end
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # Example: pub("ok", message: "hello")
27
+ def pub(channel, args = {}, &block)
28
+ keys = with_redis { |r| r.scan_each(match: "#{channel}:*").to_a }
29
+ return false if keys.empty?
30
+
31
+ values = with_redis { |r| r.mget(*keys) }
32
+
33
+ iterable = values.each_with_index.each_with_object({}) do |(val, i), hash|
34
+ key = keys[i]
35
+ hash[key] = val
36
+ end
37
+
38
+ clazzes = retrieve_klasses(iterable)
39
+ fan_out(clazzes, args, &block)
40
+ end
41
+
42
+ # Example: sub("ok", "User", ["123"])
43
+ def sub(channel, klass_name, *args, &block)
44
+ @mutex.synchronize do
45
+ id = args.first
46
+ id_string = id ? ":#{id}" : ""
47
+ key = "#{channel}:#{klass_name}#{id_string}"
48
+ existing = with_redis { |r| r.get(key) }
49
+ io = args.last.respond_to?(:call) ? args.pop : nil
50
+
51
+ with_redis { |r| r.set(key, args.join(",")) } unless existing
52
+ block&.call(key, io: io)
53
+ end
54
+ true
55
+ end
56
+
57
+ def unsub(channel, klass_name, *args, &block)
58
+ @mutex.synchronize do
59
+ id = args.first
60
+ id_string = id ? ":#{id}" : ""
61
+ key = "#{channel}:#{klass_name}#{id_string}"
62
+ value = with_redis { |r| r.get(key) }
63
+ return false unless value
64
+
65
+ io = args.pop if args.last.respond_to?(:call)
66
+ with_redis { |r| r.del(key) }
67
+ block&.call(key, io: io)
68
+ end
69
+ true
70
+ end
71
+
72
+ protected
73
+
74
+ def with_redis(&block)
75
+ @pool.with(&block)
76
+ end
77
+
78
+ def retrieve_klasses(iterable)
79
+ iterable.each_with_object({}) do |(key, args), hash|
80
+ channel, clazz, id = key.split(":")
81
+ arg_list = args.to_s.split(",")
82
+ hash[key] = { channel:, clazz:, args: arg_list, id: }
83
+ end
84
+ end
85
+
86
+ def fan_out(clazzes, method_args = {}, &block)
87
+ clazzes.each do |_key, opts|
88
+ clazz = turn_to_class(opts[:clazz])
89
+ clazz_args = opts[:args]
90
+
91
+ clazz.on_pub(opts[:channel], *clazz_args, **method_args) if clazz.respond_to?(:on_pub)
92
+ id_string = opts[:id] ? ":#{opts[:id]}" : ""
93
+ block&.call("#{opts[:channel]}:#{opts[:clazz]}#{id_string}", **method_args)
94
+ end
95
+ true
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,25 @@
1
+ module Rubee
2
+ module PubSub
3
+ module Subscriber
4
+ Error = Class.new(StandardError)
5
+
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def sub(channel, *args, &block)
12
+ Rubee::Configuration.pubsub_container.sub(channel, name, *args, &block)
13
+
14
+ true
15
+ end
16
+
17
+ def unsub(channel, *args, &block)
18
+ Rubee::Configuration.pubsub_container.unsub(channel, name, *args, &block)
19
+
20
+ true
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ # class Subscriber
2
+ # include Rubee::PubSub::Subscriber
3
+
4
+ # def self.on_pub(channel, message, options = {})
5
+ # puts "channel=#{channel} message=#{message} options=#{options}"
6
+ # end
7
+ # end
8
+
9
+ # class SubscriberOne
10
+ # include Rubee::PubSub::Subscriber
11
+
12
+ # def self.on_pub(channel, message, options = {})
13
+ # puts "channel=#{channel} message=#{message} options=#{options}"
14
+ # end
15
+ # end
16
+
17
+ # class Publisher
18
+ # include Rubee::PubSub::Publisher
19
+ # end
20
+
21
+ # Subscriber.sub("ok", ["123456"])
22
+
23
+ # SubscriberOne.sub("ok", ["123"])
24
+
25
+ # Publisher.pub("ok", { message: "hello" })
26
+
27
+ # SubscriberOne.unsub("ok", ["123"])
28
+
29
+ # Publisher.pub("ok", { message: "hello" })
@@ -0,0 +1,102 @@
1
+ require 'websocket'
2
+ require 'websocket/frame'
3
+ require 'websocket/handshake'
4
+ require 'websocket/handshake/server'
5
+ require 'json'
6
+ require 'redis'
7
+
8
+ module Rubee
9
+ class WebSocket
10
+ using ChargedHash
11
+ class << self
12
+ def call(env, &controller_block)
13
+ unless env['rack.hijack']
14
+ return [500, { 'Content-Type' => 'text/plain' }, ['Hijack not supported']]
15
+ end
16
+
17
+ env['rack.hijack'].call
18
+ io = env['rack.hijack_io']
19
+
20
+ handshake = ::WebSocket::Handshake::Server.new
21
+ handshake.from_rack(env)
22
+ unless handshake.valid?
23
+ io.write("HTTP/1.1 400 Bad Request\r\n\r\n")
24
+ io.close
25
+ return [-1, {}, []]
26
+ end
27
+
28
+ io.write(handshake.to_s)
29
+ incoming = ::WebSocket::Frame::Incoming::Server.new(version: handshake.version)
30
+
31
+ outgoing = ->(data) do
32
+ frame = ::WebSocket::Frame::Outgoing::Server.new(
33
+ version: handshake.version,
34
+ type: :text,
35
+ data: data.to_json
36
+ )
37
+ io.write(frame.to_s)
38
+ rescue IOError
39
+ nil
40
+ end
41
+
42
+ # --- Listen to incoming data ---
43
+ Thread.new do
44
+ loop do
45
+ data = io.readpartial(1024)
46
+ incoming << data
47
+
48
+ while (frame = incoming.next)
49
+ case frame.type
50
+ when :text
51
+ out = controller_out(frame, outgoing, &controller_block)
52
+ outgoing.call(**out)
53
+
54
+ when :close
55
+ # Client closed connection
56
+ handle_close(frame, io, handshake)
57
+ break
58
+ end
59
+ end
60
+ end
61
+ rescue EOFError, IOError
62
+ begin
63
+ handle_close(frame, io, handshake)
64
+ rescue
65
+ nil
66
+ end
67
+ end
68
+
69
+ [101, handshake.headers, []]
70
+ end
71
+
72
+ def payload(frame)
73
+ JSON.parse(frame.data)
74
+ rescue
75
+ {}
76
+ end
77
+
78
+ def handle_close(frame, io, handshake)
79
+ payload_hash = payload(frame)
80
+ channel = payload_hash["channel"]
81
+ subcriber = payload_hash["subcriber"]
82
+ ::Rubee::WebSocketConnections.instance.remove("#{channel}:#{subcriber}", io)
83
+ io.write(::WebSocket::Frame::Outgoing::Server.new(
84
+ version: handshake.version,
85
+ type: :close
86
+ ).to_s)
87
+ io.close
88
+ end
89
+
90
+ def controller_out(frame, io, &block)
91
+ payload_hash = payload(frame)
92
+ action = payload_hash["action"]
93
+ channel = payload_hash["channel"]
94
+ message = payload_hash["message"]
95
+ options = payload_hash.select { |k, _| !["action", "channel", "message"].include?(k) }
96
+ options.merge!(io:)
97
+
98
+ block.call(channel:, message:, action:, options:)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,35 @@
1
+ module Rubee
2
+ class WebSocketConnections
3
+ include Singleton
4
+ def initialize
5
+ @subscribers ||= Hash.new { |h, k| h[k] = [] }
6
+ end
7
+
8
+ def register(channel, io)
9
+ @subscribers[channel] << io unless @subscribers[channel].include?(io)
10
+ end
11
+
12
+ def remove(channel, io)
13
+ @subscribers[channel].delete(io)
14
+ end
15
+
16
+ def remove_all(io)
17
+ @subscribers.each_value { _1.delete(io) }
18
+ end
19
+
20
+ def flush_all
21
+ @subscribers.each_value(&:clear)
22
+ end
23
+
24
+ def stream(channel, args = {})
25
+ ios = @subscribers[channel]
26
+ if !ios&.empty? && ios.all? { _1.respond_to?(:call) }
27
+ ios.each { _1.call(args) }
28
+ end
29
+ end
30
+
31
+ def clear
32
+ @subscribers = Hash.new { |h, k| h[k] = [] }
33
+ end
34
+ end
35
+ end
data/lib/rubee.rb CHANGED
@@ -16,11 +16,13 @@ module Rubee
16
16
  JS_DIR = File.join(APP_ROOT, LIB, 'js') unless defined?(JS_DIR)
17
17
  CSS_DIR = File.join(APP_ROOT, LIB, 'css') unless defined?(CSS_DIR)
18
18
  ROOT_PATH = File.expand_path(File.join(__dir__, '..')) unless defined?(ROOT_PATH)
19
- VERSION = '1.11.1'
19
+
20
+ VERSION = '2.1.0'
20
21
 
21
22
  require_relative 'rubee/router'
22
23
  require_relative 'rubee/logger'
23
24
  require_relative 'rubee/generator'
25
+ require_relative 'rubee/features'
24
26
  require_relative 'rubee/autoload'
25
27
  require_relative 'rubee/configuration'
26
28
 
@@ -31,15 +33,10 @@ module Rubee
31
33
  def call(env)
32
34
  # autoload rb files
33
35
  Autoload.call
34
-
35
- # register images paths
36
+ # init rack request
36
37
  request = Rack::Request.new(env)
37
38
  # Add default path for assets
38
- Router.draw do |route|
39
- route.get('/images/{path}', to: 'base#image', namespace: 'Rubee')
40
- route.get('/js/{path}', to: 'base#js', namespace: 'Rubee')
41
- route.get('/css/{path}', to: 'base#css', namespace: 'Rubee')
42
- end
39
+ register_assets_routes
43
40
  # define route
44
41
  route = Router.route_for(request)
45
42
  # if react is the view so we would like to delegate not cauth by rubee routes to it.
@@ -62,5 +59,15 @@ module Rubee
62
59
  # fire the action
63
60
  controller.send(action)
64
61
  end
62
+
63
+ private
64
+
65
+ def register_assets_routes
66
+ Router.draw do |route|
67
+ route.get('/images/{path}', to: 'base#image', namespace: 'Rubee')
68
+ route.get('/js/{path}', to: 'base#js', namespace: 'Rubee')
69
+ route.get('/css/{path}', to: 'base#css', namespace: 'Rubee')
70
+ end
71
+ end
65
72
  end
66
73
  end
@@ -1,6 +1,6 @@
1
1
  require_relative '../test_helper'
2
2
 
3
- class BaseControllerTest < Minitest::Test
3
+ class WebSocketControllerTest < Minitest::Test
4
4
  include Rack::Test::Methods
5
5
 
6
6
  def app
@@ -0,0 +1,41 @@
1
+ require_relative '../test_helper'
2
+
3
+ class UsersControllerTest < Minitest::Test
4
+ include Rack::Test::Methods
5
+
6
+ def app
7
+ Rubee::Application.instance
8
+ end
9
+
10
+ def test_websocket_handshake_written_to_io
11
+ env = Rack::MockRequest.env_for(
12
+ '/ws',
13
+ {
14
+ 'REQUEST_METHOD' => 'GET',
15
+ 'PATH_INFO' => '/ws',
16
+ 'HTTP_CONNECTION' => 'keep-alive, Upgrade',
17
+ 'HTTP_UPGRADE' => 'websocket',
18
+ 'HTTP_HOST' => 'localhost:9292',
19
+ 'HTTP_ORIGIN' => 'http://localhost:9292',
20
+ 'HTTP_SEC_WEBSOCKET_KEY' => 'dGhlIHNhbXBsZSBub25jZQ==',
21
+ 'HTTP_SEC_WEBSOCKET_VERSION' => '13',
22
+ 'rack.url_scheme' => 'http'
23
+ }
24
+ )
25
+
26
+ # Mock hijack interface expected by Rubee::WebSocket
27
+ io = StringIO.new
28
+ env['rack.hijack'] = proc {}
29
+ env['rack.hijack_io'] = io
30
+
31
+ # Call the WebSocket handler
32
+ Rubee::WebSocket.call(env)
33
+
34
+ # Expect the handshake response written to IO
35
+ io.rewind
36
+ handshake_response = io.read
37
+ assert_includes(handshake_response, "HTTP/1.1 101 Switching Protocols")
38
+ assert_includes(handshake_response, "Upgrade: websocket")
39
+ assert_includes(handshake_response, "Connection: Upgrade")
40
+ end
41
+ end
@@ -15,5 +15,22 @@ describe 'Account model' do
15
15
  _(account.user.id).must_equal(user.id)
16
16
  end
17
17
  end
18
+
19
+ describe '#validate_before_persist' do
20
+ it 'rasies error if account is not valid' do
21
+ Account.validate do |account|
22
+ account
23
+ .required(:addres, required: "address is required")
24
+ .type(String, type: "address must be string")
25
+ end
26
+ Account.validate_before_persist!
27
+ user = User.new(email: 'ok-test@test.com', password: '123')
28
+ _(raise_error { Account.create(addres: 1, user_id: user.id) }.is_a?(Rubee::Validatable::Error)).must_equal(true)
29
+ account = Account.create(addres: "13Th street", user_id: user.id)
30
+ _(account.persisted?).must_equal(true)
31
+ _(raise_error { account.update(addres: 1) }.is_a?(Rubee::Validatable::Error)).must_equal(true)
32
+ _(raise_error { Account.new(addres: 1, user_id: user.id).save }.is_a?(Rubee::Validatable::Error)).must_equal(true)
33
+ end
34
+ end
18
35
  end
19
36
  end
@@ -3,7 +3,7 @@ require_relative '../test_helper'
3
3
  describe 'Comment model' do
4
4
  describe 'owns_many :users, over: :posts' do
5
5
  before do
6
- comment = Comment.new(text: 'test')
6
+ comment = Comment.new(text: 'test_enough')
7
7
  comment.save
8
8
  user = User.new(email: 'ok-test@test.com', password: '123')
9
9
  user.save
@@ -17,18 +17,18 @@ describe 'Comment model' do
17
17
 
18
18
  describe 'when there are associated comment records' do
19
19
  it 'returns all records' do
20
- _(Comment.where(text: 'test').last.users.count).must_equal(1)
21
- _(Comment.where(text: 'test').last.users.first.email).must_equal('ok-test@test.com')
20
+ _(Comment.where(text: 'test_enough').last.users.count).must_equal(1)
21
+ _(Comment.where(text: 'test_enough').last.users.first.email).must_equal('ok-test@test.com')
22
22
  end
23
23
  end
24
24
 
25
25
  describe 'sequel dataset query' do
26
26
  it 'returns all records' do
27
27
  result = Comment.dataset.join(:posts, comment_id: :id)
28
- .where(comment_id: Comment.where(text: 'test').last.id)
28
+ .where(comment_id: Comment.where(text: 'test_enough').last.id)
29
29
  .then { |dataset| Comment.serialize(dataset) }
30
30
 
31
- _(result.first.text).must_equal('test')
31
+ _(result.first.text).must_equal('test_enough')
32
32
  end
33
33
  end
34
34
  end
@@ -44,4 +44,141 @@ describe 'Comment model' do
44
44
  _(Comment.find(comment.id).text).must_equal('test 2')
45
45
  end
46
46
  end
47
+
48
+ describe 'validatable' do
49
+ def include_and_validate(required: true)
50
+ # Comment.include(Rubee::Validatable)
51
+ required_or_optional = required ? :required : :optional
52
+ required_or_optional_args = required ? [:text, required: "text filed is required"] : [:text]
53
+ Comment.validate do |comment|
54
+ comment.send(
55
+ required_or_optional, *required_or_optional_args
56
+ )
57
+ .type(String, type: "text field must be string")
58
+ .condition(proc { comment.text.length > 4 }, { length: "text length must be greater than 4" })
59
+ end
60
+ end
61
+ it 'is valid' do
62
+ include_and_validate
63
+ comment = Comment.new(text: 'test it as valid')
64
+
65
+ _(comment.valid?).must_equal(true)
66
+ end
67
+
68
+ it 'is not valid length' do
69
+ include_and_validate
70
+ comment = Comment.new(text: 'test')
71
+
72
+ _(comment.valid?).must_equal(false)
73
+ _(comment.errors[:text]).must_equal({ length: "text length must be greater than 4" })
74
+ end
75
+
76
+ it 'is not valid type' do
77
+ include_and_validate
78
+ comment = Comment.new(text: 1)
79
+
80
+ _(comment.valid?).must_equal(false)
81
+ _(comment.errors[:text]).must_equal({ type: "text field must be string" })
82
+ end
83
+
84
+ it 'is not valid required' do
85
+ include_and_validate
86
+ comment = Comment.new(user_id: 1)
87
+
88
+ _(comment.valid?).must_equal(false)
89
+ _(comment.errors[:text]).must_equal({ required: "text filed is required" })
90
+ end
91
+
92
+ describe 'when first validation is optional' do
93
+ it 'no text should be valid' do
94
+ include_and_validate required: false
95
+
96
+ comment = Comment.new(user_id: 1)
97
+
98
+ _(comment.valid?).must_equal(true)
99
+ _(comment.errors[:test]).must_equal(nil)
100
+ end
101
+
102
+ it 'text is a number should be invalid' do
103
+ include_and_validate required: false
104
+ comment = Comment.new(text: 1)
105
+
106
+ _(comment.valid?).must_equal(false)
107
+ _(comment.errors[:text]).must_equal({ type: "text field must be string" })
108
+ end
109
+
110
+ it 'text is short should be invalid' do
111
+ include_and_validate required: false
112
+ comment = Comment.new(text: 'test')
113
+
114
+ _(comment.valid?).must_equal(false)
115
+ _(comment.errors[:text]).must_equal({ length: "text length must be greater than 4" })
116
+ end
117
+ end
118
+
119
+ describe 'before save must be valid' do
120
+ it 'does not persit if record is invalid' do
121
+ include_and_validate
122
+ Comment.before(
123
+ :save, proc { |comment| raise Rubee::Validatable::Error, comment.errors.to_s },
124
+ if: ->(comment) { comment&.invalid? }
125
+ )
126
+
127
+ comment = Comment.new(text: 'test')
128
+ _(raise_error { comment.save }.is_a?(Rubee::Validatable::Error)).must_equal(true)
129
+ _(comment.persisted?).must_equal(false)
130
+ end
131
+
132
+ describe 'when usig method' do
133
+ it 'does not persit if record is invalid' do
134
+ include_and_validate
135
+ Comment.before(:save, proc { |comment| raise Rubee::Validatable::Error, comment.errors.to_s }, if: :invalid?)
136
+
137
+ comment = Comment.new(text: 'test')
138
+ _(raise_error { comment.save }.is_a?(Rubee::Validatable::Error)).must_equal(true)
139
+ _(comment.persisted?).must_equal(false)
140
+ end
141
+ end
142
+ end
143
+
144
+ describe 'before create must be invalid' do
145
+ it 'does not create if record is invalid' do
146
+ include_and_validate
147
+ Comment.before(:save, proc { |comment| raise Rubee::Validatable::Error, comment.errors.to_s }, if: :invalid?)
148
+
149
+ initial_comments_count = Comment.count
150
+ _(raise_error { Comment.create(text: 'te') }.is_a?(Rubee::Validatable::Error)).must_equal(true)
151
+ assert_equal(initial_comments_count, Comment.count)
152
+ end
153
+ end
154
+
155
+ describe 'before update must be invalid' do
156
+ it 'does not update if record is invalid' do
157
+ include_and_validate
158
+ Comment.around(:update, proc do |_comment, args, &update_method|
159
+ com = Comment.new(*args)
160
+ raise Rubee::Validatable::Error, com.errors.to_s if com.invalid?
161
+ update_method.call
162
+ end)
163
+ comment = Comment.create(text: 'test123123')
164
+
165
+ initial_comments_count = Comment.count
166
+ _(raise_error { comment.update(text: 'te') }.is_a?(Rubee::Validatable::Error)).must_equal(true)
167
+ assert_equal(initial_comments_count, Comment.count)
168
+ end
169
+
170
+ it 'updates the record if record is valid' do
171
+ include_and_validate
172
+ Comment.before(:update, ->(model, args) do
173
+ if (instance = model.class.new(*args)) && instance.invalid?
174
+ raise Rubee::Validatable::Error, instance.errors.to_s
175
+ end
176
+ end)
177
+ comment = Comment.create(text: 'test123123')
178
+
179
+ comment.update(text: 'testerter')
180
+ assert_equal('testerter', comment.text)
181
+ end
182
+ end
183
+ end
47
184
  end