leopard 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e28fb312a603ba19a413cdc3c9b62e702ec70daa6870bd833b182dd82a96b147
4
- data.tar.gz: 5dcb6ade2032068fd4daaf15b7953ae8ad3e81d352d72a6945e3452fb16b762a
3
+ metadata.gz: 61ef512c45c7a77049bcb82926d8f77f30986d3e63cad9f500f6bfc729968448
4
+ data.tar.gz: b27aeba2b9a28e286420dfe5a32be1cfc22792cd7d68c317178b7b463adbf9d9
5
5
  SHA512:
6
- metadata.gz: a85035745ea9c0470764ea48c9127a60ae8459ba88d01158c33f22fab74aba81d5781d1c44bbbc09a2bcb53f0ae8787b2434c2ecf16063a0300ecfb3ce63bf7b
7
- data.tar.gz: bcb763a8fc34511e78e9764b648ac75e24cf6370075550a958eeb69fb2cd0a738232c488fc5f06cf7ffe97b004a5d499f70e4a972c8e3aef5c09475d58135535
6
+ metadata.gz: b49256564d284e559c04e52bc689eeb5113d9ddc7d3fa4279c5d6bcca19501bc297595ab2f7481eb12a47b3cf7dffbe5a98fa7e7fc57f32357023ebc1296bc8c
7
+ data.tar.gz: dd3a974237972a781b51aa24ba3295e4be1ac49a56d2a9e5f3dc678db716cb7ec3f7813ce5a41e3eb224129ea7b795c7c01efa863ca991a050e69c026dd920c4
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.2.8"
2
+ ".": "0.2.10"
3
3
  }
