leopard 0.1.6 → 0.1.7

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: 8407a7e14f98bddcfb36e679dead026ab8f809e8da6d4d25ca2b4cda32ff348e
4
- data.tar.gz: 283df9d8c6e450f397fa68fbec41d19a9b52497046e39b7a60f4ee817c479060
3
+ metadata.gz: 343e274b8fffbe093d59b1d819a4b0be0ba9efe109c4883c535ba3692f1854a3
4
+ data.tar.gz: 6b32780cf1bd40d7b0191a79a533382dbc6ed828162d9c6ebeefa2c838191c77
5
5
  SHA512:
6
- metadata.gz: df9cae013a07f3478d9309b089db7a985e413963c5422e7c2cb6ee120adba4a40b1d7421320789c0e8e0a3afa6fdd496a1b32664990d2a4f166b177ca7686da3
7
- data.tar.gz: 7072583f8e389016a2b1b6bdbe4aaddb2e32571730c205fbaee4c4fb73f9b78bd3099e57a8c87369f6e52193a4940b0a24d6bf9dca3bad32377671d79850c04f
6
+ metadata.gz: 2ee58b9e7a03d9dee24f3d338e700c0d0456ff0056379e06405418365023a14dc7484bad768d7deff2727cb7e01f0daa331234bfa6b0875b5891f79577a1880e
7
+ data.tar.gz: '047850ac66d9a789bdaad311ef764bed2bea365776a67d6f47f9634eb039edc303ab48d6227ad443f4f2a49b5fba1f1cd63c5d45068120b544d9a1e8a65c292d'
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.1.6"
2
+ ".": "0.1.7"
3
3
  }
