natsy 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a037edf2d9a4b510d45ac57b9a891ccaf5c8b14a82ad359b5ef24974f119342f
4
+ data.tar.gz: 7351f54131880e05d0ddf5955a8bc48af0635abf53de8fcd13a06f8ef9587e72
5
+ SHA512:
6
+ metadata.gz: 071a63d868fbd6441c0b33f459c643d0d23f471c63317274dc5ae45ddf896fd69cee84701da5c1cd8ccb86ff1b8cc90e8c9cc9f98ced72508b48879a93e8b572
7
+ data.tar.gz: 13345e7ab11e6f770310d27d8dbd2cffa64b0e27dd04b63096bce5827562361b261b81f7e762c328f7a01d6d971399cb8f0c39d61efd370d69fc7aee9a3cd031
@@ -0,0 +1,44 @@
1
+ name: Test and lint
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ pull_request:
9
+ branches:
10
+ - main
11
+
12
+ # Allows you to run this workflow manually from the Actions tab
13
+ workflow_dispatch:
14
+
15
+ jobs:
16
+ build:
17
+ name: Run RSpec tests and RuboCop lints
18
+
19
+ runs-on: ubuntu-latest
20
+
21
+ strategy:
22
+ matrix:
23
+ ruby-version:
24
+ - 2.7
25
+ - 2.6
26
+
27
+ steps:
28
+ - name: Checkout the repo
29
+ uses: actions/checkout@v2
30
+
31
+ - name: Set up Ruby v${{ matrix.ruby-version }}
32
+ uses: ruby/setup-ruby@v1
33
+ with:
34
+ ruby-version: ${{ matrix.ruby-version }}
35
+ bundler-cache: true
36
+
37
+ - name: Install dependencies
38
+ run: bundle install
39
+
40
+ - name: Run RSpec tests
41
+ run: bundle exec rake spec
42
+
43
+ - name: Run RuboCop lints
44
+ run: bundle exec rubocop
data/.gitignore ADDED
@@ -0,0 +1,70 @@
1
+ # generic stuff
2
+ .env
3
+ *.gem
4
+ *.rbc
5
+ log/*.log
6
+ /.config
7
+ /InstalledFiles
8
+ /pkg/
9
+ /tmp/
10
+
11
+ # testy stuff
12
+ .rspec
13
+ .rspec_status
14
+ *.orig
15
+ /coverage/
16
+ /coverage/
17
+ /db/*.sqlite3
18
+ /db/*.sqlite3-[0-9]*
19
+ /db/*.sqlite3-journal
20
+ /public/system
21
+ /spec/examples.txt
22
+ /spec/reports/
23
+ /spec/tmp
24
+ /test/tmp/
25
+ /test/version_tmp/
26
+ capybara-*.html
27
+ pickle-email-*.html
28
+ rerun.txt
29
+ test/dummy/db/*.sqlite3
30
+ test/dummy/db/*.sqlite3-journal
31
+ test/dummy/log/*.log
32
+ test/dummy/node_modules/
33
+ test/dummy/storage/
34
+ test/dummy/tmp/
35
+ test/dummy/yarn-error.log
36
+
37
+ # debuggy stuff
38
+ .byebug_history
39
+
40
+ ## doccy stuff
41
+ /.yardoc/
42
+ /_yardoc/
43
+ /doc/
44
+ /rdoc/
45
+
46
+ ## bundly stuff
47
+ /.bundle/
48
+ /vendor/bundle
49
+ /lib/bundler/man/
50
+
51
+ # "for a library or gem, you might want to ignore these files since the code is
52
+ # intended to run in multiple environments"
53
+ Gemfile.lock
54
+ .ruby-version
55
+ .ruby-gemset
56
+
57
+ # rvmmy stuff
58
+ .rvmrc
59
+
60
+ # editory stuff
61
+ .idea
62
+ .vscode
63
+ *.rdb
64
+
65
+ # systemy stuff
66
+ *.swm
67
+ *.swn
68
+ *.swo
69
+ *.swp
70
+ *.DS_Store
data/.rubocop.yml ADDED
@@ -0,0 +1,103 @@
1
+ require:
2
+ - rubocop-performance
3
+ - rubocop-rspec
4
+ - rubocop-rake
5
+
6
+ # Globals
7
+
8
+ AllCops:
9
+ NewCops: enable
10
+ TargetRubyVersion: 2.6
11
+
12
+ # Layout
13
+
14
+ Layout/LineLength:
15
+ Max: 120
16
+ Exclude:
17
+ - 'spec/**/*_spec.rb'
18
+ - '*.gemspec'
19
+
20
+ Layout/EndAlignment:
21
+ EnforcedStyleAlignWith: variable
22
+
23
+ Layout/FirstArrayElementIndentation:
24
+ EnforcedStyle: consistent
25
+
26
+ # Metrics
27
+
28
+ Metrics/AbcSize:
29
+ Max: 30
30
+ CountRepeatedAttributes: false
31
+ Exclude:
32
+ - 'spec/**/*_spec.rb'
33
+ - '*.gemspec'
34
+
35
+ Metrics/BlockLength:
36
+ Exclude:
37
+ - 'spec/**/*_spec.rb'
38
+ - '*.gemspec'
39
+
40
+ Metrics/ClassLength:
41
+ Max: 150
42
+ CountComments: false
43
+ CountAsOne:
44
+ - array
45
+ - hash
46
+ - heredoc
47
+ Exclude:
48
+ - 'spec/**/*_spec.rb'
49
+ - '*.gemspec'
50
+
51
+ Metrics/MethodLength:
52
+ Max: 20
53
+ CountComments: false
54
+ CountAsOne:
55
+ - array
56
+ - hash
57
+ - heredoc
58
+
59
+ Metrics/ModuleLength:
60
+ Max: 150
61
+ CountComments: false
62
+ CountAsOne:
63
+ - array
64
+ - hash
65
+ - heredoc
66
+ Exclude:
67
+ - 'spec/**/*_spec.rb'
68
+ - '*.gemspec'
69
+
70
+ # Rspec
71
+
72
+ RSpec/ExampleLength:
73
+ Max: 25
74
+
75
+ RSpec/MessageSpies:
76
+ Enabled: false
77
+
78
+ RSpec/MultipleExpectations:
79
+ Enabled: false
80
+
81
+ RSpec/NestedGroups:
82
+ Max: 10
83
+
84
+ # Style
85
+
86
+ Style/DoubleNegation:
87
+ Enabled: false
88
+
89
+ Style/ExpandPathArguments:
90
+ Exclude:
91
+ - 'adornable.gemspec'
92
+
93
+ Style/StringLiterals:
94
+ Enabled: false
95
+
96
+ Style/TrailingCommaInArguments:
97
+ EnforcedStyleForMultiline: consistent_comma
98
+
99
+ Style/TrailingCommaInArrayLiteral:
100
+ EnforcedStyleForMultiline: consistent_comma
101
+
102
+ Style/TrailingCommaInHashLiteral:
103
+ EnforcedStyleForMultiline: consistent_comma
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2021-05-10
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in natsy.gemspec
6
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 Keegan Leitz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,319 @@
1
+ # Natsy
2
+
3
+ The `natsy` gem allows you to listen for (and reply to) NATS messages asynchronously in a Ruby application.
4
+
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
16
+
17
+ ## Installation
18
+
19
+ ### Locally (to your application)
20
+
21
+ Add the gem to your application's `Gemfile`:
22
+
23
+ ```ruby
24
+ gem 'natsy'
25
+ ```
26
+
27
+ ...and then run:
28
+
29
+ ```bash
30
+ bundle install
31
+ ```
32
+
33
+ ### Globally (to your system)
34
+
35
+ Alternatively, install it globally:
36
+
37
+ ```bash
38
+ gem install natsy
39
+ ```
40
+
41
+ ### NATS server (important!)
42
+
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.
44
+
45
+ ## Usage
46
+
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 `natsy` write out logs for messages received, responses sent, errors raised, lifecycle events, etc.
64
+
65
+ ```ruby
66
+ require 'natsy'
67
+ require 'logger'
68
+
69
+ nats_logger = Logger.new(STDOUT)
70
+ nats_logger.level = Logger::INFO
71
+
72
+ Natsy::Client.logger = nats_logger
73
+ ```
74
+
75
+ In a Rails application, you might do this instead:
76
+
77
+ ```ruby
78
+ Natsy::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
+ Natsy::Client.default_queue = "foobar"
98
+ ```
99
+
100
+ Leave the `::default_queue` blank (or assign `nil`) to use no default queue.
101
+
102
+ ```ruby
103
+ Natsy::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 `Natsy::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
+ Natsy::Client.reply_to("some.subject", queue: "foobar") { |data| "Got it! #{data.inspect}" }
116
+
117
+ Natsy::Client.reply_to("some.*.pattern") { |data, subject| "Got #{data} on #{subject}" }
118
+
119
+ Natsy::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
+ Natsy::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 `Natsy::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
+ Natsy::Client.start!
138
+ ```
139
+
140
+ > **NOTE:** If an error is raised in one of the handlers, `Natsy::Client` will restart automatically.
141
+
142
+ > **NOTE:** You _can_ invoke `::reply_to` to create additional message subscriptions after `Natsy::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 `Natsy::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 `natsy` 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 'natsy'
154
+ require 'logger'
155
+
156
+ nats_logger = Logger.new(STDOUT)
157
+ nats_logger.level = Logger::DEBUG
158
+
159
+ Natsy::Client.logger = nats_logger
160
+ Natsy::Client.default_queue = "foobar"
161
+
162
+ Natsy::Client.reply_to("some.subject") { |data| "Got it! #{data.inspect}" }
163
+ Natsy::Client.reply_to("some.*.pattern") { |data, subject| "Got #{data} on #{subject}" }
164
+ Natsy::Client.reply_to("subject.in.queue", queue: "barbaz") { { msg: "My turn!", turn: 5 } }
165
+
166
+ Natsy::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 `Natsy::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 `Natsy::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 `Natsy::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 < Natsy::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, `natsy` 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
+ > Natsy::Client.logger = Rails.logger
233
+ > Natsy::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
+ > Natsy::Client.start!
242
+ > end
243
+ > ```
244
+
245
+ ## Development
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 `Natsy::VERSION` value in `lib/natsy/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/natsy).
308
+
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.
310
+
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).
312
+
313
+ ## Contributing
314
+
315
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Openbay/natsy.
316
+
317
+ ## License
318
+
319
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "natsy"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/natsy.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nats/client"
4
+ require_relative "natsy/version"
5
+ require_relative "natsy/utils"
6
+ require_relative "natsy/client"
7
+ require_relative "natsy/controller"
8
+
9
+ # The +Natsy+ module provides the top-level namespace for the NATS client
10
+ # and controller machinery.
11
+ module Natsy
12
+ # Basic error
13
+ class Error < StandardError; end
14
+
15
+ # New subscription has been added at runtime
16
+ class NewSubscriptionsError < Natsy::Error; end
17
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "nats/client"
5
+ require_relative "./utils"
6
+
7
+ module Natsy
8
+ # The +Natsy::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
+ # Optional logger for lifecycle events, messages received, etc.
14
+ attr_reader :logger
15
+
16
+ # Optional default queue for message subscription and replies.
17
+ attr_reader :default_queue
18
+
19
+ # Attach a logger to have +natsy+ write out logs for messages
20
+ # received, responses sent, errors raised, lifecycle events, etc.
21
+ #
22
+ # @example
23
+ # require 'natsy'
24
+ # require 'logger'
25
+ #
26
+ # nats_logger = Logger.new(STDOUT)
27
+ # nats_logger.level = Logger::INFO
28
+ #
29
+ # Natsy::Client.logger = nats_logger
30
+ #
31
+ # In a Rails application, you might do this instead:
32
+ #
33
+ # @example
34
+ # Natsy::Client.logger = Rails.logger
35
+ #
36
+ def logger=(some_logger)
37
+ @logger = some_logger
38
+ log("Set the logger to #{@logger.inspect}")
39
+ end
40
+
41
+ # Set a default queue for subscriptions.
42
+ #
43
+ # @example
44
+ # Natsy::Client.default_queue = "foobar"
45
+ #
46
+ # Leave the +::default_queue+ blank (or assign +nil+) to use no default
47
+ # queue.
48
+ #
49
+ # @example
50
+ # Natsy::Client.default_queue = nil
51
+ #
52
+ def default_queue=(some_queue)
53
+ @default_queue = Utils.presence(some_queue.to_s)
54
+ log("Setting the default queue to #{@default_queue || '(none)'}", level: :debug)
55
+ end
56
+
57
+ # Returns +true+ if +::start!+ has already been called (meaning the client
58
+ # is listening to NATS messages). Returns +false+ if it has not yet been
59
+ # called, or if it has been stopped.
60
+ def started?
61
+ @started ||= false
62
+ end
63
+
64
+ # Opposite of +::started?+: returns +false+ if +::start!+ has already been
65
+ # called (meaning the client is listening to NATS messages). Returns
66
+ # +true+ if it has not yet been called, or if it has been stopped.
67
+ def stopped?
68
+ !started?
69
+ end
70
+
71
+ # Register a message handler with the +Natsy::Client::reply_to+
72
+ # method. Pass a subject string as the first argument (either a static
73
+ # subject string or a pattern to match more than one subject). Specify a
74
+ # queue (or don't) with the +queue:+ option. If you don't provide the
75
+ # +queue:+ option, it will be set to the value of +default_queue+, or to
76
+ # +nil+ (no queue) if a default queue hasn't been set.
77
+ #
78
+ # The result of the given block will be published in reply to the message.
79
+ # The block is passed two arguments when a message matching the subject is
80
+ # received: +data+ and +subject+. The +data+ argument is the payload of
81
+ # the message (JSON objects/arrays will be parsed into string-keyed +Hash+
82
+ # objects/+Array+ objects, respectively). The +subject+ argument is the
83
+ # subject of the message received (mostly only useful if a _pattern_ was
84
+ # specified instead of a static subject string).
85
+ #
86
+ # @example
87
+ # Natsy::Client.reply_to("some.subject", queue: "foobar") { |data| "Got it! #{data.inspect}" }
88
+ #
89
+ # Natsy::Client.reply_to("some.*.pattern") { |data, subject| "Got #{data} on #{subject}" }
90
+ #
91
+ # Natsy::Client.reply_to("other.subject") do |data|
92
+ # if data["foo"] == "bar"
93
+ # { is_bar: "Yep!" }
94
+ # else
95
+ # { is_bar: "No way!" }
96
+ # end
97
+ # end
98
+ #
99
+ # Natsy::Client.reply_to("subject.in.queue", queue: "barbaz") do
100
+ # "My turn!"
101
+ # end
102
+ #
103
+ def reply_to(subject, queue: nil, &block)
104
+ queue = Utils.presence(queue) || default_queue
105
+ queue_desc = " in queue '#{queue}'" if queue
106
+ log("Registering a reply handler for subject '#{subject}'#{queue_desc}", level: :debug)
107
+ register_reply!(subject: subject.to_s, handler: block, queue: queue.to_s)
108
+ end
109
+
110
+ # Start listening for messages with the +Natsy::Client::start!+
111
+ # method. This will spin up a non-blocking thread that subscribes to
112
+ # subjects (as specified by invocation(s) of +::reply_to+) and waits for
113
+ # messages to come in. When a message is received, the appropriate
114
+ # +::reply_to+ block will be used to compute a response, and that response
115
+ # will be published.
116
+ #
117
+ # @example
118
+ # Natsy::Client.start!
119
+ #
120
+ # **NOTE:** If an error is raised in one of the handlers,
121
+ # +Natsy::Client+ will restart automatically.
122
+ #
123
+ # **NOTE:** You _can_ invoke +::reply_to+ to create additional message
124
+ # subscriptions after +Natsy::Client.start!+, but be aware that
125
+ # this forces the client to restart. You may see (benign, already-handled)
126
+ # errors in the logs generated when this restart happens. It will force
127
+ # the client to restart and re-subscribe after _each additional
128
+ # +::reply_to+ invoked after +::start!+._ So, if you have a lot of
129
+ # additional +::reply_to+ invocations, you may want to consider
130
+ # refactoring so that your call to +Natsy::Client.start!+ occurs
131
+ # _after_ those additions.
132
+ #
133
+ # **NOTE:** The +::start!+ method can be safely called multiple times;
134
+ # only the first will be honored, and any subsequent calls to +::start!+
135
+ # after the client is already started will do nothing (except write a
136
+ # _"NATS is already running"_ log to the logger at the +DEBUG+ level).
137
+ #
138
+ def start!
139
+ log("Starting NATS", level: :debug)
140
+
141
+ if started?
142
+ log("NATS is already running", level: :debug)
143
+ return
144
+ end
145
+
146
+ started!
147
+
148
+ self.current_thread = Thread.new do
149
+ Thread.handle_interrupt(StandardError => :never) do
150
+ Thread.handle_interrupt(StandardError => :immediate) { listen }
151
+ rescue NATS::ConnectError => e
152
+ log("Could not connect to NATS server:", level: :error)
153
+ log(e.full_message, level: :error, indent: 2)
154
+ Thread.current.exit
155
+ rescue NewSubscriptionsError => e
156
+ log("New subscriptions! Restarting...", level: :info)
157
+ restart!
158
+ raise e # TODO: there has to be a better way
159
+ rescue StandardError => e
160
+ log("Encountered an error:", level: :error)
161
+ log(e.full_message, level: :error, indent: 2)
162
+ restart!
163
+ raise e
164
+ end
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ attr_accessor :current_thread
171
+
172
+ def log(text, level: :info, indent: 0)
173
+ return unless logger
174
+
175
+ timestamp = Time.now.to_s
176
+ text_lines = text.split("\n")
177
+ indentation = indent.is_a?(String) ? indent : (" " * indent)
178
+
179
+ text_lines.each do |line|
180
+ logger.send(level, "[#{timestamp}] Natsy | #{indentation}#{line}")
181
+ end
182
+ end
183
+
184
+ def kill!
185
+ current_thread.kill if current_thread && current_thread.alive?
186
+ end
187
+
188
+ def stop!
189
+ log("Stopping NATS", level: :debug)
190
+
191
+ begin
192
+ NATS.stop
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
197
+ stopped!
198
+ end
199
+
200
+ def restart!
201
+ log("Restarting NATS", level: :warn)
202
+ stop!
203
+ start!
204
+ end
205
+
206
+ def started!
207
+ @started = true
208
+ end
209
+
210
+ def stopped!
211
+ @started = false
212
+ end
213
+
214
+ def replies
215
+ @replies ||= []
216
+ end
217
+
218
+ def reply_registered?(raw_subject)
219
+ subject = raw_subject.to_s
220
+ replies.any? { |reply| reply[:subject] == subject }
221
+ end
222
+
223
+ def register_reply!(subject:, handler:, queue: nil)
224
+ raise ArgumentError, "Subject must be a string" unless subject.is_a?(String)
225
+ raise ArgumentError, "Must provide a message handler for #{subject}" unless handler.respond_to?(:call)
226
+ raise ArgumentError, "Already registered a reply to #{subject}" if reply_registered?(subject)
227
+
228
+ reply = {
229
+ subject: subject,
230
+ handler: handler,
231
+ queue: Utils.presence(queue) || default_queue,
232
+ }
233
+
234
+ replies << reply
235
+
236
+ current_thread.raise(NewSubscriptionsError, "New reply registered") if started?
237
+ end
238
+
239
+ def listen
240
+ NATS.start do
241
+ replies.each do |replier|
242
+ queue_desc = " in queue '#{replier[:queue]}'" if replier[:queue]
243
+ log("Subscribing to subject '#{replier[:subject]}'#{queue_desc}", level: :debug)
244
+
245
+ NATS.subscribe(replier[:subject], queue: replier[:queue]) do |message, inbox, subject|
246
+ parsed_message = JSON.parse(message)
247
+ id, data, pattern = parsed_message.values_at("id", "data", "pattern")
248
+
249
+ log("Received a message!")
250
+ message_desc = <<~LOG_MESSAGE
251
+ id: #{id || '(none)'}
252
+ pattern: #{pattern || '(none)'}
253
+ subject: #{subject || '(none)'}
254
+ data: #{data.to_json}
255
+ inbox: #{inbox || '(none)'}
256
+ LOG_MESSAGE
257
+ log(message_desc, indent: 2)
258
+
259
+ response_data = replier[:handler].call(data)
260
+
261
+ log("Responding with '#{response_data}'")
262
+
263
+ NATS.publish(inbox, response_data.to_json, queue: replier[:queue])
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./utils"
4
+
5
+ module Natsy
6
+ # Create controller classes which inherit from +Natsy::Controller+ in
7
+ # order to give your message listeners some structure.
8
+ class Controller
9
+ NO_QUEUE_GIVEN = :natsy_super_special_no_op_queue_symbol_qwertyuiop1234567890
10
+ private_constant :NO_QUEUE_GIVEN
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 +Natsy::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 +Natsy::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 +Natsy::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 Natsy
4
+ # Some internal utility methods
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Natsy
4
+ VERSION = "0.3.0"
5
+ end
data/natsy.gemspec ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/natsy/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "natsy"
7
+ spec.version = Natsy::VERSION
8
+ spec.authors = ["Keegan Leitz"]
9
+ spec.email = ["keegan@openbay.com"]
10
+
11
+ spec.summary = "Listen for (and reply to) NATS messages asynchronously in a Ruby application"
12
+ spec.description = "Listen for (and reply to) NATS messages asynchronously in a Ruby application"
13
+ spec.homepage = "https://github.com/openbay/natsy"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.6")
16
+
17
+ rubydoc_url = "https://www.rubydoc.info/gems/natsy/#{Natsy::VERSION}"
18
+ spec.metadata["documentation_uri"] = rubydoc_url
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
24
+ end
25
+
26
+ spec.bindir = "exe"
27
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ["lib"]
29
+
30
+ spec.add_development_dependency "bundler", "~> 2.2"
31
+ spec.add_development_dependency "rake", "~> 13.0"
32
+ spec.add_development_dependency "rspec", "~> 3.0"
33
+ spec.add_development_dependency "rubocop", "~> 1.10"
34
+ spec.add_development_dependency "rubocop-performance", "~> 1.9"
35
+ spec.add_development_dependency "rubocop-rake", "~> 0.5"
36
+ spec.add_development_dependency "rubocop-rspec", "~> 2.2"
37
+ spec.add_development_dependency "solargraph"
38
+ spec.add_development_dependency "pry"
39
+
40
+ spec.add_runtime_dependency "nats", "~> 0.11"
41
+ end
metadata ADDED
@@ -0,0 +1,201 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: natsy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Keegan Leitz
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2021-05-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.2'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.10'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.10'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop-performance
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.9'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.5'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.5'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop-rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '2.2'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '2.2'
111
+ - !ruby/object:Gem::Dependency
112
+ name: solargraph
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: pry
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: nats
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '0.11'
146
+ type: :runtime
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '0.11'
153
+ description: Listen for (and reply to) NATS messages asynchronously in a Ruby application
154
+ email:
155
+ - keegan@openbay.com
156
+ executables: []
157
+ extensions: []
158
+ extra_rdoc_files: []
159
+ files:
160
+ - ".github/workflows/main.yml"
161
+ - ".gitignore"
162
+ - ".rspec"
163
+ - ".rubocop.yml"
164
+ - CHANGELOG.md
165
+ - Gemfile
166
+ - LICENSE.txt
167
+ - README.md
168
+ - Rakefile
169
+ - bin/console
170
+ - bin/setup
171
+ - lib/natsy.rb
172
+ - lib/natsy/client.rb
173
+ - lib/natsy/controller.rb
174
+ - lib/natsy/utils.rb
175
+ - lib/natsy/version.rb
176
+ - natsy.gemspec
177
+ homepage: https://github.com/openbay/natsy
178
+ licenses:
179
+ - MIT
180
+ metadata:
181
+ documentation_uri: https://www.rubydoc.info/gems/natsy/0.3.0
182
+ post_install_message:
183
+ rdoc_options: []
184
+ require_paths:
185
+ - lib
186
+ required_ruby_version: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '2.6'
191
+ required_rubygems_version: !ruby/object:Gem::Requirement
192
+ requirements:
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ version: '0'
196
+ requirements: []
197
+ rubygems_version: 3.0.9
198
+ signing_key:
199
+ specification_version: 4
200
+ summary: Listen for (and reply to) NATS messages asynchronously in a Ruby application
201
+ test_files: []