natsy 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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: []