message_bus 3.3.3 → 3.3.7

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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.eslintrc.js +21 -0
  3. data/.github/workflows/ci.yml +71 -0
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +3 -1
  6. data/CHANGELOG +36 -8
  7. data/DEV.md +7 -0
  8. data/Gemfile +0 -25
  9. data/LICENSE +1 -1
  10. data/README.md +34 -15
  11. data/Rakefile +13 -8
  12. data/assets/message-bus-ajax.js +4 -10
  13. data/assets/message-bus.js +69 -76
  14. data/bench/codecs/all_codecs.rb +39 -0
  15. data/bench/codecs/marshal.rb +11 -0
  16. data/bench/codecs/packed_string.rb +67 -0
  17. data/bench/codecs/string_hack.rb +47 -0
  18. data/bench/codecs_large_user_list.rb +29 -0
  19. data/bench/codecs_standard_message.rb +29 -0
  20. data/examples/bench/bench.lua +2 -2
  21. data/lib/message_bus/backends/base.rb +3 -5
  22. data/lib/message_bus/backends/memory.rb +0 -2
  23. data/lib/message_bus/backends/postgres.rb +7 -5
  24. data/lib/message_bus/backends/redis.rb +3 -5
  25. data/lib/message_bus/client.rb +3 -7
  26. data/lib/message_bus/codec/base.rb +18 -0
  27. data/lib/message_bus/codec/json.rb +15 -0
  28. data/lib/message_bus/codec/oj.rb +21 -0
  29. data/lib/message_bus/connection_manager.rb +1 -1
  30. data/lib/message_bus/distributed_cache.rb +2 -1
  31. data/lib/message_bus/http_client.rb +2 -2
  32. data/lib/message_bus/rack/diagnostics.rb +30 -8
  33. data/lib/message_bus/rack/middleware.rb +22 -16
  34. data/lib/message_bus/rack/thin_ext.rb +1 -1
  35. data/lib/message_bus/version.rb +1 -1
  36. data/lib/message_bus.rb +38 -23
  37. data/message_bus.gemspec +20 -5
  38. data/package-lock.json +3744 -0
  39. data/package.json +14 -4
  40. data/spec/assets/SpecHelper.js +6 -5
  41. data/spec/assets/message-bus.spec.js +9 -6
  42. data/spec/helpers.rb +17 -6
  43. data/spec/integration/http_client_spec.rb +1 -1
  44. data/spec/lib/message_bus/backend_spec.rb +12 -44
  45. data/spec/lib/message_bus/client_spec.rb +6 -6
  46. data/spec/lib/message_bus/distributed_cache_spec.rb +5 -7
  47. data/spec/lib/message_bus/multi_process_spec.rb +1 -1
  48. data/spec/lib/message_bus/rack/middleware_spec.rb +16 -5
  49. data/spec/lib/message_bus_spec.rb +18 -7
  50. data/spec/spec_helper.rb +8 -9
  51. data/spec/support/jasmine-browser.json +16 -0
  52. metadata +230 -13
  53. data/.travis.yml +0 -17
  54. data/lib/message_bus/em_ext.rb +0 -6
  55. data/spec/assets/support/jasmine.yml +0 -126
  56. data/spec/assets/support/jasmine_helper.rb +0 -11
  57. data/vendor/assets/javascripts/message-bus-ajax.js +0 -44
  58. data/vendor/assets/javascripts/message-bus.js +0 -556
@@ -2,8 +2,6 @@
2
2
 
3
3
  require 'pg'
4
4
 
5
- require "message_bus/backends/base"
6
-
7
5
  module MessageBus
8
6
  module Backends
9
7
  # The Postgres backend stores published messages in a single Postgres table
@@ -46,6 +44,7 @@ module MessageBus
46
44
  @listening_on = {}
47
45
  @available = []
48
46
  @allocated = {}
47
+ @subscribe_connection = nil
49
48
  @mutex = Mutex.new
50
49
  @pid = Process.pid
51
50
  end
@@ -133,7 +132,7 @@ module MessageBus
133
132
  listener = Listener.new
134
133
  yield listener
135
134
 
