ruby_nest_nats 0.1.1 → 0.2.2

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.
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