ruby_nest_nats 0.1.1 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 655b85cbd8f941a46595d30adde13826cf69e8978a829653596aaa1ebd4d6437
4
- data.tar.gz: 1d72decb7def437e62a6302d1885ef18e66940285ccd10abc3bb34b34e44bbd3
3
+ metadata.gz: 115d647c8fddd551ecd29db19c99ecc6ee958b80406322141d9773219dd24345
4
+ data.tar.gz: b000384383eef9cf1f484195115aba6ef9bb4727b5baa852a818006a8bbbf876
5
5
  SHA512:
6
- metadata.gz: 17de52f910afb1eb74da267e29f80cbcfdfb0e2f929c4cc8912b4d7dcb8c445c15a063eb30c11a74b2504ec51196a1af72f141b05a9f96ada6103e8cb519c617
7
- data.tar.gz: ffb654a55f20b835fb7e516af5425c192627146a3be9e2fb61cb07f4de79357f7ee6f7eeb2a1f4a36642fb719270ec30005eef39fe2961b65813157c26d49b4d
6
+ metadata.gz: dbe204fb29c5acc514ff48c026ce34f93e297bcaebceb5e6fade77512e373e2a722b733dc92ac8e5b1228cf9e551f138659ad7511e6ff8a7d9d23740a0e91e74
7
+ data.tar.gz: 3f2b68a7473b4facddbc954aae0fd38b5d0a1e9b2a72adfd69415a1e7ec712a4d2723a04935d9a30aa25aa83aa681262149aab3047da3d26333d2e98125834fe
data/.rubocop.yml CHANGED
@@ -26,6 +26,7 @@ Layout/FirstArrayElementIndentation:
26
26
  # Metrics
27
27
 
28
28
  Metrics/AbcSize:
29
+ Max: 30
29
30
  CountRepeatedAttributes: false
30
31
  Exclude:
31
32
  - 'spec/**/*_spec.rb'
data/README.md CHANGED
@@ -1,38 +1,318 @@
1
1
  # RubyNestNats
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/ruby_nest_nats`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ The `ruby_nest_nats` gem allows you to listen for (and reply to) NATS messages asynchronously in a Ruby application.
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
5
+ ## TODO
6
+
7
+ - [x] docs
8
+ - [ ] tests
9
+ - [x] "controller"-style classes for reply organization
10
+ - [x] runtime subscription additions
11
+ - [x] multiple queues
12
+ - [ ] `on_error` handler so you can send a response (what's standard?)
13
+ - [ ] config options for URL/host/port/etc.
14
+ - [ ] config for restart behavior (default is to restart listening on any `StandardError`)
15
+ - [ ] consider using child processes instead of threads
6
16
 
7
17
  ## Installation
8
18
 
9
- Add this line to your application's Gemfile:
19
+ ### Locally (to your application)
20
+
21
+ Add the gem to your application's `Gemfile`:
10
22
 
11
23
  ```ruby
12
24
  gem 'ruby_nest_nats'
