ruby_nest_nats 0.1.0 → 0.2.1

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: eb157d3580a1c3059fd2accca997d62b983e3d02a88256caf933f842efaeb45c
4
- data.tar.gz: 85ca0f29130a422f72ea3a966a38fcafba3c1a59abbc64504fccb5e2d6ed7b84
3
+ metadata.gz: 8ff7ec8086e242612ea12d75ef5a8a52330b8f84d3cd12bc097be093b00ef5c7
4
+ data.tar.gz: aae8c3e437f6c94abf4f3c4a551efd009308b0702a064c848e8935ee8087a01f
5
5
  SHA512:
6
- metadata.gz: ecb51585d48e0a2e6613815e00b6d9c869444ea70567775138d5c6264fa20f77494f242c0165a975c03b6c24732a7c4050704dd3fc58a98a1f2be0cc789bcf50
7
- data.tar.gz: 6b0c1f22915cffd57897b41df7c9f72173ca3a1e84b7f2d3adee3a611c0bf9dc67509d1fb064966f778b50dda931412a41d1cc4b19b8028df5017876d019b511
6
+ metadata.gz: 49f30461903b281191bbd2aa1cfd2d579416b355b0d1f5c5abef3b42057b99a66989a33d8995318bf1c57d66ec4528d8e97f411759dba2cf5d74187d28060120
7
+ data.tar.gz: 73a64ce207bce9fcb050d535eb21a10ff561da69d328c4a3ebd4cbee6d9a5705f675d0bdbf086f9cffd31eb9a57691de001004a75be3b0cab7149b0744c630e4
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
+ ```rb
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
+ ```rb
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
+ ```rb
97
+ RubyNestNats::Client.default_queue = "foobar"
98
+ ```
99
+
100
+ Leave the `::default_queue` blank (or assign `nil`) to use no default queue.
101
+
102
+ ```rb
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
+ ```rb
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
+ ```rb
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
+ ```rb
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
+ ```rb
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
+ > ```rb
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,15 @@
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
7
- class Error < StandardError; end
8
-
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: reply[: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
12
+ class Error < StandardError; end # :nodoc:
54
13
 
55
- fiber.resume
56
- end
57
- end
58
- end
14
+ class NewSubscriptionsError < RubyNestNats::Error; end # :nodoc:
59
15
  end
@@ -0,0 +1,269 @@
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
+ attr_reader :logger, :default_queue # :nodoc:
14
+
15
+ # Attach a logger to have `ruby_nest_nats` write out logs for messages
16
+ # received, responses sent, errors raised, lifecycle events, etc.
17
+ #
18
+ # ```rb
19
+ # require 'ruby_nest_nats'
20
+ # require 'logger'
21
+ #
22
+ # nats_logger = Logger.new(STDOUT)
23
+ # nats_logger.level = Logger::INFO
24
+ #
25
+ # RubyNestNats::Client.logger = nats_logger
26
+ # ```
27
+ #
28
+ # In a Rails application, you might do this instead:
29
+ #
30
+ # ```rb
31
+ # RubyNestNats::Client.logger = Rails.logger
32
+ # ```
33
+ #
34
+ def logger=(some_logger)
35
+ log("Setting the logger to #{some_logger.inspect}")
36
+ @logger = some_logger
37
+ end
38
+
39
+ # Set a default queue for subscriptions.
40
+ #
41
+ # ```rb
42
+ # RubyNestNats::Client.default_queue = "foobar"
43
+ # ```
44
+ #
45
+ # Leave the `::default_queue` blank (or assign `nil`) to use no default
46
+ # queue.
47
+ #
48
+ # ```rb
49
+ # RubyNestNats::Client.default_queue = nil
50
+ # ```
51
+ #
52
+ def default_queue=(some_queue)
53
+ queue = Utils.presence(some_queue.to_s)
54
+ log("Setting the default queue to #{queue || '(none)'}", level: :debug)
55
+ @default_queue = queue
56
+ end
57
+
58
+ # Returns `true` if `::start!` has already been called (meaning the client
59
+ # is listening to NATS messages). Returns `false` if it has not yet been
60
+ # called, or if it has been stopped.
61
+ def started?
62
+ @started ||= false
63
+ end
64
+
65
+ # Opposite of `::started?`: returns `false` if `::start!` has already been
66
+ # called (meaning the client is listening to NATS messages). Returns
67
+ # `true` if it has not yet been called, or if it has been stopped.
68
+ def stopped?
69
+ !started?
70
+ end
71
+
72
+ # Register a message handler with the `RubyNestNats::Client::reply_to`
73
+ # method. Pass a subject string as the first argument (either a static
74
+ # subject string or a pattern to match more than one subject). Specify a
75
+ # queue (or don't) with the `queue:` option. If you don't provide the
76
+ # `queue:` option, it will be set to the value of `default_queue`, or to
77
+ # `nil` (no queue) if a default queue hasn't been set.
78
+ #
79
+ # The result of the given block will be published in reply to the message.
80
+ # The block is passed two arguments when a message matching the subject is
81
+ # received: `data` and `subject`. The `data` argument is the payload of
82
+ # the message (JSON objects/arrays will be parsed into string-keyed `Hash`
83
+ # objects/`Array` objects, respectively). The `subject` argument is the
84
+ # subject of the message received (mostly only useful if a _pattern_ was
85
+ # specified instead of a static subject string).
86
+ #
87
+ # ```rb
88
+ # RubyNestNats::Client.reply_to("some.subject", queue: "foobar") { |data| "Got it! #{data.inspect}" }
89
+ #
90
+ # RubyNestNats::Client.reply_to("some.*.pattern") { |data, subject| "Got #{data} on #{subject}" }
91
+ #
92
+ # RubyNestNats::Client.reply_to("other.subject") do |data|
93
+ # if data["foo"] == "bar"
94
+ # { is_bar: "Yep!" }
95
+ # else
96
+ # { is_bar: "No way!" }
97
+ # end
98
+ # end
99
+ #
100
+ # RubyNestNats::Client.reply_to("subject.in.queue", queue: "barbaz") do
101
+ # "My turn!"
102
+ # end
103
+ # ```
104
+ #
105
+ def reply_to(subject, queue: nil, &block)
106
+ queue = Utils.presence(queue) || default_queue
107
+ queue_desc = " in queue '#{queue}'" if queue
108
+ log("Registering a reply handler for subject '#{subject}'#{queue_desc}", level: :debug)
109
+ register_reply!(subject: subject.to_s, handler: block, queue: queue.to_s)
110
+ end
111
+
112
+ # Start listening for messages with the `RubyNestNats::Client::start!`
113
+ # method. This will spin up a non-blocking thread that subscribes to
114
+ # subjects (as specified by invocation(s) of `::reply_to`) and waits for
115
+ # messages to come in. When a message is received, the appropriate
116
+ # `::reply_to` block will be used to compute a response, and that response
117
+ # will be published.
118
+ #
119
+ # ```rb
120
+ # RubyNestNats::Client.start!
121
+ # ```
122
+ #
123
+ # **NOTE:** If an error is raised in one of the handlers,
124
+ # `RubyNestNats::Client` will restart automatically.
125
+ #
126
+ # **NOTE:** You _can_ invoke `::reply_to` to create additional message
127
+ # subscriptions after `RubyNestNats::Client.start!`, but be aware that
128
+ # this forces the client to restart. You may see (benign, already-handled)
129
+ # errors in the logs generated when this restart happens. It will force
130
+ # the client to restart and re-subscribe after _each additional
131
+ # `::reply_to` invoked after `::start!`._ So, if you have a lot of
132
+ # additional `::reply_to` invocations, you may want to consider
133
+ # refactoring so that your call to `RubyNestNats::Client.start!` occurs
134
+ # _after_ those additions.
135
+ #
136
+ # **NOTE:** The `::start!` method can be safely called multiple times;
137
+ # only the first will be honored, and any subsequent calls to `::start!`
138
+ # after the client is already started will do nothing (except write a
139
+ # _"NATS is already running"_ log to the logger at the `DEBUG` level).
140
+ #
141
+ def start!
142
+ log("Starting NATS", level: :debug)
143
+
144
+ if started?
145
+ log("NATS is already running", level: :debug)
146
+ return
147
+ end
148
+
149
+ started!
150
+
151
+ self.current_thread = Thread.new do
152
+ Thread.handle_interrupt(StandardError => :never) do
153
+ Thread.handle_interrupt(StandardError => :immediate) { listen }
154
+ rescue NATS::ConnectError => e
155
+ log("Could not connect to NATS server:", level: :error)
156
+ log(e.full_message, level: :error, indent: 2)
157
+ Thread.current.exit
158
+ rescue NewSubscriptionsError => e
159
+ log("New subscriptions! Restarting...", level: :info)
160
+ restart!
161
+ raise e # TODO: there has to be a better way
162
+ rescue StandardError => e
163
+ log("Encountered an error:", level: :error)
164
+ log(e.full_message, level: :error, indent: 2)
165
+ restart!
166
+ raise e
167
+ end
168
+ end
169
+ end
170
+
171
+ private
172
+
173
+ def log(text, level: :info, indent: 0)
174
+ return unless logger
175
+
176
+ timestamp = Time.now.to_s
177
+ text_lines = text.split("\n")
178
+ indentation = indent.is_a?(String) ? indent : (" " * indent)
179
+
180
+ text_lines.each do |line|
181
+ logger.send(level, "[#{timestamp}] RubyNestNats | #{indentation}#{line}")
182
+ end
183
+ end
184
+
185
+ def stop!
186
+ log("Stopping NATS", level: :debug)
187
+
188
+ begin
189
+ NATS.stop
190
+ rescue StandardError
191
+ nil
192
+ end
193
+
194
+ stopped!
195
+ end
196
+
197
+ def restart!
198
+ log("Restarting NATS", level: :warn)
199
+ stop!
200
+ start!
201
+ end
202
+
203
+ def started!
204
+ @started = true
205
+ end
206
+
207
+ def stopped!
208
+ @started = false
209
+ end
210
+
211
+ def replies
212
+ @replies ||= []
213
+ end
214
+
215
+ attr_accessor :current_thread
216
+
217
+ def reply_registered?(raw_subject)
218
+ subject = raw_subject.to_s
219
+ replies.any? { |reply| reply[:subject] == subject }
220
+ end
221
+
222
+ def register_reply!(subject:, handler:, queue: nil)
223
+ raise ArgumentError, "Subject must be a string" unless subject.is_a?(String)
224
+ raise ArgumentError, "Must provide a message handler for #{subject}" unless handler.respond_to?(:call)
225
+ raise ArgumentError, "Already registered a reply to #{subject}" if reply_registered?(subject)
226
+
227
+ reply = {
228
+ subject: subject,
229
+ handler: handler,
230
+ queue: Utils.presence(queue) || default_queue,
231
+ }
232
+
233
+ replies << reply
234
+
235
+ current_thread.raise(NewSubscriptionsError, "New reply registered") if started?
236
+ end
237
+
238
+ def listen
239
+ NATS.start do
240
+ replies.each do |replier|
241
+ queue_desc = " in queue '#{replier[:queue]}'" if replier[:queue]
242
+ log("Subscribing to subject '#{replier[:subject]}'#{queue_desc}", level: :debug)
243
+
244
+ NATS.subscribe(replier[:subject], queue: replier[:queue]) do |message, inbox, subject|
245
+ parsed_message = JSON.parse(message)
246
+ id, data, pattern = parsed_message.values_at("id", "data", "pattern")
247
+
248
+ log("Received a message!")
249
+ message_desc = <<~LOG_MESSAGE
250
+ id: #{id || '(none)'}
251
+ pattern: #{pattern || '(none)'}
252
+ subject: #{subject || '(none)'}
253
+ data: #{data.to_json}
254
+ inbox: #{inbox || '(none)'}
255
+ LOG_MESSAGE
256
+ log(message_desc, indent: 2)
257
+
258
+ response_data = replier[:handler].call(data)
259
+
260
+ log("Responding with '#{response_data}'")
261
+
262
+ NATS.publish(inbox, response_data.to_json, queue: replier[:queue])
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,161 @@
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
+ NO_QUEUE_GIVEN = :ruby_nest_nats_super_special_no_op_queue_symbol_qwertyuiop1234567890
10
+
11
+ class << self
12
+ # Default queue for the controller. Falls back to the client's default
13
+ # queue if the controller's default queue is `nil`.
14
+ #
15
+ # - Call with no argument (`::default_queue`) to get the default queue.
16
+ # - Call as a macro with an argument (`default_queue "something"`) to set
17
+ # the default queue.
18
+ #
19
+ # Example:
20
+ #
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
+ #
54
+ # class FoobarNatsController < RubyNatsController
55
+ # # ...
56
+ #
57
+ # subject "hello.wassup" do
58
+ # response do |data, subject|
59
+ # # The subject at this point is "hello.wassup"
60
+ # # ...
61
+ # end
62
+ # end
63
+ #
64
+ # subject "hello.howdy" do
65
+ # response do |data, subject|
66
+ # # The subject at this point is "hello.howdy"
67
+ # # ...
68
+ # end
69
+ # end
70
+ # end
71
+ #
72
+ # Example:
73
+ #
74
+ # class FoobarNatsController < RubyNatsController
75
+ # # ...
76
+ #
77
+ # subject "hello" do
78
+ # subject "wassup" do
79
+ # response do |data, subject|
80
+ # # The subject at this point is "hello.wassup"
81
+ # # ...
82
+ # end
83
+ # end
84
+ #
85
+ # subject "howdy" do
86
+ # response do |data, subject|
87
+ # # The subject at this point is "hello.howdy"
88
+ # # ...
89
+ # end
90
+ # end
91
+ # end
92
+ # end
93
+ #
94
+ def subject(subject_segment, queue: nil)
95
+ subject_chain.push(subject_segment)
96
+ old_queue = current_queue
97
+ self.current_queue = queue if Utils.present?(queue)
98
+ yield
99
+ self.current_queue = old_queue
100
+ subject_chain.pop
101
+ end
102
+
103
+ # You can register a response for the built-up subject/pattern string
104
+ # using the `::response` macro. Pass a block to `::response` which
105
+ # optionally takes two arguments (the same arguments supplied to the block
106
+ # of `RubyNestNats::Client::reply_to`). The result of that block will be
107
+ # sent as a response to the message received.
108
+ #
109
+ # Example:
110
+ #
111
+ # class FoobarNatsController < RubyNatsController
112
+ # # ...
113
+ #
114
+ # subject "hello" do
115
+ # subject "wassup" do
116
+ # response do |data, subject|
117
+ # # The subject at this point is "hello.wassup".
118
+ # # Assume the message sent a JSON payload of {"name":"Bob"}
119
+ # # in this example.
120
+ # # We'll reply with a string response:
121
+ # "I'm all right, #{data['name']}"
122
+ # end
123
+ # end
124
+ #
125
+ # subject "howdy" do
126
+ # response do |data, subject|
127
+ # # The subject at this point is "hello.howdy".
128
+ # # Assume the message sent a JSON payload of {"name":"Bob"}
129
+ # # in this example.
130
+ # # We'll reply with a JSON response (a Ruby `Hash`):
131
+ # { message: "I'm okay, #{data['name']}. Thanks for asking!" }
132
+ # end
133
+ # end
134
+ # end
135
+ # end
136
+ #
137
+ def response(queue: nil, &block)
138
+ response_queue = Utils.presence(queue.to_s) || current_queue || default_queue
139
+ Client.reply_to(current_subject, queue: response_queue, &block)
140
+ end
141
+
142
+ private
143
+
144
+ def subject_chain
145
+ @subject_chain ||= []
146
+ end
147
+
148
+ def current_subject
149
+ subject_chain.join(".")
150
+ end
151
+
152
+ def current_queue
153
+ @current_queue ||= nil
154
+ end
155
+
156
+ def current_queue=(some_queue)
157
+ @current_queue = Utils.presence(some_queue)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyNestNats
4
+ class Utils # :nodoc:
5
+ class << self
6
+ def blank?(value)
7
+ value.respond_to?(:empty?) ? value.empty? : !value
8
+ end
9
+
10
+ def present?(value)
11
+ !blank?(value)
12
+ end
13
+
14
+ def presence(value)
15
+ present?(value) ? value : nil
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyNestNats
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
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.0
4
+ version: 0.2.1
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