136
- conn = raw_pg_connection
135
+ conn = @subscribe_connection = raw_pg_connection
137
136
  conn.exec "LISTEN #{channel}"
138
137
  listener.do_sub.call
139
138
  while listening_on?(channel, obj)
@@ -147,6 +146,9 @@ module MessageBus
147
146
 
148
147
  conn.exec "UNLISTEN #{channel}"
149
148
  nil
149
+ ensure
150
+ @subscribe_connection&.close
151
+ @subscribe_connection = nil
150
152
  end
151
153
 
152
154
  def unsubscribe
@@ -253,7 +255,7 @@ module MessageBus
253
255
  @clear_every = config[:clear_every] || 1
254
256
  end
255
257
 
256
- # Reconnects to Postgres; used after a process fork, typically triggerd by a forking webserver
258
+ # Reconnects to Postgres; used after a process fork, typically triggered by a forking webserver
257
259
  # @see Base#after_fork
258
260
  def after_fork
259
261
  client.reconnect
@@ -279,7 +281,7 @@ module MessageBus
279
281
  msg = MessageBus::Message.new backlog_id, backlog_id, channel, data
280
282
  payload = msg.encode
281
283
  c.publish postgresql_channel_name, payload
282
- if backlog_id % clear_every == 0
284
+ if backlog_id && backlog_id % clear_every == 0
283
285
  max_backlog_size = (opts && opts[:max_backlog_size]) || self.max_backlog_size
284
286
  max_backlog_age = (opts && opts[:max_backlog_age]) || self.max_backlog_age
285
287
  c.clear_global_backlog(backlog_id, @max_global_backlog_size)
@@ -3,8 +3,6 @@
3
3
  require 'redis'
4
4
  require 'digest'
5
5
 
6
- require "message_bus/backends/base"
7
-
8
6
  module MessageBus
9
7
  module Backends
10
8
  # The Redis backend stores published messages in Redis sorted sets (using
@@ -64,7 +62,7 @@ module MessageBus
64
62
  @max_backlog_age = 604800
65
63
  end
66
64
 
67
- # Reconnects to Redis; used after a process fork, typically triggerd by a forking webserver
65
+ # Reconnects to Redis; used after a process fork, typically triggered by a forking webserver
68
66
  # @see Base#after_fork
69
67
  def after_fork
70
68
  pub_redis.disconnect!
@@ -104,8 +102,8 @@ module MessageBus
104
102
 
105
103
  local global_id = redis.call("INCR", global_id_key)
106
104
  local backlog_id = redis.call("INCR", backlog_id_key)
107
- local payload = string.format("%i|%i|%s", global_id, backlog_id, start_payload)
108
- local global_backlog_message = string.format("%i|%s", backlog_id, channel)
105
+ local payload = table.concat({ global_id, backlog_id, start_payload }, "|")
106
+ local global_backlog_message = table.concat({ backlog_id, channel }, "|")
109
107
 
110
108
  redis.call("ZADD", backlog_key, backlog_id, payload)
111
109
  redis.call("EXPIRE", backlog_key, max_backlog_age)
@@ -220,7 +220,7 @@ class MessageBus::Client
220
220
 
221
221
  private
222
222
 
223
- # heavily optimised to avoid all uneeded allocations
223
+ # heavily optimised to avoid all unneeded allocations
224
224
  NEWLINE = "\r\n".freeze
225
225
  COLON_SPACE = ": ".freeze
226
226
  HTTP_11 = "HTTP/1.1 200 OK\r\n".freeze
@@ -261,7 +261,7 @@ class MessageBus::Client
261
261
  @wrote_headers = true
262
262
  end
263
263
 
264
- # chunked encoding may be "re-chunked" by proxies, so add a seperator
264
+ # chunked encoding may be "re-chunked" by proxies, so add a separator
265
265
  postfix = NEWLINE + "|" + NEWLINE
266
266
  data = data.gsub(postfix, NEWLINE + "||" + NEWLINE)
267
267
  chunk_length = data.bytesize + postfix.bytesize
@@ -275,11 +275,7 @@ class MessageBus::Client
275
275
  @async_response << postfix
276
276
  @async_response << NEWLINE
277
277
  else