13
25
  ```
14
26
 
15
- And then execute:
27
+ ...and then run:
28
+
29
+ ```bash
30
+ bundle install
31
+ ```
16
32
 
17
- $ bundle install
33
+ ### Globally (to your system)
34
+
35
+ Alternatively, install it globally:
36
+
37
+ ```bash
38
+ gem install ruby_nest_nats
39
+ ```
18
40
 
19
- Or install it yourself as:
41
+ ### NATS server (important!)
20
42
 
21
- $ gem install ruby_nest_nats
43
+ This gem also requires a NATS server to be installed and running before use. See [the NATS documentation](https://docs.nats.io/nats-server/installation) for more details.
22
44
 
23
45
  ## Usage
24
46
 
25
- TODO: Write usage instructions here
47
+ ### Starting the NATS server
48
+
49
+ You'll need to start a NATS server before running your Ruby application. If you installed it via Docker, you might start it like so:
50
+
51
+ ```bash
52
+ docker run -p 4222:4222 -p 8222:8222 -p 6222:6222 -ti nats:latest
53
+ ```
54
+
55
+ > **NOTE:** You may need to run that command with `sudo` on some systems, depending on the permissions of your Docker installation.
56
+
57
+ > **NOTE:** For other methods of running a NATS server, see [the NATS documentation](https://docs.nats.io/nats-server/installation).
58
+
59
+ ### Logging
60
+
61
+ #### Attaching a logger
62
+
63
+ Attach a logger to have `ruby_nest_nats` write out logs for messages received, responses sent, errors raised, lifecycle events, etc.
64
+
65
+ ```ruby
66
+ require 'ruby_nest_nats'
67
+ require 'logger'
68
+
69
+ nats_logger = Logger.new(STDOUT)
70
+ nats_logger.level = Logger::INFO
71
+
72
+ RubyNestNats::Client.logger = nats_logger
73
+ ```
74
+
75
+ In a Rails application, you might do this instead:
76
+
77
+ ```ruby
78
+ RubyNestNats::Client.logger = Rails.logger
79
+ ```
80
+
81
+ #### Log levels
82
+
83
+ The following will be logged at the specified log levels
84
+
85
+ - `DEBUG`: Lifecycle events (starting NATS listeners, stopping NATS, reply registration, setting the default queue, etc.), as well as everything under `INFO`, `WARN`, and `ERROR`
86
+ - `INFO`: Message activity over NATS (received a message, replied with a message, etc.), as well as everything under `WARN` and `ERROR`
87
+ - `WARN`: Error handled gracefully (listening restarted due to some exception, etc.), as well as everything under `ERROR`
88
+ - `ERROR`: Some exception was raised in-thread (error in handler, error in subscription, etc.)
89
+
90
+ <a id="default-queue-section"></a>
91
+
92
+ ### Setting a default queue
93
+
94
+ Set a default queue for subscriptions.
95
+
96
+ ```ruby
97
+ RubyNestNats::Client.default_queue = "foobar"
98
+ ```
99
+
100
+ Leave the `::default_queue` blank (or assign `nil`) to use no default queue.
101
+
102
+ ```ruby
103
+ RubyNestNats::Client.default_queue = nil
104
+ ```
105
+
106
+ <a id="reply-to-section"></a>
107
+
108
+ ### Registering message handlers
109
+
110
+ Register a message handler with the `RubyNestNats::Client::reply_to` method. Pass a subject string as the first argument (either a static subject string or a pattern to match more than one subject). Specify a queue (or don't) with the `queue:` option. If you don't provide the `queue:` option, it will be set to the value of `default_queue`, or to `nil` (no queue) if a default queue hasn't been set.
111
+
112
+ The result of the given block will be published in reply to the message. The block is passed two arguments when a message matching the subject is received: `data` and `subject`. The `data` argument is the payload of the message (JSON objects/arrays will be parsed into string-keyed `Hash` objects/`Array` objects, respectively). The `subject` argument is the subject of the message received (mostly only useful if a _pattern_ was specified instead of a static subject string).
113
+
114
+ ```ruby
115
+ RubyNestNats::Client.reply_to("some.subject", queue: "foobar") { |data| "Got it! #{data.inspect}" }
116
+
117
+ RubyNestNats::Client.reply_to("some.*.pattern") { |data, subject| "Got #{data} on #{subject}" }
118
+
119
+ RubyNestNats::Client.reply_to("other.subject") do |data|
120
+ if data["foo"] == "bar"
121
+ { is_bar: "Yep!" }
122
+ else
123
+ { is_bar: "No way!" }
124
+ end
125
+ end
126
+
127
+ RubyNestNats::Client.reply_to("subject.in.queue", queue: "barbaz") do
128
+ "My turn!"
129
+ end
130
+ ```
131
+
132
+ ### Starting the listeners
133
+
134
+ Start listening for messages with the `RubyNestNats::Client::start!` method. This will spin up a non-blocking thread that subscribes to subjects (as specified by invocation(s) of `::reply_to`) and waits for messages to come in. When a message is received, the appropriate `::reply_to` block will be used to compute a response, and that response will be published.
135
+
136
+ ```ruby
137
+ RubyNestNats::Client.start!
138
+ ```
139
+
140
+ > **NOTE:** If an error is raised in one of the handlers, `RubyNestNats::Client` will restart automatically.
141
+
142
+ > **NOTE:** You _can_ invoke `::reply_to` to create additional message subscriptions after `RubyNestNats::Client.start!`, but be aware that this forces the client to restart. You may see (benign, already-handled) errors in the logs generated when this restart happens. It will force the client to restart and re-subscribe after _each additional `::reply_to` invoked after `::start!`._ So, if you have a lot of additional `::reply_to` invocations, you may want to consider refactoring so that your call to `RubyNestNats::Client.start!` occurs _after_ those additions.
143
+
144
+ > **NOTE:** The `::start!` method can be safely called multiple times; only the first will be honored, and any subsequent calls to `::start!` after the client is already started will do nothing (except write a _"NATS is already running"_ log to the logger at the `DEBUG` level).
145
+
146
+ ### Basic full working example (in vanilla Ruby)
147
+
148
+ The following should be enough to start a `ruby_nest_nats` setup in your Ruby application, using what we've learned so far.
149
+
150
+ > **NOTE:** For a more organized structure and implementation in a larger app (like a Rails project), see the ["controller" section below](#controller-section).
151
+
152
+ ```ruby
153
+ require 'ruby_nest_nats'
154
+ require 'logger'
155
+
156
+ nats_logger = Logger.new(STDOUT)
157
+ nats_logger.level = Logger::DEBUG
158
+
159
+ RubyNestNats::Client.logger = nats_logger
160
+ RubyNestNats::Client.default_queue = "foobar"
161
+
162
+ RubyNestNats::Client.reply_to("some.subject") { |data| "Got it! #{data.inspect}" }
163
+ RubyNestNats::Client.reply_to("some.*.pattern") { |data, subject| "Got #{data} on #{subject}" }
164
+ RubyNestNats::Client.reply_to("subject.in.queue", queue: "barbaz") { { msg: "My turn!", turn: 5 } }
165
+
166
+ RubyNestNats::Client.start!
167
+ ```
168
+
169
+ <a id="controller-section"></a>
170
+
171
+ ### Creating "controller"-style classes for listener organization
172
+
173
+ Create controller classes which inherit from `RubyNestNats::Controller` in order to give your message listeners some structure.
174
+
175
+ Use the `::default_queue` macro to set a default queue string. If omitted, the controller will fall back on the global default queue assigned with `RubyNestNats::Client::default_queue=` (as described [here](#default-queue-section)). If no default queue is set in either the controller or globally, then the default queue will be blank. Set the default queue to `nil` in a controller to override the global default queue and explicitly make the default queue blank for that controller.
176
+
177
+ Use the `::subject` macro to create a block for listening to that subject segment. Nested calls to `::subject` will append each subsequent subject/pattern string to the last (joined by a periods). There is no limit to the level of nesting.
178
+
179
+ You can register a response for the built-up subject/pattern string using the `::response` macro. Pass a block to `::response` which optionally takes two arguments ([the same arguments supplied to the block of `RubyNestNats::Client::reply_to`](#reply-to-section)). The result of that block will be sent as a response to the message received.
180
+
181
+ ```ruby
182
+ class HelloController < RubyNestNats::Controller
183
+ default_queue "foobar"
184
+
185
+ subject "hello" do
186
+ subject "jerk" do
187
+ response do |data|
188
+ # The subject at this point is "hello.jerk"
189
+ "Hey #{data['name']}... that's not cool, man."
190
+ end
191
+ end
192
+
193
+ subject "and" do
194
+ subject "wassup" do
195
+ response do |data|
196
+ # The subject at this point is "hello.and.wassup"
197
+ "Hey, how ya doin', #{data['name']}?"
198
+ end
199
+ end
200
+
201
+ subject "goodbye" do
202
+ response do |data|
203
+ # The subject at this point is "hello.and.goodbye"
204
+ "Hi #{data['name']}! But also GOODBYE."
205
+ end
206
+ end
207
+ end
208
+ end
209
+
210
+ subject "hows" do
211
+ subject "*" do
212
+ subject "doing" do
213
+ response do |data, subject|
214
+ # The subject at this point is "hows.<wildcard>.doing" (i.e., the
215
+ # subjects "hows.jack.doing" and "hows.jill.doing" will both match)
216
+ sender_name = data["name"]
217
+ other_person_name = subject.split(".")[1]
218
+ desc = rand < 0.5 ? "terribly" : "great"
219
+ "Well, #{sender_name}, #{other_person_name} is actually doing #{desc}."
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+ ```
226
+
227
+ > **NOTE:** If you implement controllers like this and you are using code-autoloading machinery (like Zeitwerk in Rails), you will need to make sure these paths are eager-loaded when your app starts. **If you don't, `ruby_nest_nats` will not register the listeners,** and will not respond to messages for the specified subjects.
228
+ >
229
+ > For example: in a Rails project (assuming you have your NATS controllers in a directory called `app/nats/`), you may want to put something like the following in an initializer (such as `config/initializers/nats.rb`):
230
+ >
231
+ > ```ruby
232
+ > RubyNestNats::Client.logger = Rails.logger
233
+ > RubyNestNats::Client.default_queue = "foobar"
234
+ >
235
+ > # ...
236
+ >
237
+ > Rails.application.config.after_initialize do
238
+ > nats_controller_paths = Dir[Rails.root.join("app", "nats", "**", "*_controller.rb")]
239
+ > nats_controller_paths.each { |file_path| require_dependency(file_path) }
240
+ >
241
+ > RubyNestNats::Client.start!
242
+ > end
243
+ > ```
26
244
 
27
245
  ## Development
28
246
 
247
+ ### Install dependencies
248
+
249
+ To install the Ruby dependencies, run:
250
+
251
+ ```bash
252
+ bin/setup
253
+ ```
254
+
255
+ This gem also requires a NATS server to be installed and running. See [the NATS documentation](https://docs.nats.io/nats-server/installation) for more details.
256
+ <!-- sudo docker run -p 4222:4222 -p 8222:8222 -p 6222:6222 -ti nats:latest -->
257
+ <!-- nats-tail -s nats://localhost:4222 ">" -->
258
+ <!-- curl --data '{"name":"Keegan"}' --header 'Content-Type: application/json' http://localhost:3000/hello -->
259
+
260
+ ### Open a console
261
+
262
+ To open a REPL with the gem's code loaded, run:
263
+
264
+ ```bash
265
+ bin/console
266
+ ```
267
+
268
+ ### Run the tests
269
+
270
+ To run the RSpec test suites, run:
271
+
272
+ ```bash
273
+ bundle exec rake spec
274
+ ```
275
+
276
+ ...or (if your Ruby setup has good defaults) just this:
277
+
278
+ ```bash
279
+ rake spec
280
+ ```
281
+
282
+ ### Run the linter
283
+
284
+ ```bash
285
+ bundle exec rubocop
286
+ ```
287
+
288
+ ### Create a release
289
+
290
+ Bump the `RubyNestNats::VERSION` value in `lib/ruby_nest_nats/version.rb`, commit, and then run:
291
+
292
+ ```bash
293
+ bundle exec rake release
294
+ ```
295
+
296
+ ...or (if your Ruby setup has good defaults) just this:
297
+
298
+ ```bash
299
+ rake release
300
+ ```
301
+
302
+ This will:
303
+
304
+ 1. create a git tag for the new version,
305
+ 1. push the commits,
306
+ 1. build the gem, and
307
+ 1. push it to [rubygems.org](https://rubygems.org/gems/ruby_nest_nats).
308
+
29
309
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
310
 
31
311
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
312
 
33
313
  ## Contributing
34
314
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/ruby_nest_nats.
315
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Openbay/ruby_nest_nats.
36
316
 
37
317
  ## License
38
318
 
@@ -1,59 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "ruby_nest_nats/version"
4
3
  require "nats/client"
4
+ require_relative "ruby_nest_nats/version"
5
+ require_relative "ruby_nest_nats/utils"
6
+ require_relative "ruby_nest_nats/client"
7
+ require_relative "ruby_nest_nats/controller"
5
8
 
9
+ # The +RubyNestNats+ module provides the top-level namespace for the NATS client
10
+ # and controller machinery.
6
11
  module RubyNestNats
12
+ # :nodoc:
7
13
  class Error < StandardError; end
8
14
 
9
- class Client
10
- class << self
11
- def queue=(some_queue)
12
- @queue = some_queue.to_s
13
- end
14
-
15
- def queue
16
- @queue
17
- end
18
-
19
- def replies
20
- @replies ||= []
21
- end
22
-
23
- def started?
24
- !!@started
25
- end
26
-
27
- def reply_to(raw_subject, with:)
28
- subject = raw_subject.to_s
29
-
30
- if started?
31
- raise StandardError, "NATS already started"
32
- elsif !with.respond_to?(:call)
33
- raise ArgumentError, "Option `:with` must be callable"
34
- elsif replies.any? { |reply| reply[:subject] == subject }
35
- raise ArgumentError, "Already registered a reply to #{subject}"
36
- end
37
-
38
- replies << { subject: subject, handler: with, queue: queue }
39
- end
40
-
41
- def start!
42
- @started = true
43
-
44
- fiber = Fiber.new do
45
- NATS.start do
46
- replies.each do |replier|
47
- NATS.subscribe(replier[:subject], queue: replier[:queue]) do |message, reply, _subject|
48
- response = replier[:handler].call(JSON.parse(message)["data"])
49
- NATS.publish(reply, response.to_json, queue: reply[:queue])
50
- end
51
- end
52
- end
53
- end
54
-
55
- fiber.resume
56
- end
57
- end
58
- end
15
+ # :nodoc:
16
+ class NewSubscriptionsError < RubyNestNats::Error; end
59
17
  end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "nats/client"
5
+ require_relative "./utils"
6
+
7
+ module RubyNestNats
8
+ # The +RubyNestNats::Client+ class provides a basic interface for subscribing
9
+ # to messages by subject & queue, and replying to those messages. It also logs
10
+ # most functionality if desired.
11
+ class Client
12
+ class << self
13
+ # :nodoc:
14
+ attr_reader :logger, :default_queue
15
+
16
+ # Attach a logger to have +ruby_nest_nats+ write out logs for messages
17
+ # received, responses sent, errors raised, lifecycle events, etc.
18
+ #
19
+ # @example
20
+ # require 'ruby_nest_nats'
21
+ # require 'logger'
22
+ #
23
+ # nats_logger = Logger.new(STDOUT)
24
+ # nats_logger.level = Logger::INFO
25
+ #
26
+ # RubyNestNats::Client.logger = nats_logger
27
+ #
28
+ # In a Rails application, you might do this instead:
29
+ #
30
+ # @example
31
+ # RubyNestNats::Client.logger = Rails.logger
32
+ #
33
+ def logger=(some_logger)
34
+ log("Setting the logger to #{some_logger.inspect}")
35
+ @logger = some_logger
36
+ end
37
+
38
+ # Set a default queue for subscriptions.
39
+ #
40
+ # @example
41
+ # RubyNestNats::Client.default_queue = "foobar"
42
+ #
43
+ # Leave the +::default_queue+ blank (or assign +nil+) to use no default
44
+ # queue.
45
+ #
46
+ # @example
47
+ # RubyNestNats::Client.default_queue = nil
48
+ #
49
+ def default_queue=(some_queue)
50
+ queue = Utils.presence(some_queue.to_s)
51
+ log("Setting the default queue to #{queue || '(none)'}", level: :debug)
52
+ @default_queue = queue
53
+ end
54
+
55
+ # Returns +true+ if +::start!+ has already been called (meaning the client
56
+ # is listening to NATS messages). Returns +false+ if it has not yet been
57
+ # called, or if it has been stopped.
58
+ def started?
59
+ @started ||= false
60
+ end
61
+
62
+ # Opposite of +::started?+: returns +false+ if +::start!+ has already been
63
+ # called (meaning the client is listening to NATS messages). Returns
64
+ # +true+ if it has not yet been called, or if it has been stopped.
65
+ def stopped?
66
+ !started?
67
+ end
68
+
69
+ # Register a message handler with the +RubyNestNats::Client::reply_to+
70
+ # method. Pass a subject string as the first argument (either a static
71
+ # subject string or a pattern to match more than one subject). Specify a
72
+ # queue (or don't) with the +queue:+ option. If you don't provide the
73
+ # +queue:+ option, it will be set to the value of +default_queue+, or to
74
+ # +nil+ (no queue) if a default queue hasn't been set.
75
+ #
76
+ # The result of the given block will be published in reply to the message.
77
+ # The block is passed two arguments when a message matching the subject is
78
+ # received: +data+ and +subject+. The +data+ argument is the payload of
79
+ # the message (JSON objects/arrays will be parsed into string-keyed +Hash+
80
+ # objects/+Array+ objects, respectively). The +subject+ argument is the
81
+ # subject of the message received (mostly only useful if a _pattern_ was
82
+ # specified instead of a static subject string).
83
+ #
84
+ # @example
85
+ # RubyNestNats::Client.reply_to("some.subject", queue: "foobar") { |data| "Got it! #{data.inspect}" }
86
+ #
87
+ # RubyNestNats::Client.reply_to("some.*.pattern") { |data, subject| "Got #{data} on #{subject}" }
88
+ #
89
+ # RubyNestNats::Client.reply_to("other.subject") do |data|
90
+ # if data["foo"] == "bar"
91
+ # { is_bar: "Yep!" }
92
+ # else
93
+ # { is_bar: "No way!" }
94
+ # end
95
+ # end
96
+ #
97
+ # RubyNestNats::Client.reply_to("subject.in.queue", queue: "barbaz") do
98
+ # "My turn!"
99
+ # end
100
+ #
101
+ def reply_to(subject, queue: nil, &block)
102
+ queue = Utils.presence(queue) || default_queue
103
+ queue_desc = " in queue '#{queue}'" if queue
104
+ log("Registering a reply handler for subject '#{subject}'#{queue_desc}", level: :debug)
105
+ register_reply!(subject: subject.to_s, handler: block, queue: queue.to_s)
106
+ end
107
+
108
+ # Start listening for messages with the +RubyNestNats::Client::start!+
109
+ # method. This will spin up a non-blocking thread that subscribes to
110
+ # subjects (as specified by invocation(s) of +::reply_to+) and waits for
111
+ # messages to come in. When a message is received, the appropriate
112
+ # +::reply_to+ block will be used to compute a response, and that response
113
+ # will be published.
114
+ #
115
+ # @example
116
+ # RubyNestNats::Client.start!
117
+ #
118
+ # **NOTE:** If an error is raised in one of the handlers,
119
+ # +RubyNestNats::Client+ will restart automatically.
120
+ #
121
+ # **NOTE:** You _can_ invoke +::reply_to+ to create additional message
122
+ # subscriptions after +RubyNestNats::Client.start!+, but be aware that
123
+ # this forces the client to restart. You may see (benign, already-handled)
124
+ # errors in the logs generated when this restart happens. It will force
125
+ # the client to restart and re-subscribe after _each additional
126
+ # +::reply_to+ invoked after +::start!+._ So, if you have a lot of
127
+ # additional +::reply_to+ invocations, you may want to consider
128
+ # refactoring so that your call to +RubyNestNats::Client.start!+ occurs
129
+ # _after_ those additions.
130
+ #
131
+ # **NOTE:** The +::start!+ method can be safely called multiple times;
132
+ # only the first will be honored, and any subsequent calls to +::start!+
133
+ # after the client is already started will do nothing (except write a
134
+ # _"NATS is already running"_ log to the logger at the +DEBUG+ level).
135
+ #
136
+ def start!
137
+ log("Starting NATS", level: :debug)
138
+
139
+ if started?
140
+ log("NATS is already running", level: :debug)
141
+ return
142
+ end
143
+
144
+ started!
145
+
146
+ self.current_thread = Thread.new do
147
+ Thread.handle_interrupt(StandardError => :never) do
148
+ Thread.handle_interrupt(StandardError => :immediate) { listen }
149
+ rescue NATS::ConnectError => e
150
+ log("Could not connect to NATS server:", level: :error)
151
+ log(e.full_message, level: :error, indent: 2)
152
+ Thread.current.exit
153
+ rescue NewSubscriptionsError => e
154
+ log("New subscriptions! Restarting...", level: :info)
155
+ restart!
156
+ raise e # TODO: there has to be a better way
157
+ rescue StandardError => e
158
+ log("Encountered an error:", level: :error)
159
+ log(e.full_message, level: :error, indent: 2)
160
+ restart!
161
+ raise e
162
+ end
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ def log(text, level: :info, indent: 0)
169
+ return unless logger
170
+
171
+ timestamp = Time.now.to_s
172
+ text_lines = text.split("\n")
173
+ indentation = indent.is_a?(String) ? indent : (" " * indent)
174
+
175
+ text_lines.each do |line|
176
+ logger.send(level, "[#{timestamp}] RubyNestNats | #{indentation}#{line}")
177
+ end
178
+ end
179
+
180
+ def stop!
181
+ log("Stopping NATS", level: :debug)
182
+
183
+ begin
184
+ NATS.stop
185
+ rescue StandardError
186
+ nil
187
+ end
188
+
189
+ stopped!
190
+ end
191
+
192
+ def restart!
193
+ log("Restarting NATS", level: :warn)
194
+ stop!
195
+ start!
196
+ end
197
+
198
+ def started!
199
+ @started = true
200
+ end
201
+
202
+ def stopped!
203
+ @started = false
204
+ end
205
+
206
+ def replies
207
+ @replies ||= []
208
+ end
209
+
210
+ attr_accessor :current_thread
211
+
212
+ def reply_registered?(raw_subject)
213
+ subject = raw_subject.to_s
214
+ replies.any? { |reply| reply[:subject] == subject }
215
+ end
216
+
217
+ def register_reply!(subject:, handler:, queue: nil)
218
+ raise ArgumentError, "Subject must be a string" unless subject.is_a?(String)
219
+ raise ArgumentError, "Must provide a message handler for #{subject}" unless handler.respond_to?(:call)
220
+ raise ArgumentError, "Already registered a reply to #{subject}" if reply_registered?(subject)
221
+
222
+ reply = {
223
+ subject: subject,
224
+ handler: handler,
225
+ queue: Utils.presence(queue) || default_queue,
226
+ }
227
+
228
+ replies << reply
229
+
230
+ current_thread.raise(NewSubscriptionsError, "New reply registered") if started?
231
+ end
232
+
233
+ def listen
234
+ NATS.start do
235
+ replies.each do |replier|
236
+ queue_desc = " in queue '#{replier[:queue]}'" if replier[:queue]
237
+ log("Subscribing to subject '#{replier[:subject]}'#{queue_desc}", level: :debug)
238
+
239
+ NATS.subscribe(replier[:subject], queue: replier[:queue]) do |message, inbox, subject|
240
+ parsed_message = JSON.parse(message)
241
+ id, data, pattern = parsed_message.values_at("id", "data", "pattern")
242
+
243
+ log("Received a message!")
244
+ message_desc = <<~LOG_MESSAGE
245
+ id: #{id || '(none)'}
246
+ pattern: #{pattern || '(none)'}
247
+ subject: #{subject || '(none)'}
248
+ data: #{data.to_json}
249
+ inbox: #{inbox || '(none)'}
250
+ LOG_MESSAGE
251
+ log(message_desc, indent: 2)
252
+
253
+ response_data = replier[:handler].call(data)
254
+
255
+ log("Responding with '#{response_data}'")
256
+
257
+ NATS.publish(inbox, response_data.to_json, queue: replier[:queue])
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./utils"
4
+
5
+ module RubyNestNats
6
+ # Create controller classes which inherit from +RubyNestNats::Controller+ in
7
+ # order to give your message listeners some structure.
8
+ class Controller
9
+ # :nodoc:
10
+ NO_QUEUE_GIVEN = :ruby_nest_nats_super_special_no_op_queue_symbol_qwertyuiop1234567890
11
+
12
+ class << self
13
+ # Default queue for the controller. Falls back to the client's default
14
+ # queue if the controller's default queue is +nil+.
15
+ #
16
+ # - Call with no argument (+::default_queue+) to get the default queue.
17
+ # - Call as a macro with an argument (+default_queue "something"+) to set
18
+ # the default queue.
19
+ #
20
+ # @example
21
+ # class FoobarNatsController < RubyNatsController
22
+ # default_queue "foobar"
23
+ #
24
+ # # ...
25
+ # end
26
+ #
27
+ # If omitted, the controller will fall back on the global default queue
28
+ # assigned with +RubyNestNats::Client::default_queue=+. If no default
29
+ # queue is set in either the controller or globally, then the default
30
+ # queue will be blank. Set the default queue to +nil+ in a controller to
31
+ # override the global default queue and explicitly make the default queue
32
+ # blank for that controller.
33
+ #
34
+ def default_queue(some_queue = NO_QUEUE_GIVEN)
35
+ # +NO_QUEUE_GIVEN+ is a special symbol (rather than +nil+) so that the
36
+ # default queue can be "unset" to +nil+ (given a non-+nil+ global
37
+ # default set with +RubyNestNats::Client::default_queue=+).
38
+ if some_queue == NO_QUEUE_GIVEN
39
+ @default_queue || Client.default_queue
40
+ else
41
+ @default_queue = Utils.presence(some_queue.to_s)
42
+ end
43
+ end
44
+
45
+ # Use the +::subject+ macro to create a block for listening to that
46
+ # subject segment. Nested calls to +::subject+ will append each subsequent
47
+ # subject/pattern string to the last (joined by a periods). There is no
48
+ # limit to the level of nesting.
49
+ #
50
+ # **NOTE:** The following two examples do exactly the same thing.
51
+ #
52
+ # @example
53
+ # class FoobarNatsController < RubyNatsController
54
+ # # ...
55
+ #
56
+ # subject "hello.wassup" do
57
+ # response do |data, subject|
58
+ # # The subject at this point is "hello.wassup"
59
+ # # ...
60
+ # end
61
+ # end
62
+ #
63
+ # subject "hello.howdy" do
64
+ # response do |data, subject|
65
+ # # The subject at this point is "hello.howdy"
66
+ # # ...
67
+ # end
68
+ # end
69
+ # end
70
+ #
71
+ # @example
72
+ # class FoobarNatsController < RubyNatsController
73
+ # # ...
74
+ #
75
+ # subject "hello" do
76
+ # subject "wassup" do
77
+ # response do |data, subject|
78
+ # # The subject at this point is "hello.wassup"
79
+ # # ...
80
+ # end
81
+ # end
82
+ #
83
+ # subject "howdy" do
84
+ # response do |data, subject|
85
+ # # The subject at this point is "hello.howdy"
86
+ # # ...
87
+ # end
88
+ # end
89
+ # end
90
+ # end
91
+ #
92
+ def subject(subject_segment, queue: nil)
93
+ subject_chain.push(subject_segment)
94
+ old_queue = current_queue
95
+ self.current_queue = queue if Utils.present?(queue)
96
+ yield
97
+ self.current_queue = old_queue
98
+ subject_chain.pop
99
+ end
100
+
101
+ # You can register a response for the built-up subject/pattern string
102
+ # using the +::response+ macro. Pass a block to +::response+ which
103
+ # optionally takes two arguments (the same arguments supplied to the block
104
+ # of +RubyNestNats::Client::reply_to+). The result of that block will be
105
+ # sent as a response to the message received.
106
+ #
107
+ # @example
108
+ # class FoobarNatsController < RubyNatsController
109
+ # # ...
110
+ #
111
+ # subject "hello" do
112
+ # subject "wassup" do
113
+ # response do |data, subject|
114
+ # # The subject at this point is "hello.wassup".
115
+ # # Assume the message sent a JSON payload of {"name":"Bob"}
116
+ # # in this example.
117
+ # # We'll reply with a string response:
118
+ # "I'm all right, #{data['name']}"
119
+ # end
120
+ # end
121
+ #
122
+ # subject "howdy" do
123
+ # response do |data, subject|
124
+ # # The subject at this point is "hello.howdy".
125
+ # # Assume the message sent a JSON payload of {"name":"Bob"}
126
+ # # in this example.
127
+ # # We'll reply with a JSON response (a Ruby +Hash+):
128
+ # { message: "I'm okay, #{data['name']}. Thanks for asking!" }
129
+ # end
130
+ # end
131
+ # end
132
+ # end
133
+ #
134
+ def response(queue: nil, &block)
135
+ response_queue = Utils.presence(queue.to_s) || current_queue || default_queue
136
+ Client.reply_to(current_subject, queue: response_queue, &block)
137
+ end
138
+
139
+ private
140
+
141
+ def subject_chain
142
+ @subject_chain ||= []
143
+ end
144
+
145
+ def current_subject
146
+ subject_chain.join(".")
147
+ end
148
+
149
+ def current_queue
150
+ @current_queue ||= nil
151
+ end
152
+
153
+ def current_queue=(some_queue)
154
+ @current_queue = Utils.presence(some_queue)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyNestNats
4
+ # :nodoc:
5
+ class Utils
6
+ class << self
7
+ def blank?(value)
8
+ value.respond_to?(:empty?) ? value.empty? : !value
9
+ end
10
+
11
+ def present?(value)
12
+ !blank?(value)
13
+ end
14
+
15
+ def presence(value)
16
+ present?(value) ? value : nil
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyNestNats
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_nest_nats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keegan Leitz
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-05-11 00:00:00.000000000 Z
11
+ date: 2021-05-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -155,6 +155,9 @@ files:
155
155
  - bin/console
156
156
  - bin/setup
157
157
  - lib/ruby_nest_nats.rb
158
+ - lib/ruby_nest_nats/client.rb
159
+ - lib/ruby_nest_nats/controller.rb
160
+ - lib/ruby_nest_nats/utils.rb
158
161
  - lib/ruby_nest_nats/version.rb
159
162
  - ruby_nest_nats.gemspec
160
163
  homepage: https://github.com/openbay/ruby_nest_nats