data/.version.txt CHANGED
@@ -1 +1 @@
1
- 0.2.8
1
+ 0.2.10
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.10](https://github.com/rubyists/leopard/compare/v0.2.9...v0.2.10) (2026-09-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * add NATS service discovery tools ([#58](https://github.com/rubyists/leopard/issues/58)) ([#59](https://github.com/rubyists/leopard/issues/59)) ([382d5e7](https://github.com/rubyists/leopard/commit/382d5e7e19367796145bec07e4bda686268905ae))
9
+
10
+ ## [0.2.9](https://github.com/rubyists/leopard/compare/v0.2.8...v0.2.9) (2026-07-23)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **deps:** remove upper bound from semantic_logger dependency ([#54](https://github.com/rubyists/leopard/issues/54)) ([0b9f154](https://github.com/rubyists/leopard/commit/0b9f154f1b2ec1961dbb176954551b6f9caf16f0))
16
+
3
17
  ## [0.2.8](https://github.com/rubyists/leopard/compare/v0.2.7...v0.2.8) (2026-05-19)
4
18
 
5
19
 
data/Readme.adoc CHANGED
@@ -4,6 +4,7 @@ bougyman <me@bougyman.com>
4
4
  :conventional-commits: https://www.conventionalcommits.org/en/v1.0.0/[Conventional Commits]
5
5
  :dry-configurable: https://github.com/dry-rb/dry-configurable[Dry::Configurable]
6
6
  :dry-monads: https://github.com/dry-rb/dry-monads[Dry::Monads]
7
+ :jetstream-consumer-config: https://docs.nats.io/nats-concepts/jetstream/consumers#configuration[Jetstream Consumer Configuration]
7
8
 
8
9
  Leopard is a small framework for building concurrent {service-api} workers.
9
10
  It uses `Concurrent::FixedThreadPool` to manage multiple workers in a single process and provides a
@@ -91,6 +92,24 @@ end
91
92
  EchoService.use LoggerMiddleware
92
93
  ----
93
94
 
95
+ == Request/Reply Failure Logging
96
+
97
+ Request/reply endpoints log failed handler results at error level by default.
98
+ To change only that decision while keeping Leopard's existing callbacks and
99
+ error responses, configure a policy that accepts the failure payload:
100
+
101
+ [source,ruby]
102
+ ----
103
+ EchoService.config.request_reply_failure_log_policy = lambda do |failure|
104
+ next if failure.status.between?(400, 499)
105
+
106
+ EchoService.logger.error 'Error processing message: ', failure
107
+ end
108
+ ----
109
+
110
+ The policy is called before Leopard responds with the failure payload. It must
111
+ not change the response: every failure still uses `respond_with_error`.
112
+
94
113
  == JetStream Pull Consumers
95
114
 
96
115
  Leopard can also bind JetStream pull consumers through the same middleware and `Dry::Monads::Result`
@@ -116,6 +135,13 @@ class EventConsumer
116
135
  end
117
136
  ----
118
137
 
138
+ Any configuration options available for JetStream consumers can be passed via snakeified keys
139
+ in the `consumer` hash, with the exception of `durable_name`, `filter_subject` and `ack_policy`.
140
+ `durable_name` and `filter_subject` are both derived from the endpoint, and `ack_policy` is always
141
+ set to `explicit`.
142
+
143
+ {jetstream-consumer-config}
144
+
119
145
  JetStream handlers receive the same `Rubyists::Leopard::MessageWrapper` as service endpoints.
120
146
  Leopard will:
121
147
 
@@ -0,0 +1,52 @@
1
+ = Enable Injectable Request/Reply Failure Logging
2
+
3
+ == Goal
4
+
5
+ Allow a service to change failure logging without replacing Leopard's
6
+ request/reply callbacks. Applications can treat expected client failures, such
7
+ as failures with particular statuses, differently from operational failures
8
+ while preserving the existing response behavior.
9
+
10
+ == Current behavior
11
+
12
+ Every `Dry::Monads::Failure` is currently logged at error level before its
13
+ payload is returned through `MessageWrapper#respond_with_error`. An application
14
+ cannot change that logging policy without overriding Leopard internals.
15
+
16
+ == Plan
17
+
18
+ . Add a `:request_reply_failure_log_policy` service setting. When present, the
19
+ policy receives each failure payload before Leopard returns it to the client.
20
+ . Keep `NatsRequestReplyCallbacks` as the callback owner. Its default policy
21
+ logs at error level, preserving current behavior when no policy is configured.
22
+ . Pass the configured policy to `NatsRequestReplyCallbacks` when the worker
23
+ constructs its memoized callback helper.
24
+ . Document that policies must leave `respond_with_error` behavior unchanged.
25
+
26
+ == Suggested application policy
27
+
28
+ Configure a policy directly. Leopard owns the callback mapping and invokes the
29
+ policy only for failed handler results.
30
+
31
+ [source,ruby]
32
+ ----
33
+ MyService.config.request_reply_failure_log_policy = lambda do |failure|
34
+ next if failure.status.between?(400, 499)
35
+
36
+ MyService.logger.error 'Error processing message: ', failure
37
+ end
38
+ ----
39
+
40
+ Use the status accessor provided by the application's failure type (or another
41
+ application-specific classifier). The policy must not change the response:
42
+ Leopard still calls `wrapper.respond_with_error(failure)` for every failure.
43
+
44
+ == Acceptance criteria
45
+
46
+ * Existing request/reply services log and respond exactly as before without
47
+ configuration.
48
+ * A service can configure failure logging without monkey-patching, inheriting
49
+ from, or replacing Leopard's callbacks.
50
+ * A configured policy can select a log level, or no log entry, for any failure
51
+ status while returning the same error payload to the requester.
52
+ * Successes and exceptions continue to follow their existing callback paths.
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/leopard/nats_api_server'
5
+
6
+ # Example to echo the given message
7
+ class ServiceGroups
8
+ include Rubyists::Leopard::NatsApiServer
9
+
10
+ def initialize(a_var: 1)
11
+ logger.info "ServiceGroups initialized with a_var: #{a_var}"
12
+ end
13
+
14
+ group :mammal
15
+ group :feline, group: :mammal, queue: :meow
16
+ endpoint(:sound, group: :feline) { |msg| Success("Meow! #{msg.data}") }
17
+ endpoint(:fail, group: :feline) { |msg| Failure({ reason: 'cat nap', data: msg.data }) }
18
+ end
19
+
20
+ if __FILE__ == $PROGRAM_NAME
21
+ SemanticLogger.default_level = :info
22
+ SemanticLogger.add_appender(io: $stdout, formatter: :color)
23
+ ServiceGroups.run(
24
+ nats_url: 'nats://localhost:4222',
25
+ service_opts: {
26
+ name: 'example.groups',
27
+ version: '1.0.0',
28
+ instance_args: { a_var: 2 },
29
+ },
30
+ instances: 1,
31
+ )
32
+ end
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
5
+
6
+ require 'leopard/nats_service_discovery/cli'
7
+
8
+ cli = Rubyists::Leopard::NatsServiceDiscovery::CLI
9
+ opts = cli.parse(
10
+ ARGV,
11
+ banner: 'Usage: leopard-service-info [options]',
12
+ description: 'Prints raw $SRV.INFO responses as JSON.',
13
+ )
14
+
15
+ cli.with_client(opts) do |client|
16
+ result = cli.operation_result!(
17
+ Rubyists::Leopard::NatsServiceDiscovery::Operation::Info.call(**cli.discovery_options(opts, client)),
18
+ )
19
+ cli.print_json(result[:responses])
20
+ end
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
5
+
6
+ require 'leopard/nats_service_discovery/cli'
7
+
8
+ cli = Rubyists::Leopard::NatsServiceDiscovery::CLI
9
+ opts = cli.parse(
10
+ ARGV,
11
+ banner: 'Usage: leopard-service-map [options]',
12
+ description: 'Builds a subject-to-endpoint map from $SRV.INFO responses.',
13
+ json: true,
14
+ )
15
+
16
+ cli.with_client(opts) do |client|
17
+ result = cli.operation_result!(
18
+ Rubyists::Leopard::NatsServiceDiscovery::Operation::SubjectMap.call(**cli.discovery_options(opts, client)),
19
+ )
20
+
21
+ if opts[:json]
22
+ cli.print_json(result[:subject_map])
23
+ else
24
+ rows = result[:subject_map].flat_map do |subject, listeners|
25
+ listeners.map { |listener| [subject, *listener.values_at('service', 'service_id', 'version', 'endpoint', 'queue_group')] }
26
+ end
27
+ cli.print_table(%w[SUBJECT SERVICE SERVICE_ID VERSION ENDPOINT QUEUE], rows)
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
5
+
6
+ require 'leopard/nats_service_discovery/cli'
7
+
8
+ cli = Rubyists::Leopard::NatsServiceDiscovery::CLI
9
+ opts = cli.parse(
10
+ ARGV,
11
+ banner: 'Usage: leopard-service-stats [options]',
12
+ description: 'Prints service endpoint stats from $SRV.STATS responses.',
13
+ json: true,
14
+ )
15
+
16
+ cli.with_client(opts) do |client|
17
+ result = cli.operation_result!(
18
+ Rubyists::Leopard::NatsServiceDiscovery::Operation::Stats.call(**cli.discovery_options(opts, client)),
19
+ )
20
+
21
+ if opts[:json]
22
+ cli.print_json(result[:responses])
23
+ else
24
+ rows = result[:responses].flat_map do |service|
25
+ Array(service['endpoints']).map do |endpoint|
26
+ [
27
+ service['name'], service['id'], endpoint['subject'], endpoint['name'], endpoint['queue_group'],
28
+ endpoint['num_requests'], endpoint['num_errors'], endpoint['average_processing_time'], endpoint['last_error']
29
+ ]
30
+ end
31
+ end
32
+ cli.print_table(%w[SERVICE SERVICE_ID SUBJECT ENDPOINT QUEUE REQUESTS ERRORS AVG_NS LAST_ERROR], rows)
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
5
+
6
+ require 'leopard/nats_service_discovery/cli'
7
+
8
+ cli = Rubyists::Leopard::NatsServiceDiscovery::CLI
9
+ opts = cli.parse(
10
+ ARGV,
11
+ banner: 'Usage: leopard-services [options]',
12
+ description: 'Lists NATS Service API services and their endpoint counts.',
13
+ json: true,
14
+ )
15
+
16
+ cli.with_client(opts) do |client|
17
+ result = cli.operation_result!(
18
+ Rubyists::Leopard::NatsServiceDiscovery::Operation::Services.call(**cli.discovery_options(opts, client)),
19
+ )
20
+
21
+ if opts[:json]
22
+ cli.print_json(result[:services])
23
+ else
24
+ rows = result[:services].map { |service| service.values_at('name', 'id', 'version', 'endpoints') }
25
+ cli.print_table(%w[SERVICE ID VERSION ENDPOINTS], rows)
26
+ end
27
+ end
@@ -31,6 +31,7 @@ module Rubyists
31
31
  base.extend(Dry::Monads[:result])