278
- @io.write(chunk_length.to_s(16))
279
- @io.write(NEWLINE)
280
- @io.write(data)
281
- @io.write(postfix)
282
- @io.write(NEWLINE)
278
+ @io.write(chunk_length.to_s(16) << NEWLINE << data << postfix << NEWLINE)
283
279
  end
284
280
  end
285
281
 
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MessageBus
4
+ module Codec
5
+ class Base
6
+ def encode(hash)
7
+ raise ConcreteClassMustImplementError
8
+ end
9
+
10
+ def decode(payload)
11
+ raise ConcreteClassMustImplementError
12
+ end
13
+ end
14
+
15
+ autoload :Json, File.expand_path("json", __dir__)
16
+ autoload :Oj, File.expand_path("oj", __dir__)
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MessageBus
4
+ module Codec
5
+ class Json < Base
6
+ def encode(hash)
7
+ JSON.dump(hash)
8
+ end
9
+
10
+ def decode(payload)
11
+ JSON.parse(payload)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj' unless defined? ::Oj
4
+
5
+ module MessageBus
6
+ module Codec
7
+ class Oj < Base
8
+ def initialize(options = { mode: :compat })
9
+ @options = options
10
+ end
11
+
12
+ def encode(hash)
13
+ ::Oj.dump(hash, @options)
14
+ end
15
+
16
+ def decode(payload)
17
+ ::Oj.load(payload, @options)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -37,7 +37,7 @@ class MessageBus::ConnectionManager
37
37
  rescue
38
38
  # pipe may be broken, move on
39
39
  end
40
- # turns out you can delete from a set while itereating
40
+ # turns out you can delete from a set while iterating
41
41
  remove_client(client) if client.closed?
42
42
  end
43
43
  end
@@ -45,7 +45,8 @@ module MessageBus
45
45
  hash = current.hash(message.site_id || DEFAULT_SITE_ID)
46
46
 
47
47
  case payload["op"]
48
- when "set" then hash[payload["key"]] = payload["marshalled"] ? Marshal.load(Base64.decode64(payload["value"])) : payload["value"]
48
+ # TODO: consider custom marshal support with a restricted set
49
+ when "set" then hash[payload["key"]] = payload["marshalled"] ? Marshal.load(Base64.decode64(payload["value"])) : payload["value"] # rubocop:disable Security/MarshalLoad
49
50
  when "delete" then hash.delete(payload["key"])
50
51
  when "clear" then hash.clear
51
52
  end
@@ -146,8 +146,8 @@ module MessageBus
146
146
  #
147
147
  # A last_message_id may be provided.
148
148
  # * -1 will subscribe to all new messages
149
- # * -2 will recieve last message + all new messages
150
- # * -3 will recieve last 2 message + all new messages
149
+ # * -2 will receive last message + all new messages
150
+ # * -3 will receive last 2 message + all new messages
151
151
  #
152
152
  # @example Subscribing to a channel with `last_message_id`
153
153
  # client.subscribe("/test", last_message_id: -2) do |payload|
@@ -13,6 +13,15 @@ class MessageBus::Rack::Diagnostics
13
13
  @bus = config[:message_bus] || MessageBus
14
14
  end
15
15
 
16
+ JS_ASSETS = %w{
17
+ jquery-1.8.2.js
18
+ react.js
19
+ react-dom.js
20
+ babel.min.js
21
+ message-bus.js
22
+ application.jsx
23
+ }
24
+
16
25
  # Process an HTTP request from a subscriber client
17
26
  # @param [Rack::Request::Env] env the request environment
18
27
  def call(env)
@@ -39,9 +48,9 @@ class MessageBus::Rack::Diagnostics
39
48
  end
40
49
 
41
50
  asset = route.split('/assets/')[1]
42
- if asset && !asset !~ /\//
51
+
52
+ if asset && JS_ASSETS.include?(asset)
43
53
  content = asset_contents(asset)
44
- split = asset.split('.')
45
54
  return [200, { 'Content-Type' => 'application/javascript;charset=UTF-8' }, [content]]
46
55
  end
47
56
 
@@ -75,6 +84,23 @@ class MessageBus::Rack::Diagnostics
75
84
  File.expand_path("../../../../assets/#{asset}", __FILE__)
