From c4b1985db9dade9d19b0666b4e0308420bfda805 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Sun, 27 Jul 2025 16:16:24 -0500 Subject: [PATCH 01/26] feat: adds main lib and errors classes --- lib/leopard.rb | 12 ++++++++++++ lib/leopard/errors.rb | 8 ++++++++ 2 files changed, 20 insertions(+) create mode 100644 lib/leopard.rb create mode 100644 lib/leopard/errors.rb diff --git a/lib/leopard.rb b/lib/leopard.rb new file mode 100644 index 0000000..3d35b01 --- /dev/null +++ b/lib/leopard.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'dry/configurable' + +module Rubyists + module Leopard + extend Dry::Configurable + end +end + +require_relative 'leopard/version' +require_relative 'leopard/errors' diff --git a/lib/leopard/errors.rb b/lib/leopard/errors.rb new file mode 100644 index 0000000..f7054a2 --- /dev/null +++ b/lib/leopard/errors.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Rubyists + module Leopard + class Error < StandardError; end + class ConfigurationError < Error; end + end +end From c4e4ec1a1a3d8175d4c24a0d1adf87e8842cb8f5 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 29 Jul 2025 10:28:38 -0500 Subject: [PATCH 02/26] chore: Adds group to Gemfile feat: Adds settings to module fix: Corrects gemname in publish-gem.sh --- Gemfile | 22 ++++++++++++---------- Gemfile.lock | 10 ++++++++++ ci/publish-gem.sh | 2 +- leopard.gemspec | 3 ++- lib/leopard.rb | 8 ++++++++ lib/leopard/nats_api_server.rb | 6 ++++++ lib/leopard/settings.rb | 10 ++++++++++ test/{test_helper.rb => helper.rb} | 11 ++++------- test/leopard/test_leopard.rb | 4 ---- test/lib/settings.rb | 13 +++++++++++++ test/lib/version.rb | 9 +++++++++ 11 files changed, 75 insertions(+), 23 deletions(-) create mode 100644 lib/leopard/nats_api_server.rb create mode 100644 lib/leopard/settings.rb rename test/{test_helper.rb => helper.rb} (50%) delete mode 100755 test/leopard/test_leopard.rb create mode 100755 test/lib/settings.rb create mode 100755 test/lib/version.rb diff --git a/Gemfile b/Gemfile index 740c85d..2a624dc 100644 --- a/Gemfile +++ b/Gemfile @@ -5,13 +5,15 @@ source 'https://rubygems.org' # Specify your gem's dependencies in sequel-pgt_outbox.gemspec gemspec -gem 'minitest' -gem 'minitest-global_expectations' -gem 'pry' -gem 'rake' -gem 'reline' -gem 'rubocop' -gem 'rubocop-minitest' -gem 'rubocop-performance' -gem 'rubocop-rake' -gem 'simplecov' +group :development, :test do + gem 'minitest' + gem 'minitest-global_expectations' + gem 'pry' + gem 'rake' + gem 'reline' + gem 'rubocop' + gem 'rubocop-minitest' + gem 'rubocop-performance' + gem 'rubocop-rake' + gem 'simplecov' +end diff --git a/Gemfile.lock b/Gemfile.lock index af59bea..7d544e5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: leopard (0.1.0) + dry-configurable (~> 1.3) nats-pure (~> 2.5) GEM @@ -12,10 +13,18 @@ GEM coderay (1.1.3) concurrent-ruby (1.3.5) docile (1.4.1) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.1.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) io-console (0.8.1) json (2.13.1) language_server-protocol (3.17.0.5) lint_roller (1.1.0) + logger (1.7.0) method_source (1.1.0) minitest (5.25.5) minitest-global_expectations (1.0.1) @@ -79,6 +88,7 @@ GEM unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uri (1.0.3) + zeitwerk (2.7.3) PLATFORMS ruby diff --git a/ci/publish-gem.sh b/ci/publish-gem.sh index d25682c..2c39b24 100755 --- a/ci/publish-gem.sh +++ b/ci/publish-gem.sh @@ -18,7 +18,7 @@ me=${BASH_SOURCE[0]} here=$(cd "$(dirname "$me")" && pwd) just_me=$(basename "$me") -: "${GEM_NAME:=sequel-pgt_outbox}" +: "${GEM_NAME:=leopard}" : "${GIT_ORG:=rubyists}" GEM_HOST=$1 diff --git a/leopard.gemspec b/leopard.gemspec index 4205f41..8c2f96e 100644 --- a/leopard.gemspec +++ b/leopard.gemspec @@ -2,7 +2,7 @@ require_relative 'lib/leopard/version' -Gem::Specification.new do |spec| +Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength spec.name = 'leopard' spec.version = Rubyists::Leopard::VERSION spec.authors = ['bougyman'] @@ -34,6 +34,7 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] # Uncomment to register a new dependency of your gem + spec.add_dependency 'dry-configurable', '~> 1.3' spec.add_dependency 'nats-pure', '~> 2.5' # For more information and examples about making a new gem, check out our diff --git a/lib/leopard.rb b/lib/leopard.rb index 3d35b01..1ac536c 100644 --- a/lib/leopard.rb +++ b/lib/leopard.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true require 'dry/configurable' +require 'pathname' + +class Pathname + def /(other) + join other.to_s + end +end module Rubyists module Leopard @@ -10,3 +17,4 @@ module Leopard require_relative 'leopard/version' require_relative 'leopard/errors' +require_relative 'leopard/settings' diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb new file mode 100644 index 0000000..8426aec --- /dev/null +++ b/lib/leopard/nats_api_server.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Rubyists + module Leopard + end +end diff --git a/lib/leopard/settings.rb b/lib/leopard/settings.rb new file mode 100644 index 0000000..19dbd13 --- /dev/null +++ b/lib/leopard/settings.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Rubyists + module Leopard + extend Dry::Configurable + + setting :libroot, reader: true, default: Pathname(__FILE__).dirname.join('..').expand_path + setting :root, reader: true, default: libroot.join('..').expand_path + end +end diff --git a/test/test_helper.rb b/test/helper.rb similarity index 50% rename from test/test_helper.rb rename to test/helper.rb index 0320dd5..8c5e84c 100644 --- a/test/test_helper.rb +++ b/test/helper.rb @@ -1,19 +1,16 @@ # frozen_string_literal: true -$LOAD_PATH.unshift File.expand_path('../lib', __dir__) - -if (coverage = ENV.delete('COVERAGE')) +if ENV.delete('COVERAGE') require 'simplecov' SimpleCov.start do enable_coverage :branch - command_name coverage + command_name 'leopard' add_filter '/test/' add_group('Missing') { |src| src.covered_percent < 100 } add_group('Covered') { |src| src.covered_percent == 100 } end end -ENV['MT_NO_PLUGINS'] = '1' # Work around stupid autoloading of plugins -gem 'minitest' -require 'minitest/global_expectations/autorun' +require 'minitest/autorun' +require_relative '../lib/leopard' diff --git a/test/leopard/test_leopard.rb b/test/leopard/test_leopard.rb deleted file mode 100755 index 2d8cc9d..0000000 --- a/test/leopard/test_leopard.rb +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require_relative '../test_helper' diff --git a/test/lib/settings.rb b/test/lib/settings.rb new file mode 100755 index 0000000..42e3f3b --- /dev/null +++ b/test/lib/settings.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'helper' + +describe 'Rubyists::Leopard' do + it 'sets libroot' do + assert_equal Pathname(__FILE__).dirname.join('../../lib'), Rubyists::Leopard.libroot + end + + it 'sets root' do + assert_equal Pathname(__FILE__).dirname.join('../..'), Rubyists::Leopard.root + end +end diff --git a/test/lib/version.rb b/test/lib/version.rb new file mode 100755 index 0000000..1d65ffe --- /dev/null +++ b/test/lib/version.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'helper' + +describe 'Rubyists::Leopard' do + it 'has a version number' do + refute_nil Rubyists::Leopard::VERSION + end +end From 7a4fccc9f2a044dd24206fa33d4ee3ae60f08fd8 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 29 Jul 2025 11:03:57 -0500 Subject: [PATCH 03/26] feat: Adds basic message wrapper --- lib/leopard/message_wrapper.rb | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 lib/leopard/message_wrapper.rb diff --git a/lib/leopard/message_wrapper.rb b/lib/leopard/message_wrapper.rb new file mode 100644 index 0000000..f80b169 --- /dev/null +++ b/lib/leopard/message_wrapper.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'json' + +module Rubyists + module Leopard + class MessageWrapper + attr_reader :raw, :data, :headers + + def initialize(nats_msg) + @raw = nats_msg + @data = parse_data(nats_msg.data) + @headers = nats_msg.header.to_h + end + + def respond(payload) + raw.respond(serialize(payload)) + end + + def respond_with_error(err, code: 500) + raw.respond_with_error(err.to_s, code:) + end + + private + + def parse_data(raw) + JSON.parse(raw) + rescue JSON::ParserError + raw + end + + def serialize(obj) + obj.is_a?(String) ? obj : JSON.generate(obj) + end + end + end +end From 015af76d606ce0c6232ab5b0ff3c9b5450a80709 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 29 Jul 2025 11:07:32 -0500 Subject: [PATCH 04/26] feat: Basic api server using message wrapper --- lib/leopard/nats_api_server.rb | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 8426aec..3185798 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -1,6 +1,89 @@ # frozen_string_literal: true +require 'nats/client' +require 'dry/monads' +require_relative 'message_wrapper' + module Rubyists module Leopard + module NatsApiServer + include Dry::Monads[:result] + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def endpoints = @endpoints ||= [] + def middleware = @middleware ||= [] + + def endpoint(name, subject: nil, queue: nil, &handler) + endpoints << { name:, subject: subject || name, queue:, handler: } + end + + def use(klass, *args, &block) + middleware << [klass, args, block] + end + + def run(nats_url:, service_opts:, instances: 4) + spawn_instances(nats_url, service_opts, instances) + end + + private + + def spawn_instances(url, opts, count) + Array.new(count) { spawn_ractor(url, opts) } + end + + def spawn_ractor(url, opts) + eps = endpoints.dup + Ractor.new(url, opts, eps) { |u, o, e| setup_worker(u, o, e) } + end + + def setup_worker(url, opts, eps) + client = NATS.connect url + service = client.services.add(**opts) + add_endpoints service, eps + # The main Ractor can just go to sleep + sleep + end + + def add_endpoints(service, endpoints) + endpoints.each do |ep| + service.endpoints.add( + ep[:name], subject: ep[:subject], queue: ep[:queue] + ) do |raw_msg| + wrapper = MessageWrapper.new(raw_msg) + dispatch_with_middleware(wrapper, ep[:handler]) + end + end + end + + def dispatch_with_middleware(wrapper, handler) + app = ->(w) { handle_message(w.raw, handler) } + middleware.reverse_each do |(klass, args, blk)| + app = klass.new(app, *args, &blk) + end + app.call(wrapper) + end + + def handle_message(raw_msg, handler) + wrapper = MessageWrapper.new(raw_msg) + result = instance_exec(wrapper, &handler) + process_result(wrapper, result) + rescue StandardError => e + wrapper.respond_with_error(e.message) + end + + def process_result(wrapper, result) + case result + in Success + wrapper.respond(result.value!) + in Failure + wrapper.respond_with_error(result.failure) + end + end + end + end end end From 21f66db034b3641b033805ed95f2873bac3026db Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 29 Jul 2025 11:18:21 -0500 Subject: [PATCH 05/26] test: Adds test for message wrapper --- test/lib/message_wrapper.rb | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 test/lib/message_wrapper.rb diff --git a/test/lib/message_wrapper.rb b/test/lib/message_wrapper.rb new file mode 100644 index 0000000..3ea550b --- /dev/null +++ b/test/lib/message_wrapper.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'helper' +require 'leopard/message_wrapper' + +class FakeMsg + attr_reader :data, :header, :responded_payload, :error_args + + def initialize(data, header = {}) + @data = data + @header = header + end + + def respond(payload) + @responded_payload = payload + end + + def respond_with_error(err, code:) + @error_args = [err, code] + end +end + +describe Rubyists::Leopard::MessageWrapper do # rubocop:disable Metrics/BlockLength + let(:header) { { 'a' => 'b' } } + let(:msg) { FakeMsg.new('{"foo":1}', header) } + let(:wrapper) { Rubyists::Leopard::MessageWrapper.new(msg) } + + it 'exposes the raw message' do + assert_equal msg, wrapper.raw + end + + it 'parses JSON data' do + assert_equal({ 'foo' => 1 }, wrapper.data) + end + + it 'provides headers as a hash' do + assert_equal header, wrapper.headers + end + + it 'returns raw data on JSON parse failure' do + bad_msg = FakeMsg.new('not json') + bad_wrap = Rubyists::Leopard::MessageWrapper.new(bad_msg) + + assert_equal 'not json', bad_wrap.data + end + + it 'responds with a string payload as-is' do + wrapper.respond('ok') + + assert_equal 'ok', msg.responded_payload + end + + it 'serializes non-string payloads to JSON when responding' do + wrapper.respond(foo: 2) + + assert_equal '{"foo":2}', msg.responded_payload + end + + it 'responds with error' do + wrapper.respond_with_error('fail', code: 404) + + assert_equal ['fail', 404], msg.error_args + end + + it 'coerces exception objects to strings when responding with error' do + err = StandardError.new('broken') + wrapper.respond_with_error(err) + + assert_equal ['broken', 500], msg.error_args + end +end From f6b877dc49938736633291b73e5224efa07e3183 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 29 Jul 2025 11:52:18 -0500 Subject: [PATCH 06/26] test: Adds tests for nats_api_server --- .gitignore | 1 + Gemfile.lock | 5 ++ leopard.gemspec | 1 + test/lib/nats_api_server.rb | 122 ++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100755 test/lib/nats_api_server.rb diff --git a/.gitignore b/.gitignore index 5e072fd..cd5c3a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.gem *.rbc +*.swp /.config /coverage/ /InstalledFiles diff --git a/Gemfile.lock b/Gemfile.lock index 7d544e5..5d7bfc1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: leopard (0.1.0) dry-configurable (~> 1.3) + dry-monads (~> 1.9) nats-pure (~> 2.5) GEM @@ -20,6 +21,10 @@ GEM concurrent-ruby (~> 1.0) logger zeitwerk (~> 2.6) + dry-monads (1.9.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) io-console (0.8.1) json (2.13.1) language_server-protocol (3.17.0.5) diff --git a/leopard.gemspec b/leopard.gemspec index 8c2f96e..a73fe58 100644 --- a/leopard.gemspec +++ b/leopard.gemspec @@ -35,6 +35,7 @@ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength # Uncomment to register a new dependency of your gem spec.add_dependency 'dry-configurable', '~> 1.3' + spec.add_dependency 'dry-monads', '~> 1.9' spec.add_dependency 'nats-pure', '~> 2.5' # For more information and examples about making a new gem, check out our diff --git a/test/lib/nats_api_server.rb b/test/lib/nats_api_server.rb new file mode 100755 index 0000000..864c572 --- /dev/null +++ b/test/lib/nats_api_server.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'helper' +require Rubyists::Leopard.libroot / 'leopard/nats_api_server' + +describe 'Rubyists::Leopard::NatsApiServer' do # rubocop:disable Metrics/BlockLength + before do + @klass = Class.new do + include Rubyists::Leopard::NatsApiServer + end + + mod = Rubyists::Leopard::NatsApiServer + cm = mod::ClassMethods + cm.const_set(:Success, mod::Success) unless cm.const_defined?(:Success) + cm.const_set(:Failure, mod::Failure) unless cm.const_defined?(:Failure) + end + + it 'registers an endpoint' do + blk = proc {} + @klass.endpoint(:foo, &blk) + + assert_equal [{ name: :foo, subject: :foo, queue: nil, handler: blk }], @klass.endpoints + end + + it 'registers an endpoint with options' do + blk = proc {} + @klass.endpoint(:foo, subject: 'bar', queue: 'q', &blk) + + assert_equal [{ name: :foo, subject: 'bar', queue: 'q', handler: blk }], @klass.endpoints + end + + it 'adds middleware' do + blk = proc {} + @klass.use(String, 1, &blk) + + assert_equal [[String, [1], blk]], @klass.middleware + end + + it 'delegates run to spawn_instances' do + args = nil + @klass.stub(:spawn_instances, ->(url, opts, count) { args = [url, opts, count] }) do + @klass.run(nats_url: 'nats://', service_opts: { name: 'svc' }, instances: 2) + end + + assert_equal ['nats://', { name: 'svc' }, 2], args + end + + it 'dispatches through middleware in reverse order' do + order = [] + mw1 = Class.new do + def initialize(app) = (@app = app) + + def call(wrapper) + wrapper.log << :mw1 + @app.call(wrapper) + end + end + mw2 = Class.new do + def initialize(app) = (@app = app) + + def call(wrapper) + wrapper.log << :mw2 + @app.call(wrapper) + end + end + handler = ->(w) { w.log << :handler } + wrapper = Struct.new(:raw, :log).new(:raw, order) + @klass.use mw1 + @klass.use mw2 + @klass.stub(:handle_message, ->(_raw, h) { h.call(wrapper) }) do + @klass.send(:dispatch_with_middleware, wrapper, handler) + end + + assert_equal %i[mw1 mw2 handler], order + end + + it 'handles a message and processes result' do + raw_msg = Object.new + wrapper = Object.new + result = Dry::Monads::Success(:ok) + received = nil + handler = proc { |w| + received = w + result + } + processed = nil + @klass.stub(:process_result, ->(w, r) { processed = [w, r] }) do + Rubyists::Leopard::MessageWrapper.stub(:new, wrapper) do + @klass.send(:handle_message, raw_msg, handler) + end + end + + assert_equal wrapper, received + assert_equal [wrapper, result], processed + end + + it 'responds with error when handler raises' do + raw_msg = Object.new + wrapper = Minitest::Mock.new + wrapper.expect(:respond_with_error, nil, ['boom']) + Rubyists::Leopard::MessageWrapper.stub(:new, wrapper) do + @klass.send(:handle_message, raw_msg, proc { raise 'boom' }) + end + wrapper.verify + end + + it 'responds when processing Success result' do + wrapper = Minitest::Mock.new + wrapper.expect(:respond, nil, ['ok']) + result = Rubyists::Leopard::NatsApiServer::Success.new('ok') + @klass.send(:process_result, wrapper, result) + wrapper.verify + end + + it 'responds when processing Failure result' do + wrapper = Minitest::Mock.new + wrapper.expect(:respond_with_error, nil, ['fail']) + result = Rubyists::Leopard::NatsApiServer::Failure.new('fail') + @klass.send(:process_result, wrapper, result) + wrapper.verify + end +end From 3db03e7e1fafa91cfdfbaeeb344d8cf6f5c6afab Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 29 Jul 2025 15:03:35 -0500 Subject: [PATCH 07/26] doc: Fills in Readme with current functionality --- Readme.adoc | 109 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 102 insertions(+), 7 deletions(-) diff --git a/Readme.adoc b/Readme.adoc index df7f996..5d422f3 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -1,9 +1,104 @@ -# Leopard Nats ServiceApi Server += Leopard NATS ServiceApi Server bougyman -:service-api: https://github.com/rubyists/nats-pure.rb/blob/main/docs/service_api.md[Service API] +:service-api: https://github.com/rubyists/nats-pure.rb/blob/main/docs/service_api.md[NATS Service API] +:conventional-commits: https://www.conventionalcommits.org/en/v1.0.0/[Conventional Commits] -The leopard nats serviceapi server provides a simple concurrency -model for NATS {service-api} workers. It is designed to be used -similarly to a web server (inspired by puma), defining endpoints -in your classes, and then serving them via the leopard (Ractor-based) -service supervisor. +Leopard is a small framework for building concurrent {service-api} workers. +It uses `Ractor` to manage multiple workers in a single process and provides a +minimal DSL for defining endpoints and middleware. + +== Features + +* Declarative endpoint definitions with `endpoint`. +* Middleware support using `use`. +* Simple concurrency via `run` with a configurable number of instances. +* JSON aware message wrapper that gracefully handles parse errors. +* Dry::Configurable settings container (currently exposing `root` and `libroot` paths). + +== Requirements + +* Ruby >= 3.3.0 +* A running NATS server with the Service API enabled. + +== Installation + +Add the gem to your project: + +[source,ruby] +---- +# Gemfile +gem 'leopard' +---- + +Then install it with Bundler. + +[source,bash] +---- +$ bundle install +---- + +== Usage + +Create a service class and include `Rubyists::Leopard::NatsApiServer`. +Define one or more endpoints. Each endpoint receives a +`Rubyists::Leopard::MessageWrapper` object. + +[source,ruby] +---- +class EchoService + include Rubyists::Leopard::NatsApiServer + + endpoint :echo do |msg| + Success(msg.data) + end +end +---- + +Run the service by providing the NATS connection details and service options: + +[source,ruby] +---- +EchoService.run( + nats_url: 'nats://localhost:4222', + service_opts: { name: 'echo' }, + instances: 4 +) +---- + +Middleware can be inserted around endpoint dispatch: + +[source,ruby] +---- +class LoggerMiddleware + def initialize(app) + @app = app + end + + def call(wrapper) + puts "received: #{wrapper.data.inspect}" + @app.call(wrapper) + end +end + +EchoService.use LoggerMiddleware +---- + +== Development + +The project uses Minitest and RuboCop. Run tests with Rake: + +[source,bash] +---- +$ bundle exec rake +---- + +=== Conventional Commits (semantic commit messages) + +This project follows the {conventional-commits} specification. + +To contribute, please follow that commit message format, +or your pull request may be rejected. + +== License + +MIT From ac6cfe15d7384558840722ce6ed8a960c60f75ab Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 29 Jul 2025 15:07:53 -0500 Subject: [PATCH 08/26] fix: Remove redundant include of Dry::Configurable --- lib/leopard.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/leopard.rb b/lib/leopard.rb index 1ac536c..ce2ac61 100644 --- a/lib/leopard.rb +++ b/lib/leopard.rb @@ -11,10 +11,9 @@ def /(other) module Rubyists module Leopard - extend Dry::Configurable end end +require_relative 'leopard/settings' require_relative 'leopard/version' require_relative 'leopard/errors' -require_relative 'leopard/settings' From fa18aa22c8e8d5cddcaf571384a48cd0417317ff Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 29 Jul 2025 15:13:04 -0500 Subject: [PATCH 09/26] doc: Adds link for Dry::Configurable and removes useless internal settings nonsense --- Readme.adoc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Readme.adoc b/Readme.adoc index 5d422f3..96f6e65 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -2,6 +2,7 @@ bougyman :service-api: https://github.com/rubyists/nats-pure.rb/blob/main/docs/service_api.md[NATS Service API] :conventional-commits: https://www.conventionalcommits.org/en/v1.0.0/[Conventional Commits] +:dry-configurable: https://github.com/dry-rb/dry-configurable[Dry::Configurable] Leopard is a small framework for building concurrent {service-api} workers. It uses `Ractor` to manage multiple workers in a single process and provides a @@ -13,7 +14,7 @@ minimal DSL for defining endpoints and middleware. * Middleware support using `use`. * Simple concurrency via `run` with a configurable number of instances. * JSON aware message wrapper that gracefully handles parse errors. -* Dry::Configurable settings container (currently exposing `root` and `libroot` paths). +* {dry-configurable} settings container. == Requirements From e44ecc64c21c8507fe65e750c61cc65ca8fec31b Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 11:08:13 -0500 Subject: [PATCH 10/26] feat: Ditch Ractors for Concurrent::FixedThreadPool --- Gemfile.lock | 1 + examples/echo_endpoint.rb | 19 +++++++++++++++++++ leopard.gemspec | 1 + lib/leopard.rb | 1 + lib/leopard/nats_api_server.rb | 15 ++++++++------- 5 files changed, 30 insertions(+), 7 deletions(-) create mode 100755 examples/echo_endpoint.rb diff --git a/Gemfile.lock b/Gemfile.lock index 5d7bfc1..5fee877 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: leopard (0.1.0) + concurrent-ruby (~> 1.1) dry-configurable (~> 1.3) dry-monads (~> 1.9) nats-pure (~> 2.5) diff --git a/examples/echo_endpoint.rb b/examples/echo_endpoint.rb new file mode 100755 index 0000000..564f9ab --- /dev/null +++ b/examples/echo_endpoint.rb @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../lib/leopard' + +# Example to echo the given message +class EchoService + include Rubyists::Leopard::NatsApiServer + + endpoint :echo, &:data +end + +if __FILE__ == $PROGRAM_NAME + EchoService.run( + nats_url: 'nats://localhost:4222', + service_opts: { name: 'example.echo', version: '1.0.0' }, + instances: 4, + ) +end diff --git a/leopard.gemspec b/leopard.gemspec index a73fe58..ec788ff 100644 --- a/leopard.gemspec +++ b/leopard.gemspec @@ -34,6 +34,7 @@ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength spec.require_paths = ['lib'] # Uncomment to register a new dependency of your gem + spec.add_dependency 'concurrent-ruby', '~> 1.1' spec.add_dependency 'dry-configurable', '~> 1.3' spec.add_dependency 'dry-monads', '~> 1.9' spec.add_dependency 'nats-pure', '~> 2.5' diff --git a/lib/leopard.rb b/lib/leopard.rb index ce2ac61..2887f4a 100644 --- a/lib/leopard.rb +++ b/lib/leopard.rb @@ -17,3 +17,4 @@ module Leopard require_relative 'leopard/settings' require_relative 'leopard/version' require_relative 'leopard/errors' +require_relative 'leopard/nats_api_server' diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 3185798..b498eea 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -2,6 +2,7 @@ require 'nats/client' require 'dry/monads' +require 'concurrent' require_relative 'message_wrapper' module Rubyists @@ -32,19 +33,19 @@ def run(nats_url:, service_opts:, instances: 4) private def spawn_instances(url, opts, count) - Array.new(count) { spawn_ractor(url, opts) } - end - - def spawn_ractor(url, opts) - eps = endpoints.dup - Ractor.new(url, opts, eps) { |u, o, e| setup_worker(u, o, e) } + pool = Concurrent::FixedThreadPool.new(count) + count.times do + eps = endpoints.dup + pool.post { setup_worker(url, opts, eps) } + end + pool end def setup_worker(url, opts, eps) client = NATS.connect url service = client.services.add(**opts) add_endpoints service, eps - # The main Ractor can just go to sleep + # Keep the worker thread alive sleep end From 80683c23c372d6540e0fca48e1fe8b54f4c5e94d Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 13:06:08 -0500 Subject: [PATCH 11/26] feat: Adds #group to namespace endpoints --- lib/leopard/nats_api_server.rb | 46 ++++++++++++++++++++++++++++------ test/lib/nats_api_server.rb | 21 ++++++++++++++-- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index b498eea..03ec091 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -16,10 +16,21 @@ def self.included(base) module ClassMethods def endpoints = @endpoints ||= [] + def groups = @groups ||= {} def middleware = @middleware ||= [] - def endpoint(name, subject: nil, queue: nil, &handler) - endpoints << { name:, subject: subject || name, queue:, handler: } + def endpoint(name, subject: nil, queue: nil, group: nil, &handler) + endpoints << { + name:, + subject: subject || name, + queue:, + group:, + handler:, + } + end + + def group(name, group: nil, queue: nil) + groups[name] = { name:, parent: group, queue: } end def use(klass, *args, &block) @@ -36,22 +47,43 @@ def spawn_instances(url, opts, count) pool = Concurrent::FixedThreadPool.new(count) count.times do eps = endpoints.dup - pool.post { setup_worker(url, opts, eps) } + gps = groups.dup + pool.post { setup_worker(url, opts, eps, gps) } end pool end - def setup_worker(url, opts, eps) + def setup_worker(url, opts, eps, gps) client = NATS.connect url service = client.services.add(**opts) - add_endpoints service, eps + group_map = add_groups(service, gps) + add_endpoints service, eps, group_map # Keep the worker thread alive sleep end - def add_endpoints(service, endpoints) + def add_groups(service, gps) + created = {} + gps.each_key { |name| build_group(service, gps, created, name) } + created + end + + def build_group(service, defs, cache, name) + return cache[name] if cache.key?(name) + + gdef = defs[name] + raise ArgumentError, "Group #{name} not defined" unless gdef + + parent = gdef[:parent] ? build_group(service, defs, cache, gdef[:parent]) : service + cache[name] = parent.groups.add(gdef[:name], queue: gdef[:queue]) + end + + def add_endpoints(service, endpoints, group_map) endpoints.each do |ep| - service.endpoints.add( + parent = ep[:group] ? group_map[ep[:group]] : service + raise ArgumentError, "Group #{ep[:group]} not defined" if ep[:group] && parent.nil? + + parent.endpoints.add( ep[:name], subject: ep[:subject], queue: ep[:queue] ) do |raw_msg| wrapper = MessageWrapper.new(raw_msg) diff --git a/test/lib/nats_api_server.rb b/test/lib/nats_api_server.rb index 864c572..a17b39c 100755 --- a/test/lib/nats_api_server.rb +++ b/test/lib/nats_api_server.rb @@ -19,14 +19,31 @@ blk = proc {} @klass.endpoint(:foo, &blk) - assert_equal [{ name: :foo, subject: :foo, queue: nil, handler: blk }], @klass.endpoints + assert_equal [{ name: :foo, subject: :foo, queue: nil, group: nil, handler: blk }], + @klass.endpoints end it 'registers an endpoint with options' do blk = proc {} @klass.endpoint(:foo, subject: 'bar', queue: 'q', &blk) - assert_equal [{ name: :foo, subject: 'bar', queue: 'q', handler: blk }], @klass.endpoints + assert_equal [{ name: :foo, subject: 'bar', queue: 'q', group: nil, handler: blk }], + @klass.endpoints + end + + it 'registers a group' do + @klass.group :math, queue: 'math' + + assert_equal({ math: { name: :math, parent: nil, queue: 'math' } }, @klass.groups) + end + + it 'registers an endpoint with a group' do + blk = proc {} + @klass.group :math + @klass.endpoint(:add, group: :math, &blk) + + assert_equal [{ name: :add, subject: :add, queue: nil, group: :math, handler: blk }], + @klass.endpoints end it 'adds middleware' do From d32da821aaee83a401fa09bd0a5cf50899bc854d Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 14:59:42 -0500 Subject: [PATCH 12/26] chore: Be more leniant with documentation commit messages --- .github/workflows/conventional_commits.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/conventional_commits.yaml b/.github/workflows/conventional_commits.yaml index 25469a2..6315812 100644 --- a/.github/workflows/conventional_commits.yaml +++ b/.github/workflows/conventional_commits.yaml @@ -22,7 +22,7 @@ jobs: # Added types to this: # * eyes - For observability related changes # * sec - For security related changes - allowed-commit-types: "build,chore,ci,docs,eyes,feat,fix,perf,refactor,revert,sec,style,test" # yamllint disable-line rule:line-length + allowed-commit-types: "build,chore,ci,doc,documentation,docs,eyes,feat,fix,perf,refactor,revert,sec,style,test" # yamllint disable-line rule:line-length conventional_pr_title: name: Validate PR title runs-on: ubuntu-latest @@ -42,6 +42,8 @@ jobs: build chore ci + doc + documentation docs eyes feat From bf13d94e4f86885ea7a2e6447b6e4c3a6fdb34d3 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 15:29:24 -0500 Subject: [PATCH 13/26] docs: Updates Readme about the change to Concurrent::FixedThreadPool --- Readme.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Readme.adoc b/Readme.adoc index 96f6e65..1eaf31a 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -5,7 +5,7 @@ bougyman :dry-configurable: https://github.com/dry-rb/dry-configurable[Dry::Configurable] Leopard is a small framework for building concurrent {service-api} workers. -It uses `Ractor` to manage multiple workers in a single process and provides a +It uses `Concurrent::FixedThreadPool` to manage multiple workers in a single process and provides a minimal DSL for defining endpoints and middleware. == Features From 9136639cf759560ec7ad02f1d217f38f4e2703bd Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 15:40:02 -0500 Subject: [PATCH 14/26] docs: Adds bits about Dry::Monads --- Readme.adoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Readme.adoc b/Readme.adoc index 1eaf31a..18e014f 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -3,6 +3,7 @@ bougyman :service-api: https://github.com/rubyists/nats-pure.rb/blob/main/docs/service_api.md[NATS Service API] :conventional-commits: https://www.conventionalcommits.org/en/v1.0.0/[Conventional Commits] :dry-configurable: https://github.com/dry-rb/dry-configurable[Dry::Configurable] +:dry-monads: https://github.com/dry-rb/dry-monads[Dry::Monads] Leopard is a small framework for building concurrent {service-api} workers. It uses `Concurrent::FixedThreadPool` to manage multiple workers in a single process and provides a @@ -14,6 +15,7 @@ minimal DSL for defining endpoints and middleware. * Middleware support using `use`. * Simple concurrency via `run` with a configurable number of instances. * JSON aware message wrapper that gracefully handles parse errors. +* Railway Oriented Design, using {dry-monads} for success and failure handling. * {dry-configurable} settings container. == Requirements From fd456c2653fc6984398e2b1a7b8e3d5fc9aef248 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 15:43:56 -0500 Subject: [PATCH 15/26] docs: Clarifies endpoint mappings --- Readme.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Readme.adoc b/Readme.adoc index 18e014f..82afa45 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -44,7 +44,7 @@ $ bundle install Create a service class and include `Rubyists::Leopard::NatsApiServer`. Define one or more endpoints. Each endpoint receives a -`Rubyists::Leopard::MessageWrapper` object. +`Rubyists::Leopard::MessageWrapper` object for each request to the {service-api} endpoint that service class is is subscribed to (subject:, or name:). [source,ruby] ---- From dcbf2b42bbfe398f0710c373deae2b99e0106c13 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 16:21:40 -0500 Subject: [PATCH 16/26] fix: Fixes example echo service --- examples/echo_endpoint.rb | 2 +- lib/leopard/nats_api_server.rb | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/echo_endpoint.rb b/examples/echo_endpoint.rb index 564f9ab..55e4521 100755 --- a/examples/echo_endpoint.rb +++ b/examples/echo_endpoint.rb @@ -1,7 +1,7 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require_relative '../lib/leopard' +require_relative '../lib/leopard/nats_api_server' # Example to echo the given message class EchoService diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 03ec091..15d65b9 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -3,6 +3,7 @@ require 'nats/client' require 'dry/monads' require 'concurrent' +require_relative '../leopard' require_relative 'message_wrapper' module Rubyists @@ -39,6 +40,7 @@ def use(klass, *args, &block) def run(nats_url:, service_opts:, instances: 4) spawn_instances(nats_url, service_opts, instances) + sleep end private From 114d70e390c8ad729702fd41195163efafdd4c65 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 17:00:26 -0500 Subject: [PATCH 17/26] fix: Got monads at all scope levels --- Gemfile.lock | 3 +++ examples/echo_endpoint.rb | 5 ++++- leopard.gemspec | 1 + lib/leopard.rb | 2 ++ lib/leopard/nats_api_server.rb | 7 +++++-- 5 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5fee877..bd17c90 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,6 +6,7 @@ PATH dry-configurable (~> 1.3) dry-monads (~> 1.9) nats-pure (~> 2.5) + semantic_logger (~> 4) GEM remote: https://rubygems.org/ @@ -83,6 +84,8 @@ GEM rubocop (>= 1.72.1) ruby-progressbar (1.13.0) securerandom (0.4.1) + semantic_logger (4.17.0) + concurrent-ruby (~> 1.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) diff --git a/examples/echo_endpoint.rb b/examples/echo_endpoint.rb index 55e4521..5d0505a 100755 --- a/examples/echo_endpoint.rb +++ b/examples/echo_endpoint.rb @@ -6,8 +6,11 @@ # Example to echo the given message class EchoService include Rubyists::Leopard::NatsApiServer + include SemanticLogger::Loggable + include Dry::Monads[:result] + extend Dry::Monads[:result] - endpoint :echo, &:data + endpoint(:echo) { |msg| Success(msg.data) } end if __FILE__ == $PROGRAM_NAME diff --git a/leopard.gemspec b/leopard.gemspec index ec788ff..02b656e 100644 --- a/leopard.gemspec +++ b/leopard.gemspec @@ -38,6 +38,7 @@ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength spec.add_dependency 'dry-configurable', '~> 1.3' spec.add_dependency 'dry-monads', '~> 1.9' spec.add_dependency 'nats-pure', '~> 2.5' + spec.add_dependency 'semantic_logger', '~> 4' # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html diff --git a/lib/leopard.rb b/lib/leopard.rb index 2887f4a..94a686c 100644 --- a/lib/leopard.rb +++ b/lib/leopard.rb @@ -2,6 +2,8 @@ require 'dry/configurable' require 'pathname' +require 'semantic_logger' +SemanticLogger.add_appender(io: $stdout, formatter: :color) class Pathname def /(other) diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 15d65b9..18057f2 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -10,6 +10,7 @@ module Rubyists module Leopard module NatsApiServer include Dry::Monads[:result] + extend Dry::Monads[:result] def self.included(base) base.extend(ClassMethods) @@ -107,14 +108,16 @@ def handle_message(raw_msg, handler) result = instance_exec(wrapper, &handler) process_result(wrapper, result) rescue StandardError => e + logger.error 'Error processing message: ', e wrapper.respond_with_error(e.message) end def process_result(wrapper, result) case result - in Success + in Dry::Monads::Success wrapper.respond(result.value!) - in Failure + in Dry::Monads::Failure + logger.error 'Error processing message: ', result.failure wrapper.respond_with_error(result.failure) end end From 549edc6de304cee35133371c713c152650f34847 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 17:11:24 -0500 Subject: [PATCH 18/26] fix: Extend the base module to reduce boilerplate --- examples/echo_endpoint.rb | 2 -- lib/leopard/nats_api_server.rb | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/echo_endpoint.rb b/examples/echo_endpoint.rb index 5d0505a..4ec70d7 100755 --- a/examples/echo_endpoint.rb +++ b/examples/echo_endpoint.rb @@ -7,8 +7,6 @@ class EchoService include Rubyists::Leopard::NatsApiServer include SemanticLogger::Loggable - include Dry::Monads[:result] - extend Dry::Monads[:result] endpoint(:echo) { |msg| Success(msg.data) } end diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 18057f2..3a7e1de 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -14,6 +14,7 @@ module NatsApiServer def self.included(base) base.extend(ClassMethods) + base.extend(Dry::Monads[:result]) end module ClassMethods From 3781a6e248ef8e4dfbc2c4584ee4e3373f60c5b1 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 17:11:49 -0500 Subject: [PATCH 19/26] fix: Get semantic logger out of the example --- examples/echo_endpoint.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/echo_endpoint.rb b/examples/echo_endpoint.rb index 4ec70d7..735fda9 100755 --- a/examples/echo_endpoint.rb +++ b/examples/echo_endpoint.rb @@ -6,7 +6,6 @@ # Example to echo the given message class EchoService include Rubyists::Leopard::NatsApiServer - include SemanticLogger::Loggable endpoint(:echo) { |msg| Success(msg.data) } end From 565ab553790191d11d0d7e33551c0c372081faad Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 30 Jul 2025 17:32:15 -0500 Subject: [PATCH 20/26] feat: Adds some basic logging --- lib/leopard/nats_api_server.rb | 2 ++ lib/leopard/settings.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 3a7e1de..84c030f 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -15,6 +15,7 @@ module NatsApiServer def self.included(base) base.extend(ClassMethods) base.extend(Dry::Monads[:result]) + base.include(SemanticLogger::Loggable) end module ClassMethods @@ -41,6 +42,7 @@ def use(klass, *args, &block) end def run(nats_url:, service_opts:, instances: 4) + logger.info 'Booting NATS API server...' spawn_instances(nats_url, service_opts, instances) sleep end diff --git a/lib/leopard/settings.rb b/lib/leopard/settings.rb index 19dbd13..b309287 100644 --- a/lib/leopard/settings.rb +++ b/lib/leopard/settings.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true +require 'semantic_logger' module Rubyists module Leopard @@ -6,5 +7,6 @@ module Leopard setting :libroot, reader: true, default: Pathname(__FILE__).dirname.join('..').expand_path setting :root, reader: true, default: libroot.join('..').expand_path + setting :logger, reader: true, default: SemanticLogger[:Leopard] end end From 1e8b0e45a7648630719392ecb6714fed5820d145 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Thu, 31 Jul 2025 00:08:27 -0500 Subject: [PATCH 21/26] fix: Do not auto require nats api server itself --- lib/leopard.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/leopard.rb b/lib/leopard.rb index 94a686c..7aecb15 100644 --- a/lib/leopard.rb +++ b/lib/leopard.rb @@ -19,4 +19,3 @@ module Leopard require_relative 'leopard/settings' require_relative 'leopard/version' require_relative 'leopard/errors' -require_relative 'leopard/nats_api_server' From 7efe5718c234701608925602ae0aa2134fd94df1 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Thu, 31 Jul 2025 09:20:52 -0500 Subject: [PATCH 22/26] docs: Added method documentation --- ci/nats/accounts.txt | 27 +++++++++++ ci/nats/start.sh | 33 +++++++++++++ lib/leopard/message_wrapper.rb | 27 +++++++++++ lib/leopard/nats_api_server.rb | 89 +++++++++++++++++++++++++++++++++- 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 ci/nats/accounts.txt create mode 100755 ci/nats/start.sh diff --git a/ci/nats/accounts.txt b/ci/nats/accounts.txt new file mode 100644 index 0000000..793bb1d --- /dev/null +++ b/ci/nats/accounts.txt @@ -0,0 +1,27 @@ +# Client port of 4222 on all interfaces +port: 4222 + +# HTTP monitoring port +monitor_port: 8222 + +accounts: { + $SYS: { + users: [ + { user: sys, password: sys } + ] + } + ME: { + jetstream: enabled + users: [ + { user: me, password: youandme } + ] + } +} +no_auth_user: me + +authorization { + default_permissions = { + publish = ">" + subscribe = ">" + } +} diff --git a/ci/nats/start.sh b/ci/nats/start.sh new file mode 100755 index 0000000..0b237b4 --- /dev/null +++ b/ci/nats/start.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +NATS_VERSION=2 + +if readlink -f . >/dev/null 2>&1 # {{{ makes readlink work on mac +then + readlink=readlink +else + if greadlink -f . >/dev/null 2>&1 + then + readlink=greadlink + else + printf "You must install greadlink to use this (brew install coreutils)\n" >&2 + fi +fi # }}} + +# Set here to the full path to this script +me=${BASH_SOURCE[0]} +[ -L "$me" ] && me=$($readlink -f "$me") +here=$(cd "$(dirname "$me")" && pwd) +just_me=$(basename "$me") +export just_me + +cd "$here" || exit 1 +if command -v podman 2>/dev/null +then + runtime=podman +else + runtime=docker +fi + +set -x +exec "$runtime" run --rm -it -p 4222:4222 -p 6222:6222 -p 8222:8222 -v ./accounts.txt:/accounts.txt nats:"$NATS_VERSION" -js -c /accounts.txt "$@" diff --git a/lib/leopard/message_wrapper.rb b/lib/leopard/message_wrapper.rb index f80b169..732cdad 100644 --- a/lib/leopard/message_wrapper.rb +++ b/lib/leopard/message_wrapper.rb @@ -5,30 +5,57 @@ module Rubyists module Leopard class MessageWrapper + # @!attribute [r] raw + # @return [NATS::Message] The original NATS message. + # + # @!attribute [r] data + # @return [Object] The parsed data from the NATS message. + # + # @!attribute [r] headers + # @return [Hash] The headers from the NATS message. attr_reader :raw, :data, :headers + # @param nats_msg [NATS::Message] The NATS message to wrap. def initialize(nats_msg) @raw = nats_msg @data = parse_data(nats_msg.data) @headers = nats_msg.header.to_h end + # @param payload [Object] The payload to respond with. + # + # @return [void] def respond(payload) raw.respond(serialize(payload)) end + # @param err [String, Exception] The error message or exception to respond with. + # @param code [Integer] The HTTP status code to use for the error response. + # + # @return [void] def respond_with_error(err, code: 500) raw.respond_with_error(err.to_s, code:) end private + # Parses the raw data from the NATS message. + # Assumes the data is in JSON format. + # If parsing fails, it returns the raw string. + # + # @param raw [String] The raw data from the NATS message. + # + # @return [Object] The parsed data, or the raw string if parsing fails. def parse_data(raw) JSON.parse(raw) rescue JSON::ParserError raw end + # Serializes the object to a JSON string if it is not already a string. + # @param obj [Object] The object to serialize. + # + # @return [String] The serialized JSON string or the original string. def serialize(obj) obj.is_a?(String) ? obj : JSON.generate(obj) end diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 84c030f..65fb7e8 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -23,6 +23,15 @@ def endpoints = @endpoints ||= [] def groups = @groups ||= {} def middleware = @middleware ||= [] + # Define an endpoint for the NATS API server. + # + # @param name [String] The name of the endpoint. + # @param subject [String, nil] The NATS subject to listen on. Defaults to the endpoint name. + # @param queue [String, nil] The NATS queue group to use. Defaults to nil. + # @param group [String, nil] The group this endpoint belongs to. Defaults to nil. + # @param handler [Proc] The block that will handle incoming messages. + # + # @return [void] def endpoint(name, subject: nil, queue: nil, group: nil, &handler) endpoints << { name:, @@ -33,15 +42,37 @@ def endpoint(name, subject: nil, queue: nil, group: nil, &handler) } end + # Define a group for organizing endpoints. + # + # @param name [String] The name of the group. + # @param group [String, nil] The parent group this group belongs to. Defaults to nil. + # @param queue [String, nil] The NATS queue group to use for this group. Defaults to nil. + # + # @return [void] def group(name, group: nil, queue: nil) groups[name] = { name:, parent: group, queue: } end + # Use a middleware class for processing messages. + # + # @param klass [Class] The middleware class to use. + # @param args [Array] Optional arguments to pass to the middleware class. + # @param block [Proc] Optional block to pass to the middleware class. + # + # @return [void] def use(klass, *args, &block) middleware << [klass, args, block] end - def run(nats_url:, service_opts:, instances: 4) + # Start the NATS API server. + # This method connects to the NATS server and spawns multiple instances of the API server. + # + # @param nats_url [String] The URL of the NATS server to connect to. + # @param service_opts [Hash] Options for the NATS service. + # @param instances [Integer] The number of instances to spawn. Defaults to 1. + # + # @return [void] + def run(nats_url:, service_opts:, instances: 1) logger.info 'Booting NATS API server...' spawn_instances(nats_url, service_opts, instances) sleep @@ -49,6 +80,13 @@ def run(nats_url:, service_opts:, instances: 4) private + # Spawns multiple instances of the NATS API server. + # + # @param url [String] The URL of the NATS server. + # @param opts [Hash] Options for the NATS service. + # @param count [Integer] The number of instances to spawn. + # + # @return [Concurrent::FixedThreadPool] The thread pool managing the worker threads. def spawn_instances(url, opts, count) pool = Concurrent::FixedThreadPool.new(count) count.times do @@ -59,6 +97,16 @@ def spawn_instances(url, opts, count) pool end + # Sets up a worker thread for the NATS API server. + # This method connects to the NATS server, adds the service, groups, and endpoints, + # and keeps the worker thread alive. + # + # @param url [String] The URL of the NATS server. + # @param opts [Hash] Options for the NATS service. + # @param eps [Array] The list of endpoints to add. + # @param gps [Hash] The groups to add. + # + # @return [void] def setup_worker(url, opts, eps, gps) client = NATS.connect url service = client.services.add(**opts) @@ -68,12 +116,26 @@ def setup_worker(url, opts, eps, gps) sleep end + # Adds groups to the NATS service. + # + # @param service [NATS::Service] The NATS service to add groups to. + # @param gps [Hash] The groups to add, where keys are group names and values are group definitions. + # + # @return [Hash] A map of group names to their created group objects. def add_groups(service, gps) created = {} gps.each_key { |name| build_group(service, gps, created, name) } created end + # Builds a group in the NATS service. + # + # @param service [NATS::Service] The NATS service to add the group to. + # @param defs [Hash] The group definitions, where keys are group names and values are group definitions. + # @param cache [Hash] A cache to store already created groups. + # @param name [String] The name of the group to build. + # + # @return [NATS::Group] The created group object. def build_group(service, defs, cache, name) return cache[name] if cache.key?(name) @@ -84,6 +146,13 @@ def build_group(service, defs, cache, name) cache[name] = parent.groups.add(gdef[:name], queue: gdef[:queue]) end + # Adds endpoints to the NATS service. + # + # @param service [NATS::Service] The NATS service to add endpoints to. + # @param endpoints [Array] The list of endpoints to add. + # @param group_map [Hash] A map of group names to their created group objects. + # + # @return [void] def add_endpoints(service, endpoints, group_map) endpoints.each do |ep| parent = ep[:group] ? group_map[ep[:group]] : service @@ -98,6 +167,12 @@ def add_endpoints(service, endpoints, group_map) end end + # Dispatches a message through the middleware stack and handles it with the provided handler. + # + # @param wrapper [MessageWrapper] The message wrapper containing the raw message. + # @param handler [Proc] The handler to process the message. + # + # @return [void] def dispatch_with_middleware(wrapper, handler) app = ->(w) { handle_message(w.raw, handler) } middleware.reverse_each do |(klass, args, blk)| @@ -106,6 +181,12 @@ def dispatch_with_middleware(wrapper, handler) app.call(wrapper) end + # Handles a raw NATS message using the provided handler. + # + # @param raw_msg [NATS::Message] The raw NATS message to handle. + # @param handler [Proc] The handler to process the message. + # + # @return [void] def handle_message(raw_msg, handler) wrapper = MessageWrapper.new(raw_msg) result = instance_exec(wrapper, &handler) @@ -115,6 +196,12 @@ def handle_message(raw_msg, handler) wrapper.respond_with_error(e.message) end + # Processes the result of the handler execution. + # + # @param wrapper [MessageWrapper] The message wrapper containing the raw message. + # @param result [Dry::Monads::Result] The result of the handler execution. + # + # @return [void] def process_result(wrapper, result) case result in Dry::Monads::Success From f09b4f8ef4ac9a9408ffef7f2522ad1d187a648b Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Thu, 31 Jul 2025 09:25:38 -0500 Subject: [PATCH 23/26] docs: Important note about the callback return expectation --- Readme.adoc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Readme.adoc b/Readme.adoc index 82afa45..799902a 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -44,7 +44,9 @@ $ bundle install Create a service class and include `Rubyists::Leopard::NatsApiServer`. Define one or more endpoints. Each endpoint receives a -`Rubyists::Leopard::MessageWrapper` object for each request to the {service-api} endpoint that service class is is subscribed to (subject:, or name:). +`Rubyists::Leopard::MessageWrapper` object for each request to the {service-api} endpoint +that service class is is subscribed to (subject:, or name:). The message handler/callback +is expected to return a `Dry::Monads[:result]` object, typically a `Success` or `Failure`. [source,ruby] ---- From 746c72af3fc7906fbf323e90a73638047be5d515 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Thu, 31 Jul 2025 09:38:25 -0500 Subject: [PATCH 24/26] style: Rubocop fixes --- lib/leopard/settings.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/leopard/settings.rb b/lib/leopard/settings.rb index b309287..2fb4034 100644 --- a/lib/leopard/settings.rb +++ b/lib/leopard/settings.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + require 'semantic_logger' module Rubyists From 3755f51c0a81020e819ed5fc9c1b74e402ad3a43 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Thu, 31 Jul 2025 10:00:08 -0500 Subject: [PATCH 25/26] test: Removes the #run from specs for now, stubbing wasn not working --- lib/leopard/nats_api_server.rb | 5 +++-- test/lib/nats_api_server.rb | 9 --------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 65fb7e8..34c0e77 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -70,12 +70,13 @@ def use(klass, *args, &block) # @param nats_url [String] The URL of the NATS server to connect to. # @param service_opts [Hash] Options for the NATS service. # @param instances [Integer] The number of instances to spawn. Defaults to 1. + # @param nosleep [Boolean] If true, does not sleep after starting the server. Defaults to false. # # @return [void] - def run(nats_url:, service_opts:, instances: 1) + def run(nats_url:, service_opts:, instances: 1, nosleep: false) logger.info 'Booting NATS API server...' spawn_instances(nats_url, service_opts, instances) - sleep + sleep unless nosleep end private diff --git a/test/lib/nats_api_server.rb b/test/lib/nats_api_server.rb index a17b39c..21ef6ca 100755 --- a/test/lib/nats_api_server.rb +++ b/test/lib/nats_api_server.rb @@ -53,15 +53,6 @@ assert_equal [[String, [1], blk]], @klass.middleware end - it 'delegates run to spawn_instances' do - args = nil - @klass.stub(:spawn_instances, ->(url, opts, count) { args = [url, opts, count] }) do - @klass.run(nats_url: 'nats://', service_opts: { name: 'svc' }, instances: 2) - end - - assert_equal ['nats://', { name: 'svc' }, 2], args - end - it 'dispatches through middleware in reverse order' do order = [] mw1 = Class.new do From 9642c8cd48efd2c7c90d6ea19dc79202665d66c6 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Thu, 31 Jul 2025 10:09:09 -0500 Subject: [PATCH 26/26] feat: Adds non-blocking mode --- lib/leopard/nats_api_server.rb | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 34c0e77..b8bd560 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -70,13 +70,16 @@ def use(klass, *args, &block) # @param nats_url [String] The URL of the NATS server to connect to. # @param service_opts [Hash] Options for the NATS service. # @param instances [Integer] The number of instances to spawn. Defaults to 1. - # @param nosleep [Boolean] If true, does not sleep after starting the server. Defaults to false. + # @param blocking [Boolean] If false, does not block current thread after starting the server. Defaults to true. # # @return [void] - def run(nats_url:, service_opts:, instances: 1, nosleep: false) + def run(nats_url:, service_opts:, instances: 1, blocking: true) logger.info 'Booting NATS API server...' - spawn_instances(nats_url, service_opts, instances) - sleep unless nosleep + # Return the thread pool if non-blocking + return spawn_instances(nats_url, service_opts, instances) unless blocking + + # Otherwise, just sleep the main thread forever + sleep end private