32
32
  base.extend(Dry::Configurable)
33
33
  base.setting :logger, default: Rubyists::Leopard.logger, reader: true
34
+ base.setting :request_reply_failure_log_policy, default: nil, reader: true
34
35
  end
35
36
 
36
37
  # Configuration for a request/reply endpoint declared with {.endpoint}.
@@ -472,7 +473,10 @@ module Rubyists
472
473
  #
473
474
  # @return [NatsRequestReplyCallbacks] The request/reply callback helper.
474
475
  def request_reply_callbacks
475
- @request_reply_callbacks ||= NatsRequestReplyCallbacks.new(logger:)
476
+ @request_reply_callbacks ||= NatsRequestReplyCallbacks.new(
477
+ logger:,
478
+ failure_log_policy: self.class.request_reply_failure_log_policy,
479
+ )
476
480
  end
477
481
 
478
482
  # Returns the memoized message processor for this worker instance.
@@ -6,11 +6,15 @@ module Rubyists
6
6
  class NatsRequestReplyCallbacks
7
7
  # Builds a callback set for request/reply endpoint outcomes.
8
8
  #
9
- # @param logger [#error] Logger used for failure payloads.
9
+ # @param logger [#error] Logger used by the default failure log policy.
10
+ # @param failure_log_policy [#call, nil] Optional policy called with a
11
+ # failure payload before it is returned to the requester.
10
12
  #
11
13
  # @return [void]
12
- def initialize(logger:)
13
- @logger = logger
14
+ def initialize(logger:, failure_log_policy: nil)
15
+ @failure_log_policy = failure_log_policy || lambda do |failure|
16
+ logger.error 'Error processing message: ', failure
17
+ end
14
18
  end
15
19
 
16
20
  # Returns transport callbacks for request/reply endpoints.
@@ -43,7 +47,7 @@ module Rubyists
43
47
  #
44
48
  # @return [void]
45
49
  def respond_with_failure(wrapper, result)
46
- log_failure(result.failure)
50
+ @failure_log_policy.call(result.failure)
47
51
  wrapper.respond_with_error(result.failure)
48
52
  end
49
53
 
@@ -56,15 +60,6 @@ module Rubyists
56
60
  def respond_with_error(wrapper, error)
57
61
  wrapper.respond_with_error(error)
58
62
  end
59
-
60
- # Logs the failure payload returned by a handler.
61
- #
62
- # @param failure [Object] The failure payload from the handler.
63
- #
64
- # @return [void]
65
- def log_failure(failure)
66
- @logger.error 'Error processing message: ', failure
67
- end
68
63
  end
69
64
  end
70
65
  end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'optparse'