76
85
  end
77
86
 
87
+ def script_tags
88
+ tags = []
89
+
90
+ JS_ASSETS.each do |asset|
91
+ type =
92
+ if asset.end_with?('.js')
93
+ 'text/javascript'
94
+ elsif asset.end_with?('.jsx')
95
+ 'text/jsx'
96
+ end
97
+
98
+ tags << js_asset(asset, type)
99
+ end
100
+
101
+ tags.join("\n")
102
+ end
103
+
78
104
  def index
79
105
  html = <<~HTML
80
106
  <!DOCTYPE html>
@@ -83,12 +109,8 @@ class MessageBus::Rack::Diagnostics
83
109
  </head>
84
110
  <body>
85
111
  <div id="app"></div>
86
- #{js_asset "jquery-1.8.2.js"}
87
- #{js_asset "react.js"}
88
- #{js_asset "react-dom.js"}
89
- #{js_asset "babel.min.js"}
90
- #{js_asset "message-bus.js"}
91
- #{js_asset "application.jsx", "text/jsx"}
112
+
113
+ #{script_tags}
92
114
  </body>
93
115
  </html>
94
116
  HTML
@@ -66,6 +66,12 @@ class MessageBus::Rack::Middleware
66
66
  private
67
67
 
68
68
  def handle_request(env)
69
+ # Prevent simple polling from clobbering the session
70
+ # See: https://github.com/discourse/message_bus/issues/257
71
+ if (rack_session_options = env[Rack::RACK_SESSION_OPTIONS])
72
+ rack_session_options[:skip] = true
73
+ end
74
+
69
75
  # special debug/test route
70
76
  if @bus.allow_broadcast? && env['PATH_INFO'] == @broadcast_route
71
77
  parsed = Rack::Request.new(env)
@@ -81,6 +87,22 @@ class MessageBus::Rack::Middleware
81
87
  client_id = env['PATH_INFO'][@base_route_length..-1].split("/")[0]
82
88
  return [404, {}, ["not found"]] unless client_id
83
89
 
90
+ headers = {}
91
+ headers["Cache-Control"] = "must-revalidate, private, max-age=0"
92
+ headers["Content-Type"] = "application/json; charset=utf-8"
93
+ headers["Pragma"] = "no-cache"
94
+ headers["Expires"] = "0"
95
+
96
+ if @bus.extra_response_headers_lookup
97
+ @bus.extra_response_headers_lookup.call(env).each do |k, v|
98
+ headers[k] = v
99
+ end
100
+ end
101
+
102
+ if env["REQUEST_METHOD"] == "OPTIONS"
103
+ return [200, headers, ["OK"]]
104
+ end
105
+
84
106
  user_id = @bus.user_id_lookup.call(env) if @bus.user_id_lookup
85
107
  group_ids = @bus.group_ids_lookup.call(env) if @bus.group_ids_lookup
86
108
  site_id = @bus.site_id_lookup.call(env) if @bus.site_id_lookup
@@ -111,22 +133,6 @@ class MessageBus::Rack::Middleware
111
133
  end
112
134
  end
113
135
 
114
- headers = {}
115
- headers["Cache-Control"] = "must-revalidate, private, max-age=0"
116
- headers["Content-Type"] = "application/json; charset=utf-8"
117
- headers["Pragma"] = "no-cache"
118
- headers["Expires"] = "0"
119
-
120
- if @bus.extra_response_headers_lookup
121
- @bus.extra_response_headers_lookup.call(env).each do |k, v|
122
- headers[k] = v
123
- end
124
- end
125
-
126
- if env["REQUEST_METHOD"] == "OPTIONS"
127
- return [200, headers, ["OK"]]
128
- end
129
-
130
136
  long_polling = @bus.long_polling_enabled? &&
131
137
  env['QUERY_STRING'] !~ /dlp=t/ &&
132
138
  @connection_manager.client_count < @bus.max_active_clients
@@ -38,7 +38,7 @@ module Thin
38
38
  end
39
39
  end
40
40
 
41
- # Response whos body is sent asynchronously.
41
+ # Response which body is sent asynchronously.
42
42
  class AsyncResponse
