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 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..bd17c90 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,11 @@ PATH remote: . specs: leopard (0.1.0) + concurrent-ruby (~> 1.1) + dry-configurable (~> 1.3) + dry-monads (~> 1.9) nats-pure (~> 2.5) + semantic_logger (~> 4) GEM remote: https://rubygems.org/ @@ -12,10 +16,22 @@ 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) + 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) lint_roller (1.1.0) + logger (1.7.0) method_source (1.1.0) minitest (5.25.5) minitest-global_expectations (1.0.1) @@ -68,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) @@ -79,6 +97,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/Readme.adoc b/Readme.adoc index df7f996..799902a 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -1,9 +1,109 @@ -# 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] +:dry-configurable: https://github.com/dry-rb/dry-configurable[Dry::Configurable] +:dry-monads: https://github.com/dry-rb/dry-monads[Dry::Monads] -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 `Concurrent::FixedThreadPool` 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. +* Railway Oriented Design, using {dry-monads} for success and failure handling. +* {dry-configurable} settings container. + +== 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 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] +---- +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 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/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/examples/echo_endpoint.rb b/examples/echo_endpoint.rb new file mode 100755 index 0000000..735fda9 --- /dev/null +++ b/examples/echo_endpoint.rb @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../lib/leopard/nats_api_server' + +# Example to echo the given message +class EchoService + include Rubyists::Leopard::NatsApiServer + + endpoint(:echo) { |msg| Success(msg.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 4205f41..02b656e 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,7 +34,11 @@ Gem::Specification.new do |spec| 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' + 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 new file mode 100644 index 0000000..7aecb15 --- /dev/null +++ b/lib/leopard.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'dry/configurable' +require 'pathname' +require 'semantic_logger' +SemanticLogger.add_appender(io: $stdout, formatter: :color) + +class Pathname + def /(other) + join other.to_s + end +end + +module Rubyists + module Leopard + end +end + +require_relative 'leopard/settings' +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 diff --git a/lib/leopard/message_wrapper.rb b/lib/leopard/message_wrapper.rb new file mode 100644 index 0000000..732cdad --- /dev/null +++ b/lib/leopard/message_wrapper.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'json' + +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 + end + end +end diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb new file mode 100644 index 0000000..b8bd560 --- /dev/null +++ b/lib/leopard/nats_api_server.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require 'nats/client' +require 'dry/monads' +require 'concurrent' +require_relative '../leopard' +require_relative 'message_wrapper' + +module Rubyists + module Leopard + module NatsApiServer + include Dry::Monads[:result] + extend Dry::Monads[:result] + + def self.included(base) + base.extend(ClassMethods) + base.extend(Dry::Monads[:result]) + base.include(SemanticLogger::Loggable) + end + + module ClassMethods + 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:, + subject: subject || name, + queue:, + group:, + 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 + + # 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. + # @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, blocking: true) + logger.info 'Booting NATS API server...' + # 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 + + # 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 + eps = endpoints.dup + gps = groups.dup + pool.post { setup_worker(url, opts, eps, gps) } + end + 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) + group_map = add_groups(service, gps) + add_endpoints service, eps, group_map + # Keep the worker thread alive + 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) + + 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 + + # 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 + 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) + dispatch_with_middleware(wrapper, ep[:handler]) + end + 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)| + app = klass.new(app, *args, &blk) + end + 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) + process_result(wrapper, result) + rescue StandardError => e + logger.error 'Error processing message: ', e + 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 + wrapper.respond(result.value!) + in Dry::Monads::Failure + logger.error 'Error processing message: ', result.failure + wrapper.respond_with_error(result.failure) + end + end + end + end + end +end diff --git a/lib/leopard/settings.rb b/lib/leopard/settings.rb new file mode 100644 index 0000000..2fb4034 --- /dev/null +++ b/lib/leopard/settings.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'semantic_logger' + +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 + setting :logger, reader: true, default: SemanticLogger[:Leopard] + 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/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 diff --git a/test/lib/nats_api_server.rb b/test/lib/nats_api_server.rb new file mode 100755 index 0000000..21ef6ca --- /dev/null +++ b/test/lib/nats_api_server.rb @@ -0,0 +1,130 @@ +# 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, 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', 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 + blk = proc {} + @klass.use(String, 1, &blk) + + assert_equal [[String, [1], blk]], @klass.middleware + 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 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