data/.version.txt CHANGED
@@ -1 +1 @@
1
- 0.1.6
1
+ 0.1.7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.7](https://github.com/rubyists/leopard/compare/v0.1.6...v0.1.7) (2025-08-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * Allow non blocking all the way down to the instance level ([#27](https://github.com/rubyists/leopard/issues/27)) ([01748a5](https://github.com/rubyists/leopard/commit/01748a56bc927ee1dbc70d2351fd12037e5b4bef))
9
+ * Stop sending code argument to respond_with_error, it does not accept it ([#23](https://github.com/rubyists/leopard/issues/23)) ([9d87b8c](https://github.com/rubyists/leopard/commit/9d87b8c308a1fdff72769863711bb6bb942b3677))
10
+
3
11
  ## [0.1.6](https://github.com/rubyists/leopard/compare/v0.1.5...v0.1.6) (2025-08-03)
4
12
 
5
13
 
@@ -0,0 +1,149 @@
1
+ = NATS ServiceApi (via Leopard) vs REST
2
+ :revdate: 2025-08-03
3
+ :doctype: whitepaper
4
+
5
+ == Abstract
6
+ Leopard’s NATS ServiceApi wrapper delivers full-featured service-to-service communication, comparable with REST/GRPC.
7
+ With NATS, you get discovery, health checks, and observability, all at the protocol layer.
8
+
9
+ All Leopard does is add an Easy Button to serve these endpoints with concurrency to scale NATS connections
10
+ horizontally, and to allow for per-endpoint (or groups of endpoints) scaling.
11
+
12
+ This abstraction preserves the simplicity of REST requests with the resilience of asynchronous messaging
13
+ under the hood.
14
+
15
+ This paper contrasts the NATS ServiceApi model with traditional REST, outlines its key benefits and trade-offs,
16
+ and illustrates how Leopard can specifically streamline microservice (and nanoservice) architectures.
17
+
18
+ == 1. Introduction
19
+ Microservices communicate most often via HTTP/REST today, but REST comes with overhead: service registries,
20
+ load-balancers, schema/version endpoints, and brittle synchronous request patterns.
21
+ Leopard leverages NATS’s ServiceApi layer to deliver:
22
+
23
+ * **Automatic discovery** via `$SRV.PING/INFO/​STATS` subjects
24
+ * **Dynamic load-balanced routing** with queue groups
25
+ * **Built-in telemetry** (per-endpoint request counts, latencies, errors)
26
+ * **Scalable workers** (Concurrent::FixedThreadPool with Leopard, currently)
27
+ * **Versioned endpoints** multiple versions can be registered concurrently, no need for /v1, /v2, etc.
28
+
29
+ == 2. Architecture Overview
30
+ Leopard embeds a “mini web-framework” in Ruby, registering each endpoint on a NATS subject and grouping instances for load-sharing:
31
+
32
+ [source,mermaid]
33
+ ----
34
+ graph LR
35
+ subgraph ServiceInstance
36
+ A[NatsApiServer.run] --> B[Registers endpoints]
37
+ end
38
+ subgraph NATS_Cluster
39
+ B --> C["$SRV.INFO.calc"]
40
+ B --> D["calc.add queue group"]
41
+ end
42
+ E[Client] -->|request calc.add| D
43
+ D --> F[Worker Thread pool]
44
+ F --> G[Handler & Dry Monads]
45
+ G -->|respond or error| E
46
+ ----
47
+
48
+ == 3. Key Benefits
49
+
50
+ === 3.1 Automatic Discovery & Monitoring
51
+ Leopard services auto-advertise on well-known NATS subjects. Clients can query:
52
+
53
+ * `$SRV.PING.<name>` – discover live instances & measure RTT
54
+ * `$SRV.INFO.<name>` – retrieve endpoint schemas & metadata
55
+ * `$SRV.STATS.<name>` – fetch per-endpoint metrics
56
+
57
+ No external service-registry (Consul, etcd) or custom HTTP health paths required.
58
+
59
+ === 3.2 Scaling-Per-Endpoint-Group
60
+ Each endpoint (mapped to a NATS subject) is registered with an optional queue group.
61
+ This allows it to enjoy native NATS queue-group load balancing. You can:
62
+
63
+ * Scale thread-pooled workers independently per service
64
+ * Horizontally add new service instances without redeploying clients
65
+ * Isolate hot-paths (e.g. “reports.generate”) onto dedicated worker farms
66
+
67
+ === 3.3 Observability & Telemetry
68
+ Leopard exposes stats out-of-the-box:
69
+
70
+ * Request counts, error counts, processing time
71
+ * Custom `on_stats` hooks for business metrics
72
+ * Integration with Prometheus or any NATS-capable dashboard
73
+
74
+ === 3.4 Asynchronous, Resilient Communication
75
+ Unlike blocking HTTP calls, Leopard’s NATS requests can:
76
+
77
+ * Employ timeouts, retries, and dead-letter queues
78
+ * Fit into event-driven pipelines, decoupling producers and consumers
79
+ * Maintain throughput under partial outages
80
+
81
+ == 4. Comparison with REST
82
+ [cols="1,1,1", options="header"]
83
+ |===
84
+ | Feature | REST (HTTP) | NATS ServiceApi
85
+
86
+ | Discovery
87
+ | Requires external service registry or API gateway, A.K.A. "it's always DNS"
88
+ | Built-in via `$SRV.PING`, `$SRV.INFO`, `$SRV.STATS` Works uniformly across all languages
89
+
90
+ | Load Balancing
91
+ | HTTP load balancer or DNS round-robin
92
+ | Native queue-group load balancing per subject
93
+
94
+ | Telemetry
95
+ | Custom instrumentation (e.g., `/metrics` endpoint)
96
+ | Auto-collected stats (`service.stats`) and `on_stats` hooks
97
+
98
+ | Latency & Overhead
99
+ | Higher (HTTP/TCP handshake, headers, JSON)
100
+ | Low-latency binary protocol with optional JSON payloads (other formats supported with plugins)
101
+
102
+ | Communication Model
103
+ | Synchronous, blocking request/response
104
+ | Asynchronous request/reply, decoupled via subjects
105
+
106
+ | Schema & Validation
107
+ | OpenAPI/Swagger externally managed
108
+ | Optional metadata on endpoints + pluggable middleware
109
+
110
+ | Error Handling
111
+ | HTTP status codes and response bodies
112
+ | Standardized error headers (`Nats-Service-Error`, `Nats-Service-Error-Code`)
113
+
114
+ | Multi-Language Support
115
+ | Varies by framework; patterns differ per language
116
+ | Uniform ServiceApi semantics with native clients in all major languages
117
+
118
+ | Scalability
119
+ | Scale replicas behind LB
120
+ | Scale thread pools vertically + horizontal instances independently, by endpoint groups (or even a single endpoint)
121
+ |===
122
+
123
+ == 5. Trade-Offs & Considerations
124
+ . **Dependency on NATS**
125
+ Leopard requires a healthy NATS cluster; network partition or broker outage impacts all services. (This is not unlike Redis or Postgres dependencies)
126
+ . **Learning Curve**
127
+ Teams must understand NATS subjects, queue groups, and ServiceApi conventions. (Easier with helpers like Leopard’s `NatsApiServer`.)
128
+ . **Language Support**
129
+ While Leopard is Ruby-centric, NATS ServiceApi is cross-language. All teams must adopt compatible clients. (And handle concurrency and error handling in their own way.)
130
+ . **Subject Naming**
131
+ Adopting a consistent naming convention for subjects is crucial. This can be a challenge in large teams.
132
+ NATS can support a massive number of subjects. But to avoid confusion, subjects should have
133
+ clear, descriptive names that reflect the service and endpoint purpose.
134
+ Even though NATS service API exposes auto-discovery, there could (should?) be a central authoritative
135
+ document that defines the subject structure and naming conventions. This can avoid the TOCTOE issue.
136
+ (You check for a subject, it's not used, but then someone implements it in the meantime.)
137
+ That leads to: there should be a "registry" of subjects, that can be queried by developers
138
+ to discover available subjects. This can avoid confusion and ensure that all developers are on the same
139
+ page and not conflicting with one another.
140
+
141
+ == 6. What, then?
142
+ Leopard’s NATS ServiceApi framework offers a powerful alternative to REST:
143
+ zero-config discovery, per-endpoint scaling, rich observability, and asynchronous resilience.
144
+
145
+ For high-throughput, low-latency microservice (nano-service?) ecosystems, Leopard can simplify infrastructure,
146
+ reduce boilerplate, and improve operational visibility.
147
+
148
+ Leopard's aim is to retain the expressiveness and composability of idiomatic Ruby, while leveraging
149
+ NATS's ServiceApi performance and flexibility.
@@ -12,6 +12,7 @@ class EchoService
12
12
  end
13
13
 
14
14
  endpoint(:echo) { |msg| Success(msg.data) }
15
+ endpoint(:echo_fail) { |msg| Failure({ failure: '*boom*', data: msg.data }) }
15
16
  end
16
17
 
17
18
  if __FILE__ == $PROGRAM_NAME
@@ -22,6 +23,6 @@ if __FILE__ == $PROGRAM_NAME
22
23
  version: '1.0.0',
23
24
  instance_args: [2],
24
25
  },
25
- instances: 4,
26
+ instances: 1,
26
27
  )
27
28
  end
@@ -30,11 +30,10 @@ module Rubyists
30
30
  end
31
31
 
32
32
  # @param err [String, Exception] The error message or exception to respond with.
33
- # @param code [Integer] The HTTP status code to use for the error response.
34
33
  #
35
34
  # @return [void]
36
- def respond_with_error(err, code: 500)
37
- raw.respond_with_error(err.to_s, code:)
35
+ def respond_with_error(err)
36
+ raw.respond_with_error(err.to_s)
38
37
  end
39
38
 
40
39
  private
@@ -75,7 +75,7 @@ module Rubyists
75
75
  def run(nats_url:, service_opts:, instances: 1, blocking: true)
76
76
  logger.info 'Booting NATS API server...'
77
77
  workers = Concurrent::Array.new
78
- pool = spawn_instances(nats_url, service_opts, instances, workers)
78
+ pool = spawn_instances(nats_url, service_opts, instances, workers, blocking)
79
79
  logger.info 'Setting up signal trap...'
80
80
  trap_signals(workers, pool)
81
81
  return pool unless blocking
@@ -90,16 +90,16 @@ module Rubyists
90
90
  # @param url [String] The URL of the NATS server.
91
91
  # @param opts [Hash] Options for the NATS service.
92
92
  # @param count [Integer] The number of instances to spawn.
93
+ # @param workers [Array] The array to store worker instances.
94
+ # @param blocking [Boolean] If false, does not block current thread after starting the server.
93
95
  #
94
96
  # @return [Concurrent::FixedThreadPool] The thread pool managing the worker threads.
95
- def spawn_instances(url, opts, count, workers)
97
+ def spawn_instances(url, opts, count, workers, blocking)
96
98
  pool = Concurrent::FixedThreadPool.new(count)
97
99
  @instance_args = opts.delete(:instance_args) || nil
98
100
  logger.info "Building #{count} workers with options: #{opts.inspect}, instance_args: #{@instance_args}"
99
101
  count.times do
100
- eps = endpoints.dup
101
- gps = groups.dup
102
- pool.post { build_worker(url, opts, eps, gps, workers) }
102
+ pool.post { build_worker(url, opts, workers, blocking) }
103
103
  end
104
104
  pool
105
105
  end
@@ -108,15 +108,16 @@ module Rubyists
108
108
  #
109
109
  # @param url [String] The URL of the NATS server.
110
110
  # @param opts [Hash] Options for the NATS service.
111
- # @param eps [Array<Hash>] The list of endpoints to add.
112
- # @param gps [Hash] The groups to add.
113
111
  # @param workers [Array] The array to store worker instances.
112
+ # @param blocking [Boolean] If true, blocks the current thread until the worker is set up.
114
113
  #
115
114
  # @return [void]
116
- def build_worker(url, opts, eps, gps, workers)
115
+ def build_worker(url, opts, workers, blocking)
117
116
  worker = @instance_args ? new(*@instance_args) : new
118
117
  workers << worker
119
- worker.setup_worker(url, opts, eps, gps)
118
+ return worker.setup_worker!(nats_url: url, service_opts: opts) if blocking
119
+
120
+ worker.setup_worker(nats_url: url, service_opts: opts)
120
121
  end
121
122
 
122
123
  # Shuts down the NATS API server gracefully.
@@ -174,7 +175,6 @@ module Rubyists
174
175
 
175
176
  # Sets up a worker thread for the NATS API server.
176
177
  # This method connects to the NATS server, adds the service, groups, and endpoints,
177
- # and keeps the worker thread alive.
178
178
  #
179
179
  # @param url [String] The URL of the NATS server.
180
180
  # @param opts [Hash] Options for the NATS service.
@@ -182,12 +182,21 @@ module Rubyists
182
182
  # @param gps [Hash] The groups to add.
183
183
  #
184
184
  # @return [void]
185
- def setup_worker(url, opts, eps, gps)
185
+ def setup_worker(nats_url: 'nats://localhost:4222', service_opts: {})
186
186
  @thread = Thread.current
187
- @client = NATS.connect url
188
- @service = @client.services.add(**opts)
187
+ @client = NATS.connect nats_url
188
+ @service = @client.services.add(build_service_opts(service_opts:))
189
+ gps = self.class.groups.dup
190
+ eps = self.class.endpoints.dup
189
191
  group_map = add_groups(gps)
190
192
  add_endpoints eps, group_map
193
+ end
194
+
195
+ # Sets up a worker thread for the NATS API server and blocks the current thread.
196
+ #
197
+ # @see #setup_worker
198
+ def setup_worker!(nats_url: 'nats://localhost:4222', service_opts: {})
199
+ setup_worker(nats_url:, service_opts:)
191
200
  sleep
192
201
  end
193
202
 
@@ -202,6 +211,18 @@ module Rubyists
202
211
 
203
212
  private
204
213
 
214
+ # Builds the service options for the NATS service.
215
+ #
216
+ # @param service_opts [Hash] Options for the NATS service.
217
+ #
218
+ # @return [Hash] The complete service options including name and version.
219
+ def build_service_opts(service_opts:)
220
+ {
221
+ name: self.class.name.split('::').join('.'),
222
+ version: '0.1.0',
223
+ }.merge(service_opts)
224
+ end
225
+
205
226
  # Adds groups to the NATS service.
206
227
  #
207
228
  # @param gps [Hash] The groups to add, where keys are group names and values are group definitions.
@@ -3,7 +3,7 @@
3
3
  module Rubyists
4
4
  module Leopard
5
5
  # x-release-please-start-version
6
- VERSION = '0.1.6'
6
+ VERSION = '0.1.7'
7
7
  # x-release-please-end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leopard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - bougyman
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-08-03 00:00:00.000000000 Z
10
+ date: 2025-08-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: concurrent-ruby
@@ -98,6 +98,7 @@ files:
98
98
  - ci/nats/accounts.txt
99
99
  - ci/nats/start.sh
100
100
  - ci/publish-gem.sh
101
+ - doc/service-api-vs-rest.adoc
101
102
  - examples/echo_endpoint.rb
102
103
  - lib/leopard.rb
103
104
  - lib/leopard/errors.rb