5
+ require_relative 'operation'
6
+
7
+ module Rubyists
8
+ module Leopard
9
+ class NatsServiceDiscovery
10
+ # Shared command-line helpers for NATS service discovery executables.
11
+ #
12
+ # @api private
13
+ module CLI
14
+ module_function
15
+
16
+ # Parses common NATS discovery CLI options.
17
+ #
18
+ # @param argv [Array<String>] Command-line arguments.
19
+ # @param banner [String] OptionParser banner.
20
+ # @param description [String] Command description.
21
+ # @param json [Boolean] Whether the command supports `--json`.
22
+ #
23
+ # @return [Hash] Parsed options.
24
+ def parse(argv, banner:, description:, json: false)
25
+ opts = default_options(json:)
26
+ OptionParser.new do |parser|
27
+ parser.banner = banner
28
+ parser.separator ''
29
+ parser.separator description
30
+ parser.separator ''
31
+ add_common_options(parser, opts)
32
+ end.parse!(argv)
33
+ validate!(opts)
34
+ opts
35
+ end
36
+
37
+ # Opens a NATS client for the duration of the provided block.
38
+ #
39
+ # @param opts [Hash] Parsed CLI options.
40
+ # @yieldparam client [NATS::Client] Connected NATS client.
41
+ #
42
+ # @return [Object] The block return value.
43
+ def with_client(opts)
44
+ result = Operation::Connect.call(
45
+ server: opts[:server],
46
+ creds: opts[:creds],
47
+ connect_options: { reconnect: false, connect_timeout: opts[:timeout] },
48
+ )
49
+ abort_with(result[:error]) unless result.success?
50
+
51
+ yield result[:client]
52
+ ensure
53
+ result&.[](:client)&.close
54
+ end
55
+
56
+ # Prints a value as pretty JSON.
57
+ #
58
+ # @param value [Object] JSON-serializable value.
59
+ #
60
+ # @return [void]
61
+ def print_json(value)
62
+ puts JSON.pretty_generate(value)
63
+ end
64
+
65
+ # Prints rows as a simple aligned table.
66
+ #
67
+ # @param headers [Array<String>] Column headers.
68
+ # @param rows [Array<Array>] Table rows.
69
+ #
70
+ # @return [void]
71
+ def print_table(headers, rows)
72
+ widths = column_widths(headers, rows)
73
+ format = widths.map { |width| "%-#{width}s" }.join(' ')
74
+ puts format % headers
75
+ puts widths.map { |width| '-' * width }.join(' ')
76
+ rows.each { |row| puts format % row }
77
+ end
78
+
79
+ # Prints an error and exits with a non-zero status.
80
+ #
81
+ # @param error [Exception] Error to report.
82
+ #
83
+ # @return [void]
84
+ def abort_with(error)
85
+ warn "error: #{error.message}"
86
+ exit 1
87
+ end
88
+
89
+ # Returns a successful operation result or aborts the process.
90
+ #
91
+ # @param result [Trailblazer::Operation::Railway::Result] Operation result.
92
+ #
93
+ # @return [Trailblazer::Operation::Railway::Result]
94
+ def operation_result!(result)
95
+ abort_with(result[:error]) unless result.success?
96
+
97
+ result
98
+ end
99
+
100
+ # Builds discovery operation options from CLI options and a client.
101
+ #
102
+ # @param opts [Hash] Parsed CLI options.
103
+ # @param client [NATS::Client] Connected NATS client.
104
+ #
105
+ # @return [Hash] Arguments for discovery operations.
106
+ def discovery_options(opts, client)
107
+ {
108
+ client:,
109
+ name: opts[:name],
110
+ id: opts[:id],
111
+ prefix: opts[:prefix],
112
+ timeout: opts[:timeout],
113
+ }
114
+ end
115
+
116
+ # Builds default CLI options.
117
+ #
118
+ # @param json [Boolean] Whether the command supports `--json`.
119
+ #
120
+ # @return [Hash] Default options.
121
+ def default_options(json:)
122
+ {
123
+ server: ENV.fetch('NATS_URL', 'nats://127.0.0.1:4222'),
124
+ timeout: DEFAULT_TIMEOUT,
125
+ prefix: DEFAULT_PREFIX,
126
+ json: json ? false : nil,
127
+ }
128
+ end
129
+
130
+ # Adds all common options to an OptionParser.
131
+ #
132
+ # @param parser [OptionParser] Parser to configure.
133
+ # @param opts [Hash] Mutable parsed option accumulator.
134
+ #
135
+ # @return [void]
136
+ def add_common_options(parser, opts)
137
+ add_connection_options(parser, opts)
138
+ add_filter_options(parser, opts)
139
+ add_output_options(parser, opts)
140
+ end
141
+
142
+ # Adds NATS connection options to an OptionParser.
143
+ #
144
+ # @param parser [OptionParser] Parser to configure.
145
+ # @param opts [Hash] Mutable parsed option accumulator.
146
+ #
147
+ # @return [void]
148
+ def add_connection_options(parser, opts)
149
+ parser.on('-s', '--server URL', 'NATS server URL. Defaults to ENV[NATS_URL] or local NATS') do |url|
150
+ opts[:server] = url
151
+ end
152
+ parser.on('--creds FILE', 'NATS user credentials file') { |file| opts[:creds] = file }
153
+ end
154
+
155
+ # Adds service filter options to an OptionParser.
156
+ #
157
+ # @param parser [OptionParser] Parser to configure.
158
+ # @param opts [Hash] Mutable parsed option accumulator.
159
+ #
160
+ # @return [void]
161
+ def add_filter_options(parser, opts)
162
+ parser.on('--name NAME', 'Only query services with this name') { |name| opts[:name] = name }
163
+ parser.on('--id ID', 'Only query this service id. Requires --name') { |id| opts[:id] = id }
164
+ parser.on('--prefix PREFIX', 'Service API prefix. Defaults to $SRV') { |prefix| opts[:prefix] = prefix }
165
+ parser.on('-t', '--timeout SECONDS', Float, 'Reply collection idle timeout') { |timeout| opts[:timeout] = timeout }
166
+ end
167
+
168
+ # Adds output and help options to an OptionParser.
169
+ #
170
+ # @param parser [OptionParser] Parser to configure.
171
+ # @param opts [Hash] Mutable parsed option accumulator.
172
+ #
173
+ # @return [void]
174
+ def add_output_options(parser, opts)
175
+ parser.on('--json', 'Print JSON output') { opts[:json] = true } unless opts[:json].nil?
176
+ parser.on('-h', '--help', 'Show this help') do
177
+ puts parser
178
+ exit
179
+ end
180
+ end
181
+
182
+ # Validates parsed CLI options.
183
+ #
184
+ # @param opts [Hash] Parsed CLI options.
185
+ #
186
+ # @return [void]
187
+ def validate!(opts)
188
+ return unless opts[:id] && opts[:name].to_s.empty?
189
+
190
+ warn 'error: --id requires --name'
191
+ exit 1
192
+ end
193
+
194
+ # Computes table column widths.
195
+ #
196
+ # @param headers [Array<String>] Column headers.
197
+ # @param rows [Array<Array>] Table rows.
198
+ #
199
+ # @return [Array<Integer>] Width for each column.
200
+ def column_widths(headers, rows)
201
+ headers.each_index.map do |index|
202
+ ([headers[index]] + rows.map { |row| row[index].to_s }).map(&:length).max
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trailblazer/operation'
4
+ require_relative '../../nats_service_discovery'
5
+
6
+ module Rubyists
7
+ module Leopard
8
+ class NatsServiceDiscovery
9
+ module Operation
10
+ # Collects and parses JSON replies for an arbitrary Service API subject.
11
+ #
12
+ # On success, the result exposes `:responses`.
13
+ # On failure, the result exposes `:error`.
14
+ class Collect < Trailblazer::Operation
15
+ step :collect
16
+
17
+ # Collects JSON replies from `subject`.
18
+ #
19
+ # @param ctx [Hash] Operation context.
20
+ # @param client [NATS::Client] Connected NATS client.
21
+ # @param subject [String] Service API subject to query.
22
+ # @param timeout [Numeric] Idle timeout while waiting for replies.
23
+ #
24
+ # @return [Boolean] Whether replies were collected successfully.
25
+ def collect(ctx, client:, subject:, timeout: DEFAULT_TIMEOUT, **)
26
+ ctx[:responses] = NatsServiceDiscovery.new(client:).send(:collect_json, subject, timeout:)
27
+ rescue StandardError => e
28
+ ctx[:error] = e
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nats/client'
4
+ require 'trailblazer/operation'
5
+ require_relative '../../nats_service_discovery'
6
+
7
+ module Rubyists
8
+ module Leopard
9
+ class NatsServiceDiscovery
10
+ module Operation
11
+ # Opens a NATS client connection for discovery operations.
12
+ #
13
+ # On success, the result exposes `:client`.
14
+ # On failure, the result exposes `:error`.
15
+ class Connect < Trailblazer::Operation
16
+ step :connect
17
+
18
+ # Connects to NATS using `:server` or `:nats_url`.
19
+ #
20
+ # @param ctx [Hash] Operation context.
21
+ #
22
+ # @return [Boolean] Whether the connection was opened.
23
+ def connect(ctx, **)
24
+ opts = (ctx[:connect_options] || {}).dup
25
+ opts[:user_credentials] = ctx[:creds] if ctx[:creds]
26
+ ctx[:client] = NATS.connect(connection_url(ctx), opts)
27
+ rescue StandardError => e
28
+ ctx[:error] = e
29
+ false
30
+ end
31
+
32
+ private
33
+
34
+ # Default NATS URL used when no URL is provided.
35
+ #
36
+ # @return [String]
37
+ def default_url
38
+ ENV.fetch('NATS_URL', 'nats://127.0.0.1:4222')
39
+ end
40
+
41
+ # Resolves the NATS URL from the operation context.
42
+ #
43
+ # @param ctx [Hash] Operation context.
44
+ #
45
+ # @return [String]
46
+ def connection_url(ctx)
47
+ ctx[:nats_url] || ctx[:server] || default_url
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../nats_service_discovery'
4
+
5
+ module Rubyists
6
+ module Leopard
7
+ class NatsServiceDiscovery
8
+ module Operation
9
+ # Shared option extraction for operations that compose other discovery operations.
10
+ module DiscoveryOptions
11
+ private
12
+
13
+ # Extracts the common discovery options from a Trailblazer context.
14
+ #
15
+ # @param ctx [Hash] Operation context.
16
+ #
17
+ # @return [Hash] Arguments suitable for another discovery operation.
18
+ def operation_options(ctx)
19
+ {
20
+ client: ctx[:client],
21
+ prefix: ctx[:prefix],
22
+ timeout: ctx[:timeout],
23
+ name: ctx[:name],
24
+ id: ctx[:id],
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'verb'
4
+
5
+ module Rubyists
6
+ module Leopard
7
+ class NatsServiceDiscovery
8
+ module Operation
9
+ # Collects `$SRV.INFO` responses.
10
+ #
11
+ # On success, the result exposes `:responses`.
12
+ class Info < Verb
13
+ # Invokes {NatsServiceDiscovery#info}.
14
+ #
15
+ # @param discovery [NatsServiceDiscovery] Discovery helper.
16
+ #
17
+ # @return [Array<Hash>] Parsed info responses.
18
+ def public_send_verb(discovery, **)
19
+ discovery.info(**)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'verb'
4
+
5
+ module Rubyists
6
+ module Leopard
7
+ class NatsServiceDiscovery
8
+ module Operation
9
+ # Collects `$SRV.PING` responses.
10
+ #
11
+ # On success, the result exposes `:responses`.
12
+ class Ping < Verb
13
+ # Invokes {NatsServiceDiscovery#ping}.
14
+ #
15
+ # @param discovery [NatsServiceDiscovery] Discovery helper.
16
+ #
17
+ # @return [Array<Hash>] Parsed ping responses.
18
+ def public_send_verb(discovery, **)
19
+ discovery.ping(**)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trailblazer/operation'
4
+ require_relative 'discovery_options'
5
+ require_relative 'info'
6
+
7
+ module Rubyists
8
+ module Leopard
9
+ class NatsServiceDiscovery
10
+ module Operation
11
+ # Builds service-list summaries from `$SRV.INFO` responses.
12
+ #
13
+ # On success, the result exposes `:responses` and `:services`.
14
+ class Services < Trailblazer::Operation
15
+ include DiscoveryOptions
16
+
17
+ step :load_info?
18
+ step :summarize
19
+
20
+ # Loads raw service info responses for summarization.
21
+ #
22
+ # @param ctx [Hash] Operation context.
23
+ #
24
+ # @return [Boolean] Whether service info was loaded.
25
+ def load_info?(ctx, **)
26
+ result = Info.call(**operation_options(ctx))
27
+ ctx[:error] = result[:error] unless result.success?
28
+ ctx[:responses] = result[:responses]
29
+ result.success?
30
+ end
31
+
32
+ # Builds compact service summaries from raw info responses.
33
+ #
34
+ # @param ctx [Hash] Operation context.
35
+ # @param responses [Array<Hash>] Parsed `$SRV.INFO` responses.
36
+ #
37
+ # @return [Array<Hash>] Service summary rows.
38
+ def summarize(ctx, responses:, **)
39
+ ctx[:services] = responses.map do |service|
40
+ {
41
+ 'name' => service['name'],
42
+ 'id' => service['id'],
43
+ 'version' => service['version'],
44
+ 'endpoints' => Array(service['endpoints']).size,
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'verb'
4
+
5
+ module Rubyists
6
+ module Leopard
7
+ class NatsServiceDiscovery
8
+ module Operation
9
+ # Collects `$SRV.STATS` responses.
10
+ #
11
+ # On success, the result exposes `:responses`.
12
+ class Stats < Verb
13
+ # Invokes {NatsServiceDiscovery#stats}.
14
+ #
15
+ # @param discovery [NatsServiceDiscovery] Discovery helper.
16
+ #
17
+ # @return [Array<Hash>] Parsed stats responses.
18
+ def public_send_verb(discovery, **)
19
+ discovery.stats(**)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trailblazer/operation'
4
+ require_relative 'discovery_options'
5
+ require_relative 'info'
6
+
7
+ module Rubyists
8
+ module Leopard
9
+ class NatsServiceDiscovery
10
+ module Operation
11
+ # Builds a subject-to-endpoint listener map from `$SRV.INFO` responses.
12
+ #
13
+ # On success, the result exposes `:responses` and `:subject_map`.
14
+ class SubjectMap < Trailblazer::Operation
15
+ include DiscoveryOptions
16
+
17
+ step :load_info?
18
+ step :map_subjects
19
+
20
+ # Loads raw service info responses for mapping.
21
+ #
22
+ # @param ctx [Hash] Operation context.
23
+ #
24
+ # @return [Boolean] Whether service info was loaded.
25
+ def load_info?(ctx, **)
26
+ result = Info.call(**operation_options(ctx))
27
+ ctx[:error] = result[:error] unless result.success?
28
+ ctx[:responses] = result[:responses]
29
+ result.success?
30
+ end
31
+
32
+ # Builds a subject-to-listeners map from info responses.
33
+ #
34
+ # @param ctx [Hash] Operation context.
35
+ # @param responses [Array<Hash>] Parsed `$SRV.INFO` responses.
36
+ #
37
+ # @return [Hash] Listener entries keyed by NATS subject.
38
+ def map_subjects(ctx, responses:, **)
39
+ ctx[:subject_map] = responses.each_with_object({}) do |service, subjects|
40
+ Array(service['endpoints']).each do |endpoint|
41
+ add_listener(subjects, service, endpoint)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Adds an endpoint listener to the subject map.
49
+ #
50
+ # @param subjects [Hash] Subject map accumulator.
51
+ # @param service [Hash] Parsed `$SRV.INFO` service response.
52
+ # @param endpoint [Hash] Endpoint payload from the service response.
53
+ #
54
+ # @return [void]
55
+ def add_listener(subjects, service, endpoint)
56
+ subject = endpoint['subject']
57
+ return if subject.to_s.empty?
58
+
59
+ subjects[subject] ||= []
60
+ subjects[subject] << listener_entry(service, endpoint)
61
+ end
62
+
63
+ # Builds a subject-map listener entry.
64
+ #
65
+ # @param service [Hash] Parsed `$SRV.INFO` service response.
66
+ # @param endpoint [Hash] Endpoint payload from the service response.
67
+ #
68
+ # @return [Hash] Listener entry suitable for subject maps.
69
+ def listener_entry(service, endpoint)
70
+ {
71
+ 'service' => service['name'],
72
+ 'service_id' => service['id'],
73
+ 'version' => service['version'],
74
+ 'endpoint' => endpoint['name'],
75
+ 'queue_group' => endpoint['queue_group'],
76
+ 'metadata' => endpoint['metadata'],
77
+ }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trailblazer/operation'
4
+ require_relative '../../nats_service_discovery'
5
+
6
+ module Rubyists
7
+ module Leopard
8
+ class NatsServiceDiscovery
9
+ module Operation
10
+ # Base class for `$SRV.<VERB>` operations.
11
+ #
12
+ # Subclasses choose which Service API verb to invoke.
13
+ class Verb < Trailblazer::Operation
14
+ step :collect
15
+
16
+ # Collects responses for the subclass Service API verb.
17
+ #
18
+ # @param ctx [Hash] Operation context.
19
+ #
20
+ # @return [Boolean] Whether responses were collected successfully.
21
+ def collect(ctx, **)
22
+ discovery = NatsServiceDiscovery.new(client: ctx[:client], prefix: ctx[:prefix] || DEFAULT_PREFIX)
23
+ ctx[:responses] = public_send_verb(
24
+ discovery,
25
+ name: ctx[:name],
26
+ id: ctx[:id],
27
+ timeout: ctx[:timeout] || DEFAULT_TIMEOUT,
28
+ )
29
+ rescue StandardError => e
30
+ ctx[:error] = e
31
+ false
32
+ end
33
+
34
+ private
35
+
36
+ # Invokes the subclass-specific discovery method.
37
+ #
38
+ # @raise [NotImplementedError] when a subclass does not implement this.
39
+ def public_send_verb(_discovery, **)
40
+ raise NotImplementedError, "#{self.class} must define #public_send_verb"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../nats_service_discovery'
4
+
5
+ module Rubyists
6
+ module Leopard
7
+ class NatsServiceDiscovery
8
+ # Trailblazer operations for NATS Service API discovery workflows.
9
+ module Operation
10
+ end
11
+ end
12
+ end
13
+ end
14
+
15
+ require_relative 'operation/discovery_options'
16
+ require_relative 'operation/connect'
17
+ require_relative 'operation/collect'
18
+ require_relative 'operation/verb'
19
+ require_relative 'operation/ping'
20
+ require_relative 'operation/info'
21
+ require_relative 'operation/stats'
22
+ require_relative 'operation/services'
23
+ require_relative 'operation/subject_map'
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'nats/client'
5
+
6
+ module Rubyists
7
+ module Leopard
8
+ # Collects NATS Service API monitoring responses from a cluster.
9
+ #
10
+ # The NATS Service API uses request/reply subjects such as `$SRV.INFO`.
11
+ # Cluster-wide requests can receive multiple replies, so this helper creates
12
+ # an inbox subscription, publishes the request, and collects replies until a
13
+ # short timeout elapses.
14
+ class NatsServiceDiscovery
15
+ # Default NATS Service API monitoring prefix.
16
+ DEFAULT_PREFIX = '$SRV'
17
+ # Default idle timeout used to decide that all service replies arrived.
18
+ DEFAULT_TIMEOUT = 0.25
19
+
20
+ attr_reader :client, :prefix
21
+
22
+ # @param client [NATS::Client] Connected NATS client.
23
+ # @param prefix [String] Service API monitoring prefix.
24
+ def initialize(client:, prefix: DEFAULT_PREFIX)
25
+ @client = client
26
+ @prefix = prefix
27
+ end
28
+
29
+ # Collects `$SRV.PING` responses.
30
+ #
31
+ # @param name [String, nil] Optional service name filter.
32
+ # @param id [String, nil] Optional service id filter; requires `name`.
33
+ # @param timeout [Numeric] Idle timeout while collecting replies.
34
+ #
35
+ # @return [Array<Hash>] Parsed ping responses.
36
+ def ping(name: nil, id: nil, timeout: DEFAULT_TIMEOUT)
37
+ collect_json(service_subject('PING', name:, id:), timeout:)
38
+ end
39
+
40
+ # Collects `$SRV.INFO` responses.
41
+ #
42
+ # @param name [String, nil] Optional service name filter.
43
+ # @param id [String, nil] Optional service id filter; requires `name`.
44
+ # @param timeout [Numeric] Idle timeout while collecting replies.
45
+ #
46
+ # @return [Array<Hash>] Parsed info responses.
47
+ def info(name: nil, id: nil, timeout: DEFAULT_TIMEOUT)
48
+ collect_json(service_subject('INFO', name:, id:), timeout:)
49
+ end
50
+
51
+ # Collects `$SRV.STATS` responses.
52
+ #
53
+ # @param name [String, nil] Optional service name filter.
54
+ # @param id [String, nil] Optional service id filter; requires `name`.
55
+ # @param timeout [Numeric] Idle timeout while collecting replies.
56
+ #
57
+ # @return [Array<Hash>] Parsed stats responses.
58
+ def stats(name: nil, id: nil, timeout: DEFAULT_TIMEOUT)
59
+ collect_json(service_subject('STATS', name:, id:), timeout:)
60
+ end
61
+
62
+ # Builds a subject-to-listener map from service info responses.
63
+ #
64
+ # @param name [String, nil] Optional service name filter.
65
+ # @param id [String, nil] Optional service id filter; requires `name`.
66
+ # @param timeout [Numeric] Idle timeout while collecting replies.
67
+ #
68
+ # @return [Hash{String => Array<Hash>}] Endpoint listeners keyed by subject.
69
+ def endpoint_subject_map(name: nil, id: nil, timeout: DEFAULT_TIMEOUT)
70
+ info(name:, id:, timeout:).each_with_object({}) do |service, subjects|
71
+ Array(service['endpoints']).each do |endpoint|
72
+ subject = endpoint['subject']
73
+ next if subject.to_s.empty?
74
+
75
+ subjects[subject] ||= []
76
+ subjects[subject] << listener_entry(service, endpoint)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Builds a Service API monitoring subject.
82
+ #
83
+ # @param verb [String, Symbol] Monitoring verb such as `INFO`.
84
+ # @param name [String, nil] Optional service name filter.
85
+ # @param id [String, nil] Optional service id filter; requires `name`.
86
+ #
87
+ # @return [String] Monitoring subject.
88
+ def service_subject(verb, name: nil, id: nil)
89
+ raise ArgumentError, 'service id requires a service name' if present?(id) && !present?(name)
90
+
91
+ parts = [prefix, verb.to_s.upcase]
92
+ parts << name.to_s if present?(name)
93
+ parts << id.to_s if present?(id)
94
+ parts.join('.')
95
+ end
96
+
97
+ private
98
+
99
+ # Collects JSON replies for a Service API subject until `timeout` elapses.
100
+ #
101
+ # @param subject [String] Service API subject to publish.
102
+ # @param timeout [Numeric] Idle timeout while waiting for replies.
103
+ #
104
+ # @return [Array<Hash>] Parsed response payloads.
105
+ def collect_json(subject, timeout:)
106
+ replies = []
107
+ sub = subscribe_to_inbox
108
+ client.publish(subject, '', sub.subject)
109
+ begin
110
+ collect_replies(sub, replies, timeout)
111
+ rescue NATS::Timeout
112
+ replies
113
+ end
114
+ ensure
115
+ sub&.unsubscribe
116
+ end
117
+
118
+ # Subscribes to an ephemeral reply inbox and flushes the subscription.
119
+ #
120
+ # @return [NATS::Subscription] Subscription bound to the reply inbox.
121
+ def subscribe_to_inbox
122
+ sub = client.subscribe(client.new_inbox)
123
+ client.flush
124
+ sub
125
+ end
126
+
127
+ # Appends parsed replies to the provided accumulator until timeout.
128
+ #
129
+ # @param sub [NATS::Subscription] Reply subscription.
130
+ # @param replies [Array<Hash>] Response accumulator.
131
+ # @param timeout [Numeric] Idle timeout while waiting for replies.
132
+ #
133
+ # @return [void]
134
+ def collect_replies(sub, replies, timeout)
135
+ loop do
136
+ msg = sub.next_msg(timeout:)
137
+ next if no_responders?(msg)
138
+
139
+ replies << JSON.parse(msg.data)
140
+ end
141
+ end
142
+
143
+ # Reports whether a reply is the server no-responders status message.
144
+ #
145
+ # @param msg [NATS::Msg] Reply message.
146
+ #
147
+ # @return [Boolean]
148
+ def no_responders?(msg)
149
+ msg.header && msg.header['Status'] == '503'
150
+ end
151
+
152
+ # Builds a subject-map listener entry from a service and endpoint payload.
153
+ #
154
+ # @param service [Hash] Parsed `$SRV.INFO` service response.
155
+ # @param endpoint [Hash] Endpoint payload from the service response.
156
+ #
157
+ # @return [Hash] Listener entry suitable for subject maps.
158
+ def listener_entry(service, endpoint)
159
+ {
160
+ 'service' => service['name'],
161
+ 'service_id' => service['id'],
162
+ 'version' => service['version'],
163
+ 'endpoint' => endpoint['name'],
164
+ 'queue_group' => endpoint['queue_group'],
165
+ 'metadata' => endpoint['metadata'],
166
+ }
167
+ end
168
+
169
+ # Reports whether a value is present for subject construction.
170
+ #
171
+ # @param value [Object] Value to check.
172
+ #
173
+ # @return [Boolean]
174
+ def present?(value)
175
+ !value.nil? && !value.to_s.empty?
176
+ end
177
+ end
178
+ end
179
+ end
@@ -3,7 +3,7 @@
3
3
  module Rubyists
4
4
  module Leopard
5
5
  # x-release-please-start-version
6
- VERSION = '0.2.8'
6
+ VERSION = '0.2.10'
7
7
  # x-release-please-end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leopard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.8
4
+ version: 0.2.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - bougyman
@@ -69,21 +69,39 @@ dependencies:
69
69
  name: semantic_logger
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
- - - "~>"
72
+ - - ">="
73
73
  - !ruby/object:Gem::Version
74
74
  version: '4'
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
- - - "~>"
79
+ - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '4'
82
+ - !ruby/object:Gem::Dependency
83
+ name: trailblazer-operation
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.11'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.11'
82
96
  description: Leopard is a puma-like server for managing concurrent NATS ServiceApi
83
97
  endpoint workers
84
98
  email:
85
99
  - bougyman@users.noreply.github.com
86
- executables: []
100
+ executables:
101
+ - leopard-service-info
102
+ - leopard-service-map
103
+ - leopard-service-stats
104
+ - leopard-services
87
105
  extensions: []
88
106
  extra_rdoc_files: []
89
107
  files:
@@ -100,8 +118,14 @@ files:
100
118
  - ci/nats/start.sh
101
119
  - ci/publish-gem.sh
102
120
  - doc/service-api-vs-rest.adoc
121
+ - documents/enable-injectable-request-reply-callbacks.adoc
103
122
  - examples/echo_endpoint.rb
123
+ - examples/groups.rb
104
124
  - examples/jetstream_endpoint.rb
125
+ - exe/leopard-service-info
126
+ - exe/leopard-service-map
127
+ - exe/leopard-service-stats
128
+ - exe/leopard-services
105
129
  - lib/leopard.rb
106
130
  - lib/leopard/errors.rb
107
131
  - lib/leopard/message_processor.rb
@@ -112,6 +136,18 @@ files:
112
136
  - lib/leopard/nats_jetstream_consumer.rb
113
137
  - lib/leopard/nats_jetstream_endpoint.rb
114
138
  - lib/leopard/nats_request_reply_callbacks.rb
139
+ - lib/leopard/nats_service_discovery.rb
140
+ - lib/leopard/nats_service_discovery/cli.rb
141
+ - lib/leopard/nats_service_discovery/operation.rb
142
+ - lib/leopard/nats_service_discovery/operation/collect.rb
143
+ - lib/leopard/nats_service_discovery/operation/connect.rb
144
+ - lib/leopard/nats_service_discovery/operation/discovery_options.rb
145
+ - lib/leopard/nats_service_discovery/operation/info.rb
146
+ - lib/leopard/nats_service_discovery/operation/ping.rb
147
+ - lib/leopard/nats_service_discovery/operation/services.rb
148
+ - lib/leopard/nats_service_discovery/operation/stats.rb
149
+ - lib/leopard/nats_service_discovery/operation/subject_map.rb
150
+ - lib/leopard/nats_service_discovery/operation/verb.rb
115
151
  - lib/leopard/settings.rb
116
152
  - lib/leopard/templates/prometheus_metrics.erb
117
153
  - lib/leopard/version.rb