43
43
  include Rack::Response::Helpers
44
44
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MessageBus
4
- VERSION = "3.3.3"
4
+ VERSION = "3.3.7"
5
5
  end
data/lib/message_bus.rb CHANGED
@@ -2,18 +2,22 @@
2
2
 
3
3
  require "monitor"
4
4
  require "set"
5
- require "message_bus/version"
6
- require "message_bus/message"
7
- require "message_bus/client"
8
- require "message_bus/connection_manager"
9
- require "message_bus/diagnostics"
10
- require "message_bus/rack/middleware"
11
- require "message_bus/rack/diagnostics"
12
- require "message_bus/timer_thread"
5
+
6
+ require_relative "message_bus/version"
7
+ require_relative "message_bus/message"
8
+ require_relative "message_bus/client"
9
+ require_relative "message_bus/connection_manager"
10
+ require_relative "message_bus/diagnostics"
11
+ require_relative "message_bus/rack/middleware"
12
+ require_relative "message_bus/rack/diagnostics"
13
+ require_relative "message_bus/timer_thread"
14
+ require_relative "message_bus/codec/base"
15
+ require_relative "message_bus/backends"
16
+ require_relative "message_bus/backends/base"
13
17
 
14
18
  # we still need to take care of the logger
15
- if defined?(::Rails)
16
- require 'message_bus/rails/railtie'
19
+ if defined?(::Rails::Engine)
20
+ require_relative 'message_bus/rails/railtie'
17
21
  end
18
22
 
19
23
  # @see MessageBus::Implementation
@@ -96,14 +100,14 @@ module MessageBus::Implementation
96
100
  configure(long_polling_enabled: val)
97
101
  end
98
102
 
99
- # @param [Integer] val The number of simultanuous clients we can service;
103
+ # @param [Integer] val The number of simultaneous clients we can service;
100
104
  # will revert to polling if we are out of slots
101
105
  # @return [void]
102
106
  def max_active_clients=(val)
103
107
  configure(max_active_clients: val)
104
108
  end
105
109
 
106
- # @return [Integer] The number of simultanuous clients we can service;
110
+ # @return [Integer] The number of simultaneous clients we can service;
107
111
  # will revert to polling if we are out of slots. Defaults to 1000 if not
108
112
  # explicitly set.
109
113
  def max_active_clients
@@ -271,13 +275,24 @@ module MessageBus::Implementation
271
275
  # set, defaults to false unless we're in Rails test or development mode.
272
276
  def allow_broadcast?
273
277
  @config[:allow_broadcast] ||=
274
- if defined? ::Rails
278
+ if defined? ::Rails.env
275
279
  ::Rails.env.test? || ::Rails.env.development?
276
280
  else
277
281
  false
278
282
  end
279
283
  end
280
284
 
285
+ # @param [MessageBus::Codec::Base] codec used to encode and decode Message payloads
286
+ # @return [void]
287
+ def transport_codec=(codec)
288
+ configure(transport_codec: codec)
289
+ end
290
+
291
+ # @return [MessageBus::Codec::Base] codec used to encode and decode Message payloads
292
+ def transport_codec
293
+ @config[:transport_codec] ||= MessageBus::Codec::Json.new
294
+ end
295
+
281
296
  # @param [MessageBus::Backend::Base] pub_sub a configured backend
282
297
  # @return [void]
283
298
  def reliable_pub_sub=(pub_sub)
@@ -323,7 +338,7 @@ module MessageBus::Implementation
323
338
  # @option opts [Array<String,Integer>] :group_ids (`nil`) the group IDs to which the message should be available. If nil, available to all.
324
339
  # @option opts [String] :site_id (`nil`) the site ID to scope the message to; used for hosting multiple
325
340
  # applications or instances of an application against a single message_bus
326
- # @option opts [nil,Integer] :max_backlog_age the longest amount of time a message may live in a backlog before beging removed, in seconds
341
+ # @option opts [nil,Integer] :max_backlog_age the longest amount of time a message may live in a backlog before being removed, in seconds
327
342
  # @option opts [nil,Integer] :max_backlog_size the largest permitted size (number of messages) for the channel backlog; beyond this capacity, old messages will be dropped
328
343
  #
329
344
  # @return [Integer] the channel-specific ID the message was given
@@ -358,18 +373,18 @@ module MessageBus::Implementation
358
373
  raise ::MessageBus::InvalidMessageTarget
359
374
  end
360
375
 
361
- encoded_data = JSON.dump(
362
- data: data,
363
- user_ids: user_ids,
364
- group_ids: group_ids,
365
- client_ids: client_ids
366
- )
376
+ encoded_data = transport_codec.encode({
377
+ "data" => data,
378
+ "user_ids" => user_ids,
379
+ "group_ids" => group_ids,
380
+ "client_ids" => client_ids
381
+ })
367
382
 
368
383
  channel_opts = {}
369
384
 
370
385
  if opts
371
386
  if ((age = opts[:max_backlog_age]) || (size = opts[:max_backlog_size]))
372
- channel_opts[:max_backlog_size] = size,
387
+ channel_opts[:max_backlog_size] = size
373
388
  channel_opts[:max_backlog_age] = age
374
389
  end
375
390
 
@@ -512,7 +527,7 @@ module MessageBus::Implementation
512
527
  end
513
528
 
514
529
  # Stops listening for publications and stops executing scheduled tasks.
515
- # Mostly used in tests to detroy entire bus.
530
+ # Mostly used in tests to destroy entire bus.
516
531
  # @return [void]
517
532
  def destroy
518
533
  return if @destroyed
@@ -626,7 +641,7 @@ module MessageBus::Implementation
626
641
  channel, site_id = decode_channel_name(msg.channel)
627
642
  msg.channel = channel
628
643
  msg.site_id = site_id
629
- parsed = JSON.parse(msg.data)
644
+ parsed = transport_codec.decode(msg.data)
630
645
  msg.data = parsed["data"]
631
646
  msg.user_ids = parsed["user_ids"]
632
647
  msg.group_ids = parsed["group_ids"]
data/message_bus.gemspec CHANGED
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
- # -*- encoding: utf-8 -*-
3
2
 
4
3
  require File.expand_path('../lib/message_bus/version', __FILE__)
5
4
 
@@ -8,17 +7,33 @@ Gem::Specification.new do |gem|
8
7
  gem.email = ["sam.saffron@gmail.com"]
9
8
  gem.description = %q{A message bus for rack}
10
9
  gem.summary = %q{}
11
- gem.homepage = "https://github.com/SamSaffron/message_bus"
10
+ gem.homepage = "https://github.com/discourse/message_bus"
12
11
  gem.license = "MIT"
13
- gem.files = `git ls-files`.split($\) +
14
- ["vendor/assets/javascripts/message-bus.js", "vendor/assets/javascripts/message-bus-ajax.js"]
12
+ gem.files = `git ls-files`.split($\)
15
13
  gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
16
14
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
17
15
  gem.name = "message_bus"
18
16
  gem.require_paths = ["lib"]
19
17
  gem.version = MessageBus::VERSION
20
- gem.required_ruby_version = ">= 2.3.0"
18
+ gem.required_ruby_version = ">= 2.6.0"
19
+
21
20
  gem.add_runtime_dependency 'rack', '>= 1.1.3'
21
+
22
22
  gem.add_development_dependency 'redis'
23
23
  gem.add_development_dependency 'pg'
24
+ gem.add_development_dependency 'concurrent-ruby' # for distributed-cache
25
+ gem.add_development_dependency 'minitest'
26
+ gem.add_development_dependency 'minitest-hooks'
27
+ gem.add_development_dependency 'minitest-global_expectations'
28
+ gem.add_development_dependency 'rake'
29
+ gem.add_development_dependency 'http_parser.rb'
30
+ gem.add_development_dependency 'thin'
31
+ gem.add_development_dependency 'rack-test'
32
+ gem.add_development_dependency 'puma'
33
+ gem.add_development_dependency 'm'
34
+ gem.add_development_dependency 'byebug'
35
+ gem.add_development_dependency 'oj'
36
+ gem.add_development_dependency 'yard'
37
+ gem.add_development_dependency 'rubocop-discourse'
38
+ gem.add_development_dependency 'rubocop-rspec'
24
39
  end