diff --git a/.claude/settings.json b/.claude/settings.json index 3ada8ab..c72616d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,13 @@ { + "permissions": { + "allow": [ + "Bash(bundle exec rspec:*)", + "Bash(bundle exec rubocop:*)", + "Bash(find:*)" + ], + "deny": [], + "ask": [] + }, "hooks": { "PostToolUse": [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b810e..88d9596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,42 @@ -## [Unreleased] +## [0.2.0] - 2025-12-16 + +### Added + +- **Guards System**: Reusable validation rules with rich error responses + - `Servus::Guard` base class for creating custom guards + - `Servus::Guards` module included in services with `enforce_*!` and `check_*?` methods + - Built-in guards: + - `PresenceGuard` - validates values are present (not nil or empty) + - `TruthyGuard` - validates object attributes are truthy + - `FalseyGuard` - validates object attributes are falsey + - `StateGuard` - validates object attributes match expected value(s) + - Guards auto-define methods when classes inherit from `Servus::Guard` + - Guard DSL: `http_status`, `error_code`, `message` with interpolation support + - Multiple message template formats: String, I18n Symbol, inline Hash, Proc + - Rails auto-loading from `app/guards/*_guard.rb` + - Configuration options: `guards_dir`, `include_default_guards` + +- **GuardError**: New error class for guard validation failures + - Custom `code` and `http_status` per guard + - Services catch `:guard_failure` and wrap in failure response automatically + +### Changed + +- **Error API Refactored**: Cleaner separation of HTTP status and error body + - All errors now have `http_status` method returning Rails status symbol + - `api_error` returns `{ code:, message: }` for response body only + - Follows community conventions (Stripe, JSON:API) where HTTP status is in header + +- **Controller Helpers Refactored**: + - Renamed `render_service_object_error` to `render_service_error` + - Now takes error object directly instead of `api_error` hash + - Response format: `{ error: { code:, message: } }` with status from `error.http_status` + +### Breaking Changes + +- `render_service_object_error` renamed to `render_service_error` +- `render_service_error` now accepts error object, not hash: `render_service_error(result.error)` instead of `render_service_error(result.error.api_error)` +- Error response JSON structure changed from `{ code:, message: }` to `{ error: { code:, message: } }` ## [0.1.6] - 2025-12-06 diff --git a/Gemfile.lock b/Gemfile.lock index 0b9d5f6..9026e41 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - servus (0.1.6) + servus (0.2.0) active_model_serializers (~> 0.10.0) activesupport (~> 8.0) json-schema (~> 5) diff --git a/READme.md b/READme.md index 27bce4e..5d7abec 100644 --- a/READme.md +++ b/READme.md @@ -350,16 +350,105 @@ end The block receives the exception and has access to `success` and `failure` methods for creating the response. +## **Guards** + +Guards are reusable validation rules that halt service execution when conditions aren't met. They provide declarative precondition checking with rich error responses. + +### Built-in Guards + +```ruby +def call + # Validate values are present (not nil or empty) + enforce_presence!(user: user, account: account) + + # Validate object attributes are truthy + enforce_truthy!(on: user, check: :active) + enforce_truthy!(on: user, check: [:active, :verified]) # all must be truthy + + # Validate object attributes are falsey + enforce_falsey!(on: user, check: :banned) + enforce_falsey!(on: post, check: [:deleted, :hidden]) # all must be falsey + + # Validate attribute matches expected value(s) + enforce_state!(on: order, check: :status, is: :pending) + enforce_state!(on: account, check: :status, is: [:active, :trial]) # any match passes + + # ... business logic ... + success(result) +end +``` + +### Predicate Methods + +Each guard has a predicate version for conditional logic: + +```ruby +if check_truthy?(on: user, check: :premium) + apply_premium_discount +else + apply_standard_rate +end +``` + +### Custom Guards + +Create custom guards in `app/guards/`: + +```bash +$ rails g servus:guard open_account +=> create app/guards/open_account_guard.rb + create spec/guards/open_account_guard_spec.rb +``` + +```ruby +# app/guards/open_account_guard.rb +class OpenAccountGuard < Servus::Guard + http_status 422 + error_code 'open_account_required' + + message 'Invalid account: % does not have an open account' do + message_data + end + + def test(user:) + user.account.present? && user.account.status_open? + end + + private + + def message_data + { + name: kwargs[:user].name + } + end +end + +# Usage in services: +# enforce_open_account!(user: user_record) # throws on failure +# check_open_account?(user: user_record) # returns boolean +``` + +### Guard Error Responses + +When a guard fails, the service returns a failure response with structured error data: + +```ruby +result = TransferService.call(from_account: account, amount: 1000) +result.success? # => false +result.error.message # => "Invalid account: Bob Jones does not have an open account" +result.error.code # => "open_account_required" +result.error.http_status # => 422 +``` + ## Controller Helpers -Service objects can be called from controllers using the `run_service` and `render_service_object_error` helpers. +Service objects can be called from controllers using the `run_service` and `render_service_error` helpers. ### run_service -`run_service` calls the service object with the provided parameters and set's an instance variable `@result` to the -result of the service object if the result is successful. If the result is not successful, it will pass the result -to error to the `render_service_object_error` helper. This allows for easy error handling in the controller for -repetetive usecases. +`run_service` calls the service object with the provided parameters and sets an instance variable `@result` to the +result of the service object. If the result is not successful, it automatically calls `render_service_error` with +the error. This provides consistent error handling across controllers. ```ruby class SomeController < AppController @@ -367,7 +456,7 @@ class SomeController < AppController def controller_action result = Services::SomeServiceObject::Service.call(my_params) return if result.success? - render_service_object_error(result.error.api_error) + render_service_error(result.error) end # After @@ -377,26 +466,42 @@ class SomeController < AppController end ``` -### render_service_object_error +### render_service_error -`render_service_object_error` renders the error of a service object. It expects a hash with a `message` key and a `code` key from -the api_error method of the service error. This is all setup by default for a JSON API response, thought the method can be -overridden if needed to handle different usecases. +`render_service_error` renders a service error as JSON. It takes an error object (not a hash) and uses +`error.http_status` for the response status and `error.api_error` for the response body. ```ruby -# Behind the scenes, render_service_object_error calls the following: +# Behind the scenes, render_service_error calls the following: # -# error = result.error.api_error -# => { message: "Error message", code: 400 } +# render json: { error: error.api_error }, status: error.http_status # -# render json: { message: error[:message], code: error[:code] }, status: error[:code] +# Which produces a response like: +# { "error": { "code": "not_found", "message": "User not found" } } +# with HTTP status 404 class SomeController < AppController def controller_action result = Services::SomeServiceObject::Service.call(my_params) return if result.success? - render_service_object_error(result.error.api_error) + render_service_error(result.error) + end +end +``` + +Override `render_service_error` in your controller to customize error response format: + +```ruby +class ApplicationController < ActionController::Base + def render_service_error(error) + render json: { + error: { + type: error.api_error[:code], + details: error.message, + timestamp: Time.current + } + }, status: error.http_status end end ``` diff --git a/builds/servus-0.1.6.gem b/builds/servus-0.1.6.gem new file mode 100644 index 0000000..d66fa13 Binary files /dev/null and b/builds/servus-0.1.6.gem differ diff --git a/docs/current_focus.md b/docs/current_focus.md deleted file mode 100644 index b82c920..0000000 --- a/docs/current_focus.md +++ /dev/null @@ -1,569 +0,0 @@ -# Servus Event Bus Specification - -## 1. Overview & Philosophy - -This document specifies the design and API for a native event bus within the Servus gem. The core philosophy is **clean separation of concerns**, ensuring that services remain focused on business logic, while a dedicated, lightweight layer handles the routing of events to service invocations. - -This approach is defined by three distinct layers: - -1. **The Emitter (****`Servus::Base`****)**: A service that performs a business function and declares the events it emits upon completion. - -1. **The Mapper (****`Servus::EventHandler`****)**: A new, lightweight class whose sole responsibility is to subscribe to a single event and declaratively map it to one or more service invocations. - -1. **The Consumer (****`Servus::Base`****)**: A service that is invoked by the event handler and performs a subsequent business function, without needing any awareness of the eventing system. - -This design avoids overloading service classes with subscription logic and eliminates the need for auto-generated code, resulting in a system that is explicit, discoverable, and highly maintainable. - ---- - -## 2. Core Components - -### 2.1. The Emitter: `Servus::Base` - -Services that emit events will use a class-level `emits` method to declare them. - -#### **API: ****`emits(event_name, on:, with: nil)`** - -- **`event_name`**** (Symbol)**: The unique name of the event (e.g., `:referral_created`). - -- **`on`**** (Symbol)**: The trigger for automatic emission. Must be `:success` or `:failure`. - -- **`with`**** (Symbol, Optional)**: The name of an instance method on the service that will build the event payload. This method receives the service's `Servus::Support::Response` object as its only argument. - -If the `with:` option is omitted, the payload will be `result.data` for a success event or `{ error: result.error }` for a failure event. - -#### **Example: Service Declaration** - -```ruby -# app/services/referrals/create_referral/service.rb -class Referrals::CreateReferral::Service < Servus::Base - # On success, emit :referral_created, building the payload with the - # :referral_payload instance method. - emits :referral_created, on: :success, with: :referral_payload - - # On failure, emit :referral_failed with a default error payload. - emits :referral_failed, on: :failure - - # On error, emit :referral_error with a default error payload - emits :referral_error, on: :error - - def initialize(referee_id:) - @referee_id = referee_id - end - - def call - # ... business logic ... - - # The :referral_created event is automatically emitted here upon success. - success({ referral: @referral, referee: @referee, referrer: @referrer }) - end - - private - - # This method is called by the event system to build the payload. - def referral_payload(result) - { - referral_id: result.data[:referral].id, - referee_id: result.data[:referee].id, - referrer_id: result.data[:referrer].id, - created_at: Time.current - } - end -end -``` - -### 2.2. The Mapper: `Servus::EventHandler` - -This is a new, lightweight class that lives in the `app/events` directory. Each handler subscribes to a single event and maps it to one or more service invocations. - -#### **API: ****`handles(event_name)`** - -- **`event_name`**** (Symbol)**: The unique name of the event this class handles. - -#### **API: ****`invoke(service_class, options = {}, &block)`** - -- **`service_class`**** (Class)**: The `Servus::Base` subclass to be invoked. - -- **`options`**** (Hash, Optional)**: A hash of options for the invocation. - - `:async` (Boolean): If `true`, invokes the service using `.call_async`. Defaults to `false`. - - `:queue` (Symbol): The queue name to use for async jobs. - - `:if` (Proc): A lambda that receives the payload and must return `true` for the invocation to proceed. - - `:unless` (Proc): A lambda that receives the payload and must return `false` for the invocation to proceed. - -- **`&block`**** (Block)**: A block that receives the event payload and **must** return a hash of keyword arguments for the target service's `initialize` method. - -#### **Example: Event Handler** - -```ruby -# app/events/referral_created_handler.rb -class ReferralCreatedHandler < Servus::EventHandler - # Subscribe to the :referral_created event. - handles :referral_created - - # Define an invocation for the Rewards::GrantReferralRewards::Service. - invoke Rewards::GrantReferralRewards::Service, async: true do |payload| - # Map the event payload to the service's required arguments. - { user_id: payload[:referrer_id] } - end - - # Define another invocation for the Referrals::ActivityNotifier::Service. - invoke Referrals::ActivityNotifier::Service, async: true, queue: :notifications do |payload| - { referral_id: payload[:referral_id] } - end -end -``` - -### 2.3. Automatic Registration - -All classes inheriting from `Servus::EventHandler` within the `app/events` directory will be automatically discovered and registered by the gem at boot time. No manual configuration is required. - ---- - -## 3. Directory Structure - -The introduction of `EventHandler` classes establishes a new conventional directory: - -``` -app/ -├── events/ # New directory for event handlers -│ ├── referral_created_handler.rb -│ └── user_graduated_handler.rb -├── services/ -│ ├── referrals/ -│ │ └── create_referral/ -│ │ └── service.rb # Emitter -│ └── rewards/ -│ └── grant_referral_rewards/ -│ └── service.rb # Consumer -└── ... -``` - ---- - -## 4. Generators - -A Rails generator will be provided to facilitate the creation of `EventHandler` classes. - -#### **Command** - -```bash -$ rails g servus:event_handler referral_created -``` - -#### **Output** - -This command will generate two files: - -1. `app/events/referral_created_handler.rb` - -1. `spec/events/referral_created_handler_spec.rb` - -#### **Generated Handler Template** - -```ruby -# app/events/referral_created_handler.rb -class ReferralCreatedHandler < Servus::EventHandler - handles :referral_created - - # TODO: Add service invocations using the `invoke` DSL. - # - # Example: - # invoke SomeService, async: true do |payload| - # { argument_name: payload[:some_key] } - # end -end -``` - ---- - -## 5. Testing Strategy - -The separation of concerns enables focused and decoupled testing. - -### 5.1. Testing Event Emission - -When testing a service, you should only assert that the correct event was emitted with the expected payload. A test helper will be provided for this. - -```ruby -# spec/services/referrals/create_referral/service_spec.rb -RSpec.describe Referrals::CreateReferral::Service do - include Servus::Events::TestHelpers - - it 'emits a :referral_created event on success' do - # Assert that the block will cause the specified event to be emitted. - expect_event(:referral_created) - .with_payload(hash_including(:referral_id, :referee_id, :referrer_id)) - .when { described_class.call(referee_id: referee.id) } - end -end -``` - -### 5.2. Testing an Event Handler - -When testing a handler, you should provide a sample payload and assert that the correct services are invoked with the correctly mapped arguments. - -```ruby -# spec/events/referral_created_handler_spec.rb -RSpec.describe ReferralCreatedHandler do - let(:payload) do - { - referral_id: 'referral-123', - referrer_id: 'user-456', - referee_id: 'user-789', - created_at: Time.current - } - end - - it 'invokes the GrantReferralRewards service with the correct user ID' do - expect(Rewards::GrantReferralRewards::Service) - .to receive(:call_async) - .with(user_id: 'user-456') - - # Trigger the handler with the test payload. - described_class.handle(payload) - end - - it 'invokes the ActivityNotifier service with the correct referral ID' do - expect(Referrals::ActivityNotifier::Service) - .to receive(:call_async) - .with(referral_id: 'referral-123', queue: :notifications) - - described_class.handle(payload) - end -end -``` - ---- - -## 6. Implementation Plan - -This section provides a detailed, phase-by-phase breakdown of tasks required to implement the event bus feature. Each phase builds upon the previous one, and tasks are organized by logical implementation order. - -### Phase 1: Core Event Infrastructure - -**Goal**: Establish the foundational event emission capability in `Servus::Base`. - -- [ ] **Create Event Bus/Registry** (`lib/servus/events/bus.rb`) - - Create `Servus::Events::Bus` singleton class - - Implement event registration: `Bus.register_handler(event_name, handler_class)` - - Implement event emission: `Bus.emit(event_name, payload)` - - Store handlers in a thread-safe Hash: `@handlers = Concurrent::Hash.new { |h, k| h[k] = [] }` - - Add method to dispatch event to all registered handlers - - **Files**: `lib/servus/events/bus.rb`, `spec/servus/events/bus_spec.rb` - -- [ ] **Add `emits` DSL to Servus::Base** (`lib/servus/base.rb`) - - Create class-level `emits(event_name, on:, with: nil)` method - - Store event declarations in class instance variable: `@event_emissions ||= []` - - Validate `on:` parameter is one of: `:success`, `:failure`, `:error` - - Store event config as: `{ event_name:, trigger:, payload_builder: }` - - Add accessor method: `def self.event_emissions; @event_emissions || []; end` - - **Files**: `lib/servus/base.rb:30-50` - -- [ ] **Implement Automatic Event Emission** (`lib/servus/base.rb`) - - In `#call` method, after executing user's `#call` (around line 120): - - After success: trigger events where `on: :success` - - After failure: trigger events where `on: :failure` - - In rescue blocks: trigger events where `on: :error` - - Create private method `#emit_events_for(trigger_type, result)` - - **Files**: `lib/servus/base.rb:120-140` - -- [ ] **Implement Payload Builder Logic** (`lib/servus/base.rb`) - - Create private method `#build_event_payload(event_config, result)` - - If `with:` option present: call instance method with `result` as argument - - If `with:` absent and success: return `result.data` - - If `with:` absent and failure/error: return `{ error: result.error }` - - Handle case where custom payload builder returns nil (log warning, use default) - - **Files**: `lib/servus/base.rb:250-270` - -- [ ] **Write Comprehensive Specs** - - Test `emits` DSL declaration and storage - - Test automatic emission on success/failure/error - - Test custom payload builders via `with:` option - - Test default payloads when `with:` omitted - - Test multiple event declarations on same service - - Test events inherited by subclasses - - **Files**: `spec/servus/base_spec.rb:450-600` (new section) - -### Phase 2: EventHandler Base Class - -**Goal**: Create the `Servus::EventHandler` class with the `handles` and `invoke` DSL. - -- [ ] **Create EventHandler Base Class** (`lib/servus/event_handler.rb`) - - Create `Servus::EventHandler` class - - Add class instance variable: `@event_name` for event subscription - - Add class instance variable: `@invocations = []` for service mappings - - Add reader: `def self.event_name; @event_name; end` - - Add reader: `def self.invocations; @invocations || []; end` - - **Files**: `lib/servus/event_handler.rb`, `spec/servus/event_handler_spec.rb` - -- [ ] **Implement `handles` DSL Method** (`lib/servus/event_handler.rb`) - - Create class method: `def self.handles(event_name)` - - Store event name: `@event_name = event_name` - - Automatically register with Bus: `Servus::Events::Bus.register_handler(event_name, self)` - - Raise error if `handles` called multiple times in same class - - **Files**: `lib/servus/event_handler.rb:20-30` - -- [ ] **Implement `invoke` DSL Method** (`lib/servus/event_handler.rb`) - - Create class method: `def self.invoke(service_class, options = {}, &block)` - - Validate `service_class` is a subclass of `Servus::Base` - - Validate `options` keys are valid: `:async`, `:queue`, `:if`, `:unless` - - Require block to be present (raise error if missing) - - Store invocation config: `@invocations << { service_class:, options:, mapper: block }` - - **Files**: `lib/servus/event_handler.rb:40-60` - -- [ ] **Implement Event Handling Dispatcher** (`lib/servus/event_handler.rb`) - - Create class method: `def self.handle(payload)` - - Iterate over `@invocations` - - For each invocation: - - Check `:if` condition (skip if returns false) - - Check `:unless` condition (skip if returns true) - - Call mapper block with payload to get service kwargs - - Invoke service: `service_class.call(**kwargs)` or `.call_async(**kwargs.merge(queue: options[:queue]))` - - Return array of results from all invocations - - **Files**: `lib/servus/event_handler.rb:70-95` - -- [ ] **Handle Async Options** (`lib/servus/event_handler.rb`) - - When `async: true`, use `service_class.call_async(**kwargs)` - - Pass `:queue` option to `call_async` if present - - Ensure async calls work with existing `Servus::Extensions::Async` module - - **Files**: `lib/servus/event_handler.rb:85-90` - -- [ ] **Implement Conditional Logic** (`lib/servus/event_handler.rb`) - - Create private method: `def self.should_invoke?(payload, options)` - - Check `:if` proc: `return false if options[:if] && !options[:if].call(payload)` - - Check `:unless` proc: `return false if options[:unless] && options[:unless].call(payload)` - - Return true if all conditions pass - - **Files**: `lib/servus/event_handler.rb:100-110` - -- [ ] **Write Comprehensive Specs** - - Test `handles` DSL declaration and registration - - Test `invoke` DSL with various options - - Test `.handle(payload)` dispatches to services correctly - - Test conditional execution (`:if`, `:unless`) - - Test sync vs async invocation - - Test queue routing for async jobs - - Test multiple invocations in single handler - - Test payload mapping via block - - **Files**: `spec/servus/event_handler_spec.rb` - -### Phase 3: Automatic Handler Discovery - -**Goal**: Auto-discover and register all EventHandler classes in `app/events/` at Rails boot. - -- [ ] **Create Railtie for Initialization** (`lib/servus/railtie.rb`) - - Update existing railtie or create if doesn't exist - - Add initializer: `initializer 'servus.discover_event_handlers', after: :load_config_initializers` - - In initializer, call `Servus::Events::Loader.discover_handlers` - - **Files**: `lib/servus/railtie.rb:20-30` - -- [ ] **Create Handler Discovery Loader** (`lib/servus/events/loader.rb`) - - Create `Servus::Events::Loader` module - - Method: `def self.discover_handlers` - - Scan `app/events/**/*_handler.rb` using `Dir.glob` - - Require each file: `require_dependency(file_path)` - - Return count of discovered handlers for logging - - **Files**: `lib/servus/events/loader.rb`, `spec/servus/events/loader_spec.rb` - -- [ ] **Add Handler Conflict Detection** (`lib/servus/events/bus.rb`) - - In `Bus.register_handler`, detect if event already has handler - - Raise `Servus::Events::DuplicateHandlerError` if duplicate detected - - Include both handler class names in error message - - Add config option to allow multiple handlers (default: false) - - **Files**: `lib/servus/events/bus.rb:25-35` - -- [ ] **Create Custom Errors** (`lib/servus/events/errors.rb`) - - Create `Servus::Events::DuplicateHandlerError < StandardError` - - Create `Servus::Events::UnregisteredEventError < StandardError` - - **Files**: `lib/servus/events/errors.rb` - -- [ ] **Add Development Mode Reloading** (`lib/servus/railtie.rb`) - - Clear handler registry on code reload: `to_prepare` hook - - Call `Servus::Events::Bus.clear` before re-discovering - - Ensure handlers re-register properly in development - - **Files**: `lib/servus/railtie.rb:35-40` - -- [ ] **Write Comprehensive Specs** - - Test handler discovery in dummy Rails app - - Test duplicate handler detection raises error - - Test handler reloading in development mode - - Test nested handler files are discovered - - Test handlers are properly registered with Bus - - **Files**: `spec/servus/events/loader_spec.rb`, `spec/integration/handler_discovery_spec.rb` - -### Phase 4: Test Helpers - -**Goal**: Provide intuitive test helpers for asserting event emissions and testing handlers. - -- [ ] **Create Test Helpers Module** (`lib/servus/events/test_helpers.rb`) - - Create `Servus::Events::TestHelpers` module - - Add RSpec-specific helpers - - Include event capture/inspection utilities - - **Files**: `lib/servus/events/test_helpers.rb`, `spec/servus/events/test_helpers_spec.rb` - -- [ ] **Implement `expect_event` Matcher** (`lib/servus/events/test_helpers.rb`) - - Create chainable matcher: `expect_event(event_name)` - - Implement `.with_payload(expected_payload)` chain - - Implement `.when { block }` chain that executes code - - Capture events emitted during block execution - - Assert event was emitted with matching payload - - Use RSpec's `hash_including` for partial payload matching - - **Files**: `lib/servus/events/test_helpers.rb:10-60` - -- [ ] **Create Event Capture Mechanism** (`lib/servus/events/test_helpers.rb`) - - Create thread-local event store: `@captured_events = []` - - Hook into `Bus.emit` to capture events during tests - - Method: `def capture_events(&block)` that returns array of emitted events - - Auto-clear captured events between test runs - - **Files**: `lib/servus/events/test_helpers.rb:70-90` - -- [ ] **Add Handler Testing Utilities** (`lib/servus/events/test_helpers.rb`) - - Helper method: `trigger_event(event_name, payload)` for directly testing handlers - - Method to assert handler invoked specific service: `expect_handler_to_invoke(service_class)` - - Method to build sample payloads: `sample_payload_for(event_name)` - - **Files**: `lib/servus/events/test_helpers.rb:100-130` - -- [ ] **Create RSpec Configuration** (`lib/servus/events/test_helpers.rb`) - - Add RSpec config to auto-include TestHelpers in event specs - - Add config to auto-clear event registry between tests - - Add matcher aliases for readability - - **Files**: `lib/servus/events/test_helpers.rb:140-160` - -- [ ] **Write Comprehensive Specs and Examples** - - Test `expect_event` matcher with various payload matchers - - Test `.when` block execution and event capture - - Test negative cases (event not emitted, wrong payload) - - Test handler testing utilities - - Create example specs showing usage patterns - - **Files**: `spec/servus/events/test_helpers_spec.rb`, `spec/examples/event_testing_spec.rb` - -### Phase 5: Generator - -**Goal**: Provide Rails generator for quickly scaffolding new EventHandler classes and specs. - -- [ ] **Create Generator Class** (`lib/generators/servus/event_handler/event_handler_generator.rb`) - - Inherit from `Rails::Generators::NamedBase` - - Set source root: `source_root File.expand_path('templates', __dir__)` - - Define generator description and usage - - **Files**: `lib/generators/servus/event_handler/event_handler_generator.rb` - -- [ ] **Implement File Generation Logic** (`lib/generators/servus/event_handler/event_handler_generator.rb`) - - Method: `def create_handler_file` - - Generate file at: `app/events/#{file_name}_handler.rb` - - Use ERB template with proper class name and event name - - Method: `def create_spec_file` - - Generate file at: `spec/events/#{file_name}_handler_spec.rb` - - **Files**: `lib/generators/servus/event_handler/event_handler_generator.rb:15-30` - -- [ ] **Create Handler Template** (`lib/generators/servus/event_handler/templates/handler.rb.tt`) - - ERB template with `<%= class_name %>Handler < Servus::EventHandler` - - Include `handles :<%= event_name %>` - - Include TODO comment with example invoke usage - - **Files**: `lib/generators/servus/event_handler/templates/handler.rb.tt` - -- [ ] **Create Spec Template** (`lib/generators/servus/event_handler/templates/handler_spec.rb.tt`) - - ERB template for RSpec test file - - Include sample payload `let` block - - Include example test for service invocation - - Include pending test for additional invocations - - **Files**: `lib/generators/servus/event_handler/templates/handler_spec.rb.tt` - -- [ ] **Add Naming Conventions** (`lib/generators/servus/event_handler/event_handler_generator.rb`) - - Convert snake_case event names to proper class names - - Example: `referral_created` → `ReferralCreatedHandler` - - Handle multi-word event names correctly - - Add validation for event name format (only alphanumeric and underscores) - - **Files**: `lib/generators/servus/event_handler/event_handler_generator.rb:40-55` - -- [ ] **Write Generator Specs** (`spec/generators/servus/event_handler_generator_spec.rb`) - - Test generator creates handler file in correct location - - Test generator creates spec file in correct location - - Test generated files have correct content/structure - - Test naming conventions work correctly - - Test generator with various event name formats - - Use `Rails::Generators::TestCase` for generator testing - - **Files**: `spec/generators/servus/event_handler_generator_spec.rb` - -### Phase 6: Documentation & Polish - -**Goal**: Document the event bus feature and prepare for release. - -- [ ] **Move Spec to Feature Docs** (`docs/features/5_event_bus.md`) - - Copy content from `docs/current_focus.md` to `docs/features/5_event_bus.md` - - Remove "Implementation Plan" section (internal only) - - Polish language to be present tense ("The event bus provides...") - - Add introduction paragraph linking to related features (async execution) - - **Files**: `docs/features/5_event_bus.md` - -- [ ] **Update Current Focus** (`docs/current_focus.md`) - - Clear or archive current content - - Add new focus area (could be generator updates from IDEAS.md) - - Or mark as "Event bus implementation complete, awaiting next focus" - - **Files**: `docs/current_focus.md` - -- [ ] **Update README** (`READme.md`) - - Add "Event Bus" section under features list - - Add quick example showing emitter → handler → consumer flow - - Add link to full documentation: `docs/features/5_event_bus.md` - - Keep example concise (10-15 lines) - - **Files**: `READme.md:30-60` - -- [ ] **Add YARD Documentation** (various files) - - Document `Servus::Base.emits` with @param and @example tags - - Document `Servus::EventHandler` class and DSL methods - - Document `Servus::Events::Bus` public methods - - Document test helpers module and matchers - - Generate updated YARD docs: `bundle exec yard doc` - - **Files**: `lib/servus/base.rb`, `lib/servus/event_handler.rb`, etc. - -- [ ] **Update CHANGELOG** (`CHANGELOG.md`) - - Add new section: `## [0.2.0] - Unreleased` - - List new features: - - Event bus with `emits` DSL for services - - `Servus::EventHandler` for mapping events to service invocations - - Automatic handler discovery in `app/events/` - - Test helpers with `expect_event` matcher - - Generator: `rails g servus:event_handler` - - Note any breaking changes (hopefully none) - - **Files**: `CHANGELOG.md:1-20` - -- [ ] **Create Migration Guide** (`docs/guides/3_adding_events.md`) - - Guide for adding events to existing services - - Walkthrough: identify business events → add `emits` → create handler → test - - Best practices: when to use events vs direct service calls - - Common patterns: notification events, audit events, workflow triggers - - Troubleshooting: handler not found, payload mapping issues - - **Files**: `docs/guides/3_adding_events.md` - -- [ ] **Update Version Number** (`lib/servus/version.rb`) - - Bump version to `0.2.0` (minor version for new feature) - - Update version in `servus.gemspec` if needed - - **Files**: `lib/servus/version.rb:3` - ---- - -### Implementation Notes - -**Testing Strategy**: -- Write specs FIRST for each component (TDD approach) -- Use dummy Rails app in `spec/dummy` for integration tests -- Test thread safety for Bus registry (use concurrent gem) - -**Performance Considerations**: -- Event emission should add < 1ms to service execution -- Handler lookup should be O(1) using hash-based registry -- Consider async-by-default for most event handlers to avoid blocking - -**Backward Compatibility**: -- All event features are opt-in (no breaking changes) -- Services without `emits` declarations work exactly as before -- No changes to existing public APIs - -**Dependencies**: -- May need `concurrent-ruby` gem for thread-safe Bus registry -- Async features already depend on ActiveJob (no new dependencies) - -**Phasing Approach**: -- Phases 1-2 can be merged as single PR (core functionality) -- Phase 3 requires Rails integration testing (separate PR recommended) -- Phases 4-6 are polish/DX improvements (can be bundled or separate) - diff --git a/docs/features/6_guards.md b/docs/features/6_guards.md new file mode 100644 index 0000000..4e906b4 --- /dev/null +++ b/docs/features/6_guards.md @@ -0,0 +1,356 @@ +# @title Features / 6. Guards + +# Guards + +Guards are reusable validation rules that halt service execution when conditions aren't met. They provide a declarative way to enforce preconditions with rich, API-friendly error responses. + +## Why Guards? + +Instead of scattering validation logic throughout services: + +```ruby +# Without guards - repetitive and verbose +def call + return failure("User required", type: ValidationError) unless user + return failure("User must be active", type: ValidationError) unless user.active? + # ... business logic ... +end +``` + +Use guards for clean, declarative validation: + +```ruby +# With guards - clear and reusable +def call + enforce_presence!(user: user) + enforce_truthy!(on: user, check: :active) + # ... business logic ... +end +``` + +## Built-in Guards + +Servus includes four guards by default: + +### PresenceGuard + +Validates that all values are present (not nil or empty): + +```ruby +# Single value +enforce_presence!(user: user) + +# Multiple values - all must be present +enforce_presence!(user: user, account: account, device: device) + +# Works with strings, arrays, hashes +enforce_presence!(email: email) # fails if nil or "" +enforce_presence!(items: cart.items) # fails if nil or [] +enforce_presence!(data: response.body) # fails if nil or {} +``` + +Error: `"user must be present (got nil)"` or `"email must be present (got \"\")"` + +### TruthyGuard + +Validates that attribute(s) on an object are truthy: + +```ruby +# Single attribute +enforce_truthy!(on: user, check: :active) + +# Multiple attributes - all must be truthy +enforce_truthy!(on: user, check: [:active, :verified, :confirmed]) + +# Conditional check +if check_truthy?(on: subscription, check: :valid?) + process_subscription +end +``` + +Error: `"User.active must be truthy (got false)"` + +### FalseyGuard + +Validates that attribute(s) on an object are falsey: + +```ruby +# Single attribute - user must not be banned +enforce_falsey!(on: user, check: :banned) + +# Multiple attributes - all must be falsey +enforce_falsey!(on: post, check: [:deleted, :hidden, :flagged]) + +# Conditional check +if check_falsey?(on: user, check: :suspended) + allow_action +end +``` + +Error: `"User.banned must be falsey (got true)"` + +### StateGuard + +Validates that an attribute matches an expected value or one of several allowed values: + +```ruby +# Single expected value +enforce_state!(on: order, check: :status, is: :pending) + +# Multiple allowed values - any match passes +enforce_state!(on: account, check: :status, is: [:active, :trial]) + +# Conditional check +if check_state?(on: order, check: :status, is: :shipped) + send_tracking_email +end +``` + +Errors: +- Single value: `"Order.status must be pending (got shipped)"` +- Multiple values: `"Account.status must be one of active, trial (got suspended)"` + +## Guard Methods + +Each guard defines two methods on `Servus::Guards`: + +- **Bang method (`!`)** - Throws on failure, halts execution +- **Predicate method (`?`)** - Returns boolean, continues execution + +```ruby +# Bang method - use for preconditions that must pass +enforce_presence!(user: user) # throws :guard_failure if nil + +# Predicate method - use for conditional logic +if check_truthy?(on: account, check: :premium) + apply_premium_discount +else + apply_standard_rate +end +``` + +## Creating Custom Guards + +Define guards by inheriting from `Servus::Guard`: + +```ruby +# app/guards/sufficient_balance_guard.rb +class SufficientBalanceGuard < Servus::Guard + http_status 422 + error_code 'insufficient_balance' + + message 'Insufficient balance: need %s, have %s' do + message_data + end + + def test(account:, amount:) + account.balance >= amount + end + + private + + def message_data + { + required: kwargs[:amount], + available: kwargs[:account].balance + } + end +end +``` + +This automatically defines `enforce_sufficient_balance!` and `check_sufficient_balance?` methods. + +### Guard DSL + +**`http_status`** - HTTP status code for API responses (default: 422) + +```ruby +http_status 422 # Unprocessable Entity +http_status 403 # Forbidden +http_status 400 # Bad Request +``` + +**`error_code`** - Machine-readable error code for API clients + +```ruby +error_code 'insufficient_balance' +error_code 'daily_limit_exceeded' +error_code 'account_locked' +``` + +**`message`** - Human-readable error message with optional interpolation + +```ruby +# Static message +message 'Amount must be positive' + +# With interpolation (uses Ruby's % formatting) +message 'Balance: %s, Required: %s' do + { current: account.balance, required: amount } +end +``` + +The message block has access to all kwargs passed to the guard via `kwargs`. + +**`test`** - The validation logic (must return boolean) + +```ruby +def test(account:, amount:) + account.balance >= amount +end +``` + +## Message Templates + +Guards support multiple message template formats: + +### String with Interpolation + +```ruby +message 'Insufficient balance: need %s, have %s' do + { required: amount, available: account.balance } +end +``` + +### I18n Symbol + +```ruby +message :insufficient_balance +# Looks up: I18n.t('guards.insufficient_balance') +# Falls back to: "Insufficient balance" (humanized) +``` + +### Inline Translations + +```ruby +message( + en: 'Insufficient balance', + es: 'Saldo insuficiente', + fr: 'Solde insuffisant' +) +``` + +### Dynamic Proc + +```ruby +message -> { "Limit exceeded for #{limit_type} transfers" } +``` + +## Error Handling + +When a bang guard fails, it throws `:guard_failure` with a `GuardError`. Services automatically catch this and return a failure response: + +```ruby +class TransferService < Servus::Base + def call + enforce_state!(on: from_account, check: :status, is: :active) + # If guard fails, execution stops here + # Service returns: Response(success: false, error: GuardError) + + transfer_funds + success(transfer: transfer) + end +end +``` + +The `GuardError` includes all metadata: + +```ruby +error = guard.error +error.message # "Account.status must be active (got suspended)" +error.code # "invalid_state" +error.http_status # 422 +``` + +## Naming Convention + +Guard class names are converted to method names by stripping the `Guard` suffix and converting to snake_case: + +| Class Name | Bang Method | Predicate Method | +|------------|-------------|------------------| +| `SufficientBalanceGuard` | `enforce_sufficient_balance!` | `check_sufficient_balance?` | +| `ValidAmountGuard` | `enforce_valid_amount!` | `check_valid_amount?` | +| `AuthorizedGuard` | `enforce_authorized!` | `check_authorized?` | + +The built-in guards follow this pattern: `TruthyGuard` -> `enforce_truthy!` / `check_truthy?`. + +## Rails Auto-Loading + +In Rails, guards in `app/guards/` are automatically loaded. Files must follow the `*_guard.rb` naming convention: + +``` +app/guards/ +├── sufficient_balance_guard.rb +├── valid_amount_guard.rb +└── authorized_guard.rb +``` + +## Configuration + +Disable built-in guards if you want to define your own (you can have both): + +```ruby +Servus.configure do |config| + config.include_default_guards = false # Default: true + config.guards_dir = 'app/guards' # Default: 'app/guards' +end +``` + +## Testing Guards + +Test guards in isolation: + +```ruby +RSpec.describe Servus::Guards::TruthyGuard do + let(:user_class) do + Struct.new(:active, :verified, keyword_init: true) do + def self.name + 'User' + end + end + end + + describe '#test' do + it 'passes when attribute is truthy' do + user = user_class.new(active: true) + guard = described_class.new(on: user, check: :active) + expect(guard.test(on: user, check: :active)).to be true + end + + it 'fails when attribute is falsey' do + user = user_class.new(active: false) + guard = described_class.new(on: user, check: :active) + expect(guard.test(on: user, check: :active)).to be false + end + end + + describe '#error' do + it 'returns GuardError with correct metadata' do + user = user_class.new(active: false) + guard = described_class.new(on: user, check: :active) + error = guard.error + + expect(error.code).to eq('must_be_truthy') + expect(error.message).to include('User', 'active', 'false') + expect(error.http_status).to eq(422) + end + end +end +``` + +Test guards in service integration: + +```ruby +RSpec.describe TransferService do + it 'fails when account is not active' do + result = described_class.call( + from_account: suspended_account, + to_account: recipient, + amount: 100 + ) + + expect(result).to be_failure + expect(result.error.code).to eq('invalid_state') + end +end +``` \ No newline at end of file diff --git a/docs/features/guards_naming_convention.md b/docs/features/guards_naming_convention.md new file mode 100644 index 0000000..15b3fae --- /dev/null +++ b/docs/features/guards_naming_convention.md @@ -0,0 +1,540 @@ +# Servus Guards: Final Naming Convention + +## The Pattern + +### Class Naming: `Guard` + +**Rule:** Guard class names should describe **what is being checked**, NOT the action. + +- ✅ **DO** use nouns, adjectives, or states +- ❌ **DON'T** use action verbs like "Enforce", "Check", "Verify", "Require", "Assert" + +### Generated Methods + +The framework automatically generates two methods: + +1. **Bang method:** `enforce_!` - Enforces the rule or throws +2. **Predicate method:** `check_?` - Checks if the rule is met + +--- + +## ✅ Good Examples + +### Example 1: Balance Check + +```ruby +# ✅ GOOD - Describes the condition +class SufficientBalanceGuard < Servus::Guard + message 'Insufficient balance: need %s, have %s' do + { required: amount, available: account.balance } + end + + def test(account:, amount:) + account.balance >= amount + end +end + +# Generates: +enforce_sufficient_balance!(account: account, amount: amount) +check_sufficient_balance?(account: account, amount: amount) +``` + +**Why it's good:** "SufficientBalance" describes the state/condition being checked. + +--- + +### Example 2: Presence Check + +```ruby +# ✅ GOOD - Describes the condition +class PresenceGuard < Servus::Guard + message '%s must be present' do + { keys: kwargs.keys.join(', ') } + end + + def test(**values) + values.values.all?(&:present?) + end +end + +# Generates: +enforce_presence!(user: user, account: account) +check_presence?(user: user, account: account) +``` + +**Alternative naming:** +```ruby +# Also good +class NotNilGuard < Servus::Guard + # ... +end + +# Generates: +enforce_not_nil!(user: user) +check_not_nil?(user: user) +``` + +--- + +### Example 3: Authorization + +```ruby +# ✅ GOOD - Describes the requirement +class AdminRoleGuard < Servus::Guard + message 'User must have admin role' do + {} + end + + def test(user:) + user.admin? + end +end + +# Generates: +enforce_admin_role!(user: user) +check_admin_role?(user: user) +``` + +--- + +### Example 4: Product Feature + +```ruby +# ✅ GOOD - Describes the enabled state +class EnabledProductGuard < Servus::Guard + message 'Product %s is not enabled' do + { product_name: product.name } + end + + def test(product:) + product.enabled? + end +end + +# Generates: +enforce_enabled_product!(product: product) +check_enabled_product?(product: product) +``` + +--- + +### Example 5: Age Requirement + +```ruby +# ✅ GOOD - Describes the requirement +class MinimumAgeGuard < Servus::Guard + message 'Must be at least %s years old' do + { minimum: 18 } + end + + def test(date_of_birth:) + age = ((Time.zone.now - date_of_birth.to_time) / 1.year.seconds).floor + age >= 18 + end +end + +# Generates: +enforce_minimum_age!(date_of_birth: user.date_of_birth) +check_minimum_age?(date_of_birth: user.date_of_birth) +``` + +--- + +### Example 6: Rate Limiting + +```ruby +# ✅ GOOD - Describes the limit state +class DailyLimitRemainingGuard < Servus::Guard + message 'Daily limit exceeded: %s/%s' do + { + used: user.daily_api_calls, + limit: user.daily_api_limit + } + end + + def test(user:) + user.daily_api_calls < user.daily_api_limit + end +end + +# Generates: +enforce_daily_limit_remaining!(user: user) +check_daily_limit_remaining?(user: user) +``` + +--- + +### Example 7: Ownership + +```ruby +# ✅ GOOD - Describes the relationship +class OwnershipGuard < Servus::Guard + message 'User does not own this resource' do + {} + end + + def test(user:, resource:) + resource.user_id == user.id + end +end + +# Generates: +enforce_ownership!(user: user, resource: account) +check_ownership?(user: user, resource: account) +``` + +--- + +## ❌ Bad Examples (DON'T DO THIS) + +### Example 1: Using "Enforce" in Class Name + +```ruby +# ❌ BAD - Uses action verb +class EnforceSufficientBalanceGuard < Servus::Guard + # ... +end + +# Generates (redundant!): +enforce_enforce_sufficient_balance!(...) # ❌ Redundant! +check_enforce_sufficient_balance?(...) # ❌ Doesn't make sense! +``` + +**Why it's bad:** The action verb "Enforce" is already added by the framework. + +--- + +### Example 2: Using "Check" in Class Name + +```ruby +# ❌ BAD - Uses action verb +class CheckPresenceGuard < Servus::Guard + # ... +end + +# Generates (redundant!): +enforce_check_presence!(...) # ❌ Weird! +check_check_presence?(...) # ❌ Redundant! +``` + +**Why it's bad:** The action verb "Check" is already added by the framework. + +--- + +### Example 3: Using "Require" in Class Name + +```ruby +# ❌ BAD - Uses action verb +class RequireAdminRoleGuard < Servus::Guard + # ... +end + +# Generates (awkward!): +enforce_require_admin_role!(...) # ❌ Double action verbs! +check_require_admin_role?(...) # ❌ Confusing! +``` + +**Why it's bad:** "Require" is an action verb that conflicts with the framework's verbs. + +--- + +### Example 4: Using "Verify" in Class Name + +```ruby +# ❌ BAD - Uses action verb +class VerifyOwnershipGuard < Servus::Guard + # ... +end + +# Generates (awkward!): +enforce_verify_ownership!(...) # ❌ Double action verbs! +check_verify_ownership?(...) # ❌ Confusing! +``` + +**Why it's bad:** "Verify" is an action verb that conflicts with the framework. + +--- + +### Example 5: Using "Validate" in Class Name + +```ruby +# ❌ BAD - Uses action verb +class ValidateEmailGuard < Servus::Guard + # ... +end + +# Generates (awkward!): +enforce_validate_email!(...) # ❌ Double action verbs! +check_validate_email?(...) # ❌ Confusing! +``` + +**Why it's bad:** "Validate" is an action verb. + +--- + +## 📝 Naming Guidelines + +### DO: Use Descriptive Conditions + +**Pattern:** `Guard` or `Guard` + +Examples: +- `SufficientBalanceGuard` - adjective + noun +- `PresenceGuard` - noun +- `AdminRoleGuard` - noun +- `EnabledProductGuard` - adjective + noun +- `MinimumAgeGuard` - adjective + noun +- `ActiveDeviceGuard` - adjective + noun +- `ValidEmailGuard` - adjective + noun +- `PositiveAmountGuard` - adjective + noun +- `UniqueEmailGuard` - adjective + noun + +### DON'T: Use Action Verbs + +**Avoid these prefixes:** +- ❌ `Enforce...Guard` +- ❌ `Check...Guard` +- ❌ `Verify...Guard` +- ❌ `Require...Guard` +- ❌ `Assert...Guard` +- ❌ `Validate...Guard` +- ❌ `Ensure...Guard` +- ❌ `Demand...Guard` +- ❌ `Test...Guard` + +**Why:** The framework automatically adds `enforce_` and `check_` prefixes to the generated methods. + +--- + +## 🎯 Naming Tips + +### Tip 1: Think About the Condition, Not the Action + +**Ask yourself:** "What state or condition am I checking?" + +- ✅ "Is the balance sufficient?" → `SufficientBalanceGuard` +- ✅ "Is the user present?" → `PresenceGuard` +- ✅ "Does the user have admin role?" → `AdminRoleGuard` +- ✅ "Is the product enabled?" → `EnabledProductGuard` + +**Don't ask:** "What action am I taking?" + +- ❌ "I'm enforcing balance" → `EnforceBalanceGuard` (wrong!) +- ❌ "I'm checking presence" → `CheckPresenceGuard` (wrong!) + +--- + +### Tip 2: Use Adjectives for States + +When checking if something is in a certain state, use an adjective: + +- `ActiveDeviceGuard` - device is active +- `ValidEmailGuard` - email is valid +- `PositiveAmountGuard` - amount is positive +- `UniqueEmailGuard` - email is unique +- `EnabledProductGuard` - product is enabled + +--- + +### Tip 3: Use Nouns for Existence/Presence + +When checking if something exists or is present: + +- `PresenceGuard` - checks presence +- `OwnershipGuard` - checks ownership +- `AdminRoleGuard` - checks for admin role +- `PermissionGuard` - checks for permission + +--- + +### Tip 4: Describe Requirements Positively + +Prefer positive descriptions over negative: + +- ✅ `SufficientBalanceGuard` (positive) +- ⚠️ `InsufficientBalanceGuard` (negative - works but less clear) + +- ✅ `ActiveDeviceGuard` (positive) +- ⚠️ `InactiveDeviceGuard` (negative) + +- ✅ `ValidEmailGuard` (positive) +- ⚠️ `InvalidEmailGuard` (negative) + +**Exception:** Sometimes negative is clearer: + +- `NotNilGuard` - clear and concise +- `NotEmptyGuard` - clear what it checks + +--- + +## 📚 Complete Examples Library + +### Simple Validations + +```ruby +class PresenceGuard < Servus::Guard + # enforce_presence! / check_presence? +end + +class NotNilGuard < Servus::Guard + # enforce_not_nil! / check_not_nil? +end + +class PositiveAmountGuard < Servus::Guard + # enforce_positive_amount! / check_positive_amount? +end + +class ValidEmailGuard < Servus::Guard + # enforce_valid_email! / check_valid_email? +end + +class UniqueEmailGuard < Servus::Guard + # enforce_unique_email! / check_unique_email? +end +``` + +### Business Rules + +```ruby +class SufficientBalanceGuard < Servus::Guard + # enforce_sufficient_balance! / check_sufficient_balance? +end + +class DailyLimitRemainingGuard < Servus::Guard + # enforce_daily_limit_remaining! / check_daily_limit_remaining? +end + +class MinimumPurchaseAmountGuard < Servus::Guard + # enforce_minimum_purchase_amount! / check_minimum_purchase_amount? +end + +class WithinTransferLimitGuard < Servus::Guard + # enforce_within_transfer_limit! / check_within_transfer_limit? +end +``` + +### Authorization + +```ruby +class AdminRoleGuard < Servus::Guard + # enforce_admin_role! / check_admin_role? +end + +class OwnershipGuard < Servus::Guard + # enforce_ownership! / check_ownership? +end + +class PermissionGuard < Servus::Guard + # enforce_permission! / check_permission? +end + +class ActiveMembershipGuard < Servus::Guard + # enforce_active_membership! / check_active_membership? +end +``` + +### Resource States + +```ruby +class ActiveDeviceGuard < Servus::Guard + # enforce_active_device! / check_active_device? +end + +class EnabledProductGuard < Servus::Guard + # enforce_enabled_product! / check_enabled_product? +end + +class AvailableInventoryGuard < Servus::Guard + # enforce_available_inventory! / check_available_inventory? +end + +class OpenAccountGuard < Servus::Guard + # enforce_open_account! / check_open_account? +end +``` + +### Compliance + +```ruby +class MinimumAgeGuard < Servus::Guard + # enforce_minimum_age! / check_minimum_age? +end + +class CompletedKYCGuard < Servus::Guard + # enforce_completed_kyc! / check_completed_kyc? +end + +class AcceptedTermsGuard < Servus::Guard + # enforce_accepted_terms! / check_accepted_terms? +end + +class VerifiedEmailGuard < Servus::Guard + # enforce_verified_email! / check_verified_email? +end +``` + +--- + +## 🔄 Migration Guide + +If you have existing guards with action verbs, here's how to rename them: + +### Before (with action verbs) +```ruby +class EnforceSufficientBalanceGuard < Servus::Guard + # ... +end + +# Usage: +enforce_enforce_sufficient_balance!(...) # Redundant! +``` + +### After (condition only) +```ruby +class SufficientBalanceGuard < Servus::Guard + # ... +end + +# Usage: +enforce_sufficient_balance!(...) # Clean! +check_sufficient_balance?(...) # Clear! +``` + +### Rename Mapping + +| Old Name (❌) | New Name (✅) | +|--------------|--------------| +| `EnforceSufficientBalanceGuard` | `SufficientBalanceGuard` | +| `RequirePresenceGuard` | `PresenceGuard` | +| `CheckAdminRoleGuard` | `AdminRoleGuard` | +| `VerifyOwnershipGuard` | `OwnershipGuard` | +| `AssertPositiveGuard` | `PositiveAmountGuard` | +| `EnsureValidEmailGuard` | `ValidEmailGuard` | +| `DemandActiveDeviceGuard` | `ActiveDeviceGuard` | + +--- + +## ✅ Summary + +**The Golden Rule:** + +> Guard class names describe **WHAT** is being checked, not **HOW** it's being checked. + +**Pattern:** +```ruby +class Guard < Servus::Guard + # Describes the condition/state/requirement +end + +# Framework generates: +enforce_! # Action verb added by framework +check_? # Action verb added by framework +``` + +**Examples:** +- `SufficientBalanceGuard` → `enforce_sufficient_balance!` / `check_sufficient_balance?` +- `PresenceGuard` → `enforce_presence!` / `check_presence?` +- `AdminRoleGuard` → `enforce_admin_role!` / `check_admin_role?` +- `EnabledProductGuard` → `enforce_enabled_product!` / `check_enabled_product?` + +**Remember:** Let the framework add the action verbs. Your job is to describe the condition! 🎯 diff --git a/docs/integration/1_configuration.md b/docs/integration/1_configuration.md index e703c13..8df1b05 100644 --- a/docs/integration/1_configuration.md +++ b/docs/integration/1_configuration.md @@ -6,7 +6,7 @@ Servus works without configuration. Optional settings exist for customizing dire ## Directory Configuration -Configure where Servus looks for schemas, services, and event handlers: +Configure where Servus looks for schemas, services, event handlers, and guards: ```ruby # config/initializers/servus.rb @@ -19,10 +19,13 @@ Servus.configure do |config| # Default: 'app/events' config.events_dir = 'app/events' + + # Default: 'app/guards' + config.guards_dir = 'app/guards' end ``` -These affect legacy file-based schemas and handler auto-loading. Schemas defined via the `schema` DSL method do not use files. +These affect legacy file-based schemas, handler auto-loading, and guard auto-loading. Schemas defined via the `schema` DSL method do not use files. ## Schema Cache @@ -55,6 +58,53 @@ config.active_job.default_queue_name = :default Servus respects ActiveJob queue configuration - no Servus-specific setup needed. +## Guards Configuration + +### Default Guards + +Servus includes built-in guards (`PresenceGuard`, `TruthyGuard`, `FalseyGuard`, `StateGuard`) that are loaded by default. Disable them if you want to define your own: + +```ruby +# config/initializers/servus.rb +Servus.configure do |config| + # Default: true + config.include_default_guards = false +end +``` + +### Guard Auto-Loading + +In Rails, custom guards in `app/guards/` are automatically loaded. The Railtie eager-loads all `*_guard.rb` files from `config.guards_dir`: + +``` +app/guards/ +├── sufficient_balance_guard.rb +├── valid_amount_guard.rb +└── authorized_guard.rb +``` + +Guards define methods on `Servus::Guards` when inherited from `Servus::Guard`. The `Guard` suffix is stripped from the method name: + +```ruby +# app/guards/sufficient_balance_guard.rb +class SufficientBalanceGuard < Servus::Guard + http_status 422 + error_code 'insufficient_balance' + + message 'Insufficient balance: need %s, have %s' do + { required: amount, available: account.balance } + end + + def test(account:, amount:) + account.balance >= amount + end +end + +# Usage in services: +# enforce_sufficient_balance!(account: account, amount: 100) # throws on failure +# check_sufficient_balance?(account: account, amount: 100) # returns boolean +``` + ## Event Bus Configuration ### Strict Event Validation diff --git a/lib/generators/servus/guard/guard_generator.rb b/lib/generators/servus/guard/guard_generator.rb new file mode 100644 index 0000000..b320bd0 --- /dev/null +++ b/lib/generators/servus/guard/guard_generator.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Servus + module Generators + # Rails generator for creating Servus guards. + # + # Generates a guard class and spec file. + # + # @example Generate a guard + # rails g servus:guard sufficient_balance + # + # @example Generated files + # app/guards/sufficient_balance_guard.rb + # spec/guards/sufficient_balance_guard_spec.rb + # + # @see https://guides.rubyonrails.org/generators.html + class GuardGenerator < Rails::Generators::NamedBase + source_root File.expand_path('templates', __dir__) + + class_option :no_docs, type: :boolean, + default: false, + desc: 'Skip documentation comments in generated files' + + # Creates the guard and spec files. + # + # @return [void] + def create_guard_file + template 'guard.rb.erb', guard_path + template 'guard_spec.rb.erb', guard_spec_path + end + + private + + # Returns the path for the guard file. + # + # @return [String] guard file path + # @api private + def guard_path + File.join(Servus.config.guards_dir, "#{file_name}_guard.rb") + end + + # Returns the path for the guard spec file. + # + # @return [String] spec file path + # @api private + def guard_spec_path + File.join('spec', Servus.config.guards_dir, "#{file_name}_guard_spec.rb") + end + + # Returns the guard class name. + # + # @return [String] guard class name + # @api private + def guard_class_name + "#{class_name}Guard" + end + + # Returns the enforce method name. + # + # @return [String] enforce method name + # @api private + def enforce_method_name + "enforce_#{file_name}!" + end + + # Returns the check method name. + # + # @return [String] check method name + # @api private + def check_method_name + "check_#{file_name}?" + end + end + end +end diff --git a/lib/generators/servus/guard/templates/guard.rb.erb b/lib/generators/servus/guard/templates/guard.rb.erb new file mode 100644 index 0000000..d3847b3 --- /dev/null +++ b/lib/generators/servus/guard/templates/guard.rb.erb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +<%- unless options[:no_docs] -%> +# Guard that validates <%= file_name.humanize.downcase %> conditions. +# +# Guards are reusable validation rules that halt service execution when +# conditions aren't met. They provide declarative precondition checking +# with rich error responses. +# +# @example Basic usage in a service +# class MyService < Servus::Base +# def call +# <%= enforce_method_name %>(arg: value) +# # ... business logic ... +# success(result) +# end +# end +# +# @example Conditional check (returns boolean, doesn't halt) +# if <%= check_method_name %>(arg: value) +# # condition is met +# end +# +# @see Servus::Guard +# @see Servus::Guards +<%- end -%> +class <%= guard_class_name %> < Servus::Guard + http_status 422 + error_code '<%= file_name %>' + + message '%s <%= file_name.humanize.downcase %> validation failed' do + message_data + end + + # Tests whether the <%= file_name.humanize.downcase %> condition is met. + # + # @param kwargs [Hash] the arguments to validate + # @return [Boolean] true if validation passes + def test(**kwargs) + # TODO: Implement validation logic + # Return true if the condition is met, false otherwise + # + # Example: + # def test(account:, amount:) + # account.balance >= amount + # end + true + end + + private + + # Builds the interpolation data for the error message. + # + # @return [Hash] message interpolation data + def message_data + # TODO: Return hash of values for message interpolation + # Access guard arguments via kwargs[:argument_name] + # + # Example: + # { + # class_name: kwargs[:account].class.name, + # balance: kwargs[:account].balance, + # required: kwargs[:amount] + # } + { + class_name: 'Object' + } + end +end \ No newline at end of file diff --git a/lib/generators/servus/guard/templates/guard_spec.rb.erb b/lib/generators/servus/guard/templates/guard_spec.rb.erb new file mode 100644 index 0000000..643a28e --- /dev/null +++ b/lib/generators/servus/guard/templates/guard_spec.rb.erb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe <%= guard_class_name %> do +<%- unless options[:no_docs] -%> + # TODO: Define a test class or use a real model + # let(:test_class) do + # Struct.new(:attribute, keyword_init: true) do + # def self.name + # 'TestModel' + # end + # end + # end + +<%- end -%> + describe '#test' do +<%- unless options[:no_docs] -%> + # TODO: Add test cases for your guard logic + # + # Example: + # context 'when condition is met' do + # it 'returns true' do + # object = test_class.new(attribute: valid_value) + # guard = described_class.new(object: object) + # expect(guard.test(object: object)).to be true + # end + # end + # + # context 'when condition is not met' do + # it 'returns false' do + # object = test_class.new(attribute: invalid_value) + # guard = described_class.new(object: object) + # expect(guard.test(object: object)).to be false + # end + # end + +<%- end -%> + it 'returns true when validation passes' do + guard = described_class.new + expect(guard.test).to be true + end + end + + describe '#error' do + it 'returns a GuardError with correct metadata' do + guard = described_class.new + error = guard.error + + expect(error).to be_a(Servus::Support::Errors::GuardError) + expect(error.code).to eq('<%= file_name %>') + expect(error.http_status).to eq(422) + end + end + + describe 'method registration' do + it 'defines <%= enforce_method_name %> on Servus::Guards' do + expect(Servus::Guards.method_defined?(:<%= enforce_method_name %>)).to be true + end + + it 'defines <%= check_method_name %> on Servus::Guards' do + expect(Servus::Guards.method_defined?(:<%= check_method_name %>)).to be true + end + end +end \ No newline at end of file diff --git a/lib/servus.rb b/lib/servus.rb index 5a5b982..53c0ace 100644 --- a/lib/servus.rb +++ b/lib/servus.rb @@ -24,6 +24,7 @@ module Servus; end require_relative 'servus/support/validator' require_relative 'servus/support/errors' require_relative 'servus/support/rescuer' +require_relative 'servus/support/message_resolver' # Events require_relative 'servus/events/errors' @@ -31,6 +32,10 @@ module Servus; end require_relative 'servus/events/emitter' require_relative 'servus/event_handler' +# Guards (guards.rb loads defaults based on config) +require_relative 'servus/guard' +require_relative 'servus/guards' + # Core require_relative 'servus/version' require_relative 'servus/base' diff --git a/lib/servus/base.rb b/lib/servus/base.rb index c230d11..546758e 100644 --- a/lib/servus/base.rb +++ b/lib/servus/base.rb @@ -49,6 +49,7 @@ class Base include Servus::Support::Errors include Servus::Support::Rescuer include Servus::Events::Emitter + include Servus::Guards # Support class aliases Logger = Servus::Support::Logger @@ -185,7 +186,14 @@ def call(**args) before_call(args) instance = new(**args) - result = benchmark(**args) { instance.call } + + # Wrap execution in catch block to handle guard failures + result = catch(:guard_failure) do + benchmark(**args) { instance.call } + end + + # If result is a GuardError, a guard failed - wrap in failure Response + result = Response.new(false, nil, result) if result.is_a?(Servus::Support::Errors::GuardError) after_call(result, instance) diff --git a/lib/servus/config.rb b/lib/servus/config.rb index bdb25b7..805779e 100644 --- a/lib/servus/config.rb +++ b/lib/servus/config.rb @@ -44,14 +44,29 @@ class Config # @return [Boolean] true to validate, false to skip validation attr_accessor :strict_event_validation + # The directory where guard classes are located. + # + # Defaults to `Rails.root/app/guards` in Rails applications. + # + # @return [String] the guards directory path + attr_accessor :guards_dir + + # Whether to include the default built-in guards (EnsurePresent, EnsurePositive). + # + # @return [Boolean] true to include default guards, false to exclude them + attr_accessor :include_default_guards + # Initializes a new configuration with default values. # # @api private def initialize - @events_dir = 'app/events' - @schemas_dir = 'app/schemas' + @guards_dir = 'app/guards' + @events_dir = 'app/events' + @schemas_dir = 'app/schemas' @services_dir = 'app/services' + @strict_event_validation = true + @include_default_guards = true end # Returns the full path to a service's schema file. diff --git a/lib/servus/guard.rb b/lib/servus/guard.rb new file mode 100644 index 0000000..3ec9bb1 --- /dev/null +++ b/lib/servus/guard.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +module Servus + # Base class for guards that encapsulate validation logic with rich error responses. + # + # Guard classes define reusable validation rules with declarative metadata and + # localized error messages. They provide a clean, performant alternative to + # scattering validation logic throughout services. + # + # @example Basic guard + # class SufficientBalanceGuard < Servus::Guard + # http_status 422 + # error_code 'insufficient_balance' + # + # message "Insufficient balance: need %{required}, have %{available}" do + # { + # required: amount, + # available: account.balance + # } + # end + # + # def test(account:, amount:) + # account.balance >= amount + # end + # end + # + # @example Using a guard in a service + # class TransferService < Servus::Base + # def call + # enforce_sufficient_balance!(account: from_account, amount: amount) + # # ... perform transfer ... + # success(result) + # end + # end + # + # @see Servus::Guards + # @see Servus::Base + class Guard + class << self + # Executes a guard and throws :guard_failure with the guard's error if validation fails. + # + # This is the bang (!) execution method that halts execution on failure. + # The caller is responsible for catching the thrown error and handling it. + # + # @param guard_class [Class] the guard class to execute + # @param kwargs [Hash] keyword arguments for the guard + # @return [void] returns nil if guard passes + # @throw [:guard_failure, GuardError] if guard fails + # + # @example + # Servus::Guard.execute!(EnsurePositive, amount: 100) # passes, returns nil + # Servus::Guard.execute!(EnsurePositive, amount: -10) # throws :guard_failure + def execute!(guard_class, **) + guard = guard_class.new(**) + return if guard.test(**) + + throw(:guard_failure, guard.error) + end + + # Executes a guard and returns boolean result without throwing. + # + # This is the predicate (?) execution method for conditional checks. + # + # @param guard_class [Class] the guard class to execute + # @param kwargs [Hash] keyword arguments for the guard + # @return [Boolean] true if guard passes, false otherwise + # + # @example + # Servus::Guard.execute?(EnsurePositive, amount: 100) # => true + # Servus::Guard.execute?(EnsurePositive, amount: -10) # => false + def execute?(guard_class, **) + guard_class.new(**).test(**) + end + + # Declares the HTTP status code for API responses. + # + # @param status [Integer] the HTTP status code + # @return [void] + # + # @example + # class MyGuard < Servus::Guard + # http_status 422 + # end + def http_status(status) + @http_status_code = status + end + + # Returns the HTTP status code. + # + # @return [Integer, nil] the HTTP status code or nil if not set + attr_reader :http_status_code + + # Declares the error code for API responses. + # + # @param code [String] the error code + # @return [void] + # + # @example + # class MyGuard < Servus::Guard + # error_code 'insufficient_balance' + # end + def error_code(code) + @error_code_value = code + end + + # Returns the error code. + # + # @return [String, nil] the error code or nil if not set + attr_reader :error_code_value + + # Declares the message template and data block. + # + # The template can be a String (static or with %{} interpolation), + # a Symbol (I18n key), a Proc (dynamic), or a Hash (inline translations). + # + # The block provides data for message interpolation and is evaluated + # in the guard instance's context. + # + # @param template [String, Symbol, Proc, Hash] the message template + # @yield block that returns a Hash of interpolation data + # @return [void] + # + # @example With string template + # message "Balance must be at least %{minimum}" do + # { minimum: 100 } + # end + # + # @example With I18n key + # message :insufficient_balance do + # { required: amount, available: account.balance } + # end + def message(template, &block) + @message_template = template + @message_block = block if block_given? + end + + # Returns the message template. + # + # @return [String, Symbol, Proc, Hash, nil] the message template + attr_reader :message_template + + # Returns the message data block. + # + # @return [Proc, nil] the message data block + attr_reader :message_block + + # Hook called when a class inherits from Guard. + # + # Automatically defines guard methods on the Servus::Guards module. + # + # @param subclass [Class] the inheriting class + # @return [void] + # @api private + def inherited(subclass) + super + register_guard_methods(subclass) + end + + # Defines bang and predicate methods on Servus::Guards for the guard class. + # + # Creates two methods: + # - enforce_! (throws :guard_failure on validation failure) + # - check_? (returns boolean) + # + # @param guard_class [Class] the guard class to register + # @return [void] + # @api private + # + # @example + # # For SufficientBalanceGuard, creates: + # # enforce_sufficient_balance!(account:, amount:) + # # check_sufficient_balance?(account:, amount:) + def register_guard_methods(guard_class) + return unless guard_class.name + + base_name = derive_method_name(guard_class) + + # Define bang method (throws on failure) + Servus::Guards.define_method("enforce_#{base_name}!") do |**kwargs| + Servus::Guard.execute!(guard_class, **kwargs) + end + + # Define predicate method (returns boolean) + Servus::Guards.define_method("check_#{base_name}?") do |**kwargs| + Servus::Guard.execute?(guard_class, **kwargs) + end + end + + # Converts a guard class name to a method name. + # + # Strips the 'Guard' suffix and converts to snake_case. + # The resulting name is used with 'enforce_' and 'check_' prefixes. + # + # @param guard_class [Class] the guard class + # @return [String] the base method name (without enforce_/check_ prefix or ! or ?) + # @api private + # + # @example + # derive_method_name(SufficientBalanceGuard) # => "sufficient_balance" + # derive_method_name(PresenceGuard) # => "presence" + def derive_method_name(guard_class) + class_name = guard_class.name.split('::').last + class_name.gsub(/Guard$/, '') + .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase + end + end + + attr_reader :kwargs + + # Initializes a new guard instance with the provided arguments. + # + # @param kwargs [Hash] keyword arguments for the guard + def initialize(**kwargs) + @kwargs = kwargs + end + + # Tests whether the guard passes. + # + # Subclasses must implement this method with explicit keyword arguments + # that define the guard's contract. + # + # @return [Boolean] true if the guard passes, false otherwise + # @raise [NotImplementedError] if not implemented by subclass + # + # @example + # def test(account:, amount:) + # account.balance >= amount + # end + def test + raise NotImplementedError, "#{self.class} must implement #test" + end + + # Returns the formatted error message. + # + # Uses {Servus::Support::MessageResolver} to resolve the template and + # interpolate data from the message block. + # + # @return [String] the formatted error message + # @see Servus::Support::MessageResolver + def message + Servus::Support::MessageResolver.new( + template: self.class.message_template, + data: self.class.message_block ? instance_exec(&self.class.message_block) : {}, + i18n_scope: 'guards' + ).resolve(context: self) + end + + # Returns a GuardError instance configured with this guard's metadata. + # + # Called when a guard fails to create the error that gets thrown. + # The caller decides how to handle the error (e.g., wrap in a failure response). + # + # @return [Servus::Support::Errors::GuardError] the error instance + def error + Servus::Support::Errors::GuardError.new( + message, + code: self.class.error_code_value || 'validation_failed', + http_status: self.class.http_status_code || 422 + ) + end + + # Provides convenience access to kwargs as methods. + # + # This allows the message data block to access parameters directly + # (e.g., `amount` instead of `kwargs[:amount]`). + # + # @param method_name [Symbol] the method name + # @param args [Array] method arguments + # @param block [Proc] method block + # @return [Object] the value from kwargs + # @raise [NoMethodError] if the method is not found + # @api private + def method_missing(method_name, *args, &) + kwargs[method_name] || super + end + + # Checks if the guard responds to a method. + # + # @param method_name [Symbol] the method name + # @param include_private [Boolean] whether to include private methods + # @return [Boolean] true if the method exists or is in kwargs + # @api private + def respond_to_missing?(method_name, include_private = false) + kwargs.key?(method_name) || super + end + end +end diff --git a/lib/servus/guards.rb b/lib/servus/guards.rb new file mode 100644 index 0000000..06d8983 --- /dev/null +++ b/lib/servus/guards.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Servus + # Module providing guard functionality to Servus services. + # + # Guard methods are defined directly on this module when guard classes + # inherit from Servus::Guard. The inherited hook triggers method definition, + # so no registry or method_missing is needed. + # + # @example Using guards in a service + # class TransferService < Servus::Base + # def call + # enforce_presence!(user: user, account: account) + # enforce_state!(on: account, check: :status, is: :active) + # # ... perform transfer ... + # success(result) + # end + # end + # + # @see Servus::Guard + module Guards + # Guard methods are defined dynamically via Servus::Guard.inherited + # when guard classes are loaded. Each guard class defines: + # - enforce_! (throws :guard_failure on failure) + # - check_? (returns boolean) + + class << self + # Loads default guards if configured. + # + # Called after Guards module is defined to load built-in guards + # when Servus.config.include_default_guards is true. + # + # @return [void] + # @api private + def load_defaults + return unless Servus.config.include_default_guards + + require_relative 'guards/presence_guard' + require_relative 'guards/truthy_guard' + require_relative 'guards/falsey_guard' + require_relative 'guards/state_guard' + end + end + end +end + +# Load default guards based on configuration +Servus::Guards.load_defaults diff --git a/lib/servus/guards/falsey_guard.rb b/lib/servus/guards/falsey_guard.rb new file mode 100644 index 0000000..20ecb14 --- /dev/null +++ b/lib/servus/guards/falsey_guard.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Servus + module Guards + # Guard that ensures all specified attributes on an object are falsey. + # + # @example Single attribute + # enforce_falsey!(on: user, check: :banned) + # + # @example Multiple attributes (all must be falsey) + # enforce_falsey!(on: post, check: [:deleted, :hidden, :flagged]) + # + # @example Conditional check + # if check_falsey?(on: user, check: :suspended) + # # user is not suspended + # end + class FalseyGuard < Servus::Guard + http_status 422 + error_code 'must_be_falsey' + + message '%s.%s must be falsey (got %s)' do + message_data + end + + # Tests whether all specified attributes are falsey. + # + # @param on [Object] the object to check + # @param check [Symbol, Array] attribute(s) to verify + # @return [Boolean] true if all attributes are falsey + def test(on:, check:) + Array(check).all? { |attr| !on.public_send(attr) } + end + + private + + # Builds the interpolation data for the error message. + # + # @return [Hash] message interpolation data + def message_data + object = kwargs[:on] + failed = find_failing_attribute(object, Array(kwargs[:check])) + { + class_name: object.class.name, + failed_attr: failed, + value: object.public_send(failed).inspect + } + end + + # Finds the first attribute that fails the falsey check. + # + # @param object [Object] the object to check + # @param checks [Array] attributes to check + # @return [Symbol] the first failing attribute + def find_failing_attribute(object, checks) + checks.find { |attr| !!object.public_send(attr) } + end + end + end +end diff --git a/lib/servus/guards/presence_guard.rb b/lib/servus/guards/presence_guard.rb new file mode 100644 index 0000000..d8005e3 --- /dev/null +++ b/lib/servus/guards/presence_guard.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Servus + module Guards + # Guard that ensures all provided values are present (not nil or empty). + # + # This is a flexible guard that accepts any number of keyword arguments + # and validates that all values are present. + # + # @example Basic usage + # class MyService < Servus::Base + # def call + # enforce_presence!(user: user, account: account) + # # ... + # end + # end + # + # @example Single value + # enforce_presence!(email: email) + # + # @example Multiple values + # enforce_presence!(user: user, account: account, device: device) + # + # @example Conditional check + # if check_presence?(user: user) + # # user is present + # end + class PresenceGuard < Servus::Guard + http_status 422 + error_code 'must_be_present' + + message '%s must be present (got %s)' do + message_data + end + + # Tests whether all provided values are present. + # + # A value is considered present if it is not nil and not empty + # (for values that respond to empty?). + # + # @param values [Hash] keyword arguments to validate + # @return [Boolean] true if all values are present + def test(**values) + values.all? { |_, value| present?(value) } + end + + private + + # Builds the interpolation data for the error message. + # + # @return [Hash] message interpolation data + def message_data + failed_key, failed_value = find_failing_entry + + { + key: failed_key, + value: failed_value.inspect + } + end + + # Finds the first key-value pair that fails the presence check. + # + # @return [Array] the failing key and value + def find_failing_entry + kwargs.find { |_, value| !present?(value) } + end + + # Checks if a value is present (not nil and not empty). + # + # @param value [Object] the value to check + # @return [Boolean] true if present + def present?(value) + return false if value.nil? + return !value.empty? if value.respond_to?(:empty?) + + true + end + end + end +end diff --git a/lib/servus/guards/state_guard.rb b/lib/servus/guards/state_guard.rb new file mode 100644 index 0000000..59a46fc --- /dev/null +++ b/lib/servus/guards/state_guard.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Servus + module Guards + # Guard that ensures an attribute matches an expected value or one of several allowed values. + # + # @example Single expected value + # enforce_state!(on: order, check: :status, is: :pending) + # + # @example Multiple allowed values (any match passes) + # enforce_state!(on: account, check: :status, is: [:active, :trial]) + # + # @example Conditional check + # if check_state?(on: order, check: :status, is: :shipped) + # # order is shipped + # end + class StateGuard < Servus::Guard + http_status 422 + error_code 'invalid_state' + + message '%s.%s must be %s (got %s)' do + message_data + end + + # Tests whether the attribute matches the expected value(s). + # + # @param on [Object] the object to check + # @param check [Symbol] the attribute to verify + # @param is [Object, Array] expected value(s) - passes if attribute matches any + # @return [Boolean] true if attribute matches expected value(s) + def test(on:, check:, is:) # rubocop:disable Naming/MethodParameterName + Array(is).include?(on.public_send(check)) + end + + private + + # Builds the interpolation data for the error message. + # + # @return [Hash] message interpolation data + def message_data + object = kwargs[:on] + attr = kwargs[:check] + expected = kwargs[:is] + + { + attr: attr, + class_name: object.class.name, + actual: object.public_send(attr), + expected: format_expected(expected) + } + end + + # Formats the expected value(s) for the error message. + # + # @param expected [Object, Array] the expected value(s) + # @return [String] formatted expected value(s) + def format_expected(expected) + expected.is_a?(Array) ? "one of #{expected.join(', ')}" : expected.to_s + end + end + end +end diff --git a/lib/servus/guards/truthy_guard.rb b/lib/servus/guards/truthy_guard.rb new file mode 100644 index 0000000..2cf9d28 --- /dev/null +++ b/lib/servus/guards/truthy_guard.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Servus + module Guards + # Guard that ensures all specified attributes on an object are truthy. + # + # @example Single attribute + # enforce_truthy!(on: user, check: :active) + # + # @example Multiple attributes (all must be truthy) + # enforce_truthy!(on: user, check: [:active, :verified, :confirmed]) + # + # @example Conditional check + # if check_truthy?(on: subscription, check: :valid?) + # # subscription is valid + # end + class TruthyGuard < Servus::Guard + http_status 422 + error_code 'must_be_truthy' + + message '%s.%s must be truthy (got %s)' do + message_data + end + + # Tests whether all specified attributes are truthy. + # + # @param on [Object] the object to check + # @param check [Symbol, Array] attribute(s) to verify + # @return [Boolean] true if all attributes are truthy + def test(on:, check:) + Array(check).all? { |attr| !!on.public_send(attr) } + end + + private + + # Builds the interpolation data for the error message. + # + # @return [Hash] message interpolation data + def message_data + object = kwargs[:on] + check = kwargs[:check] + failed = find_failing_attribute(object, Array(check)) + + { + failed_attr: failed, + class_name: object.class.name, + value: object.public_send(failed).inspect + } + end + + # Finds the first attribute that fails the truthy check. + # + # @param object [Object] the object to check + # @param checks [Array] attributes to check + # @return [Symbol] the first failing attribute + def find_failing_attribute(object, checks) + checks.find { |attr| !object.public_send(attr) } + end + end + end +end diff --git a/lib/servus/helpers/controller_helpers.rb b/lib/servus/helpers/controller_helpers.rb index 3b8eb78..b71ab2e 100644 --- a/lib/servus/helpers/controller_helpers.rb +++ b/lib/servus/helpers/controller_helpers.rb @@ -14,16 +14,12 @@ module Helpers # end # # @see #run_service - # @see #render_service_object_error + # @see #render_service_error module ControllerHelpers # Executes a service and handles success/failure automatically. # - # This method runs the service with the provided parameters. On success, - # it stores the result in @result for use in views. On failure, it - # automatically calls {#render_service_object_error} with the error details. - # - # The result is always stored in the @result instance variable, making it - # available in views for rendering successful responses. + # On success, stores the result in @result for use in views. + # On failure, renders the error as JSON with the appropriate HTTP status. # # @param klass [Class] service class to execute (must inherit from {Servus::Base}) # @param params [Hash] keyword arguments to pass to the service @@ -33,69 +29,45 @@ module ControllerHelpers # class UsersController < ApplicationController # def create # run_service Services::CreateUser::Service, user_params - # # If successful, @result is available for rendering - # # If failed, error response is automatically rendered # end # end # - # @example Using @result in views - # # In your Jbuilder view (create.json.jbuilder) - # json.user do - # json.id @result.data[:user_id] - # json.email @result.data[:email] - # end - # - # @example Manual success handling - # class UsersController < ApplicationController - # def create - # run_service Services::CreateUser::Service, user_params - # return unless @result.success? - # - # # Custom success handling - # redirect_to user_path(@result.data[:user_id]) - # end - # end - # - # @see #render_service_object_error + # @see #render_service_error # @see Servus::Base.call def run_service(klass, params) @result = klass.call(**params) - render_service_object_error(@result.error.api_error) unless @result.success? + render_service_error(@result.error) unless @result.success? end # Renders a service error as a JSON response. # - # This method is called automatically by {#run_service} when a service fails, - # but can also be called manually for custom error handling. It renders the - # error's api_error hash with the appropriate HTTP status code. + # Uses error.http_status for the response status code and + # error.api_error for the response body. # # Override this method in your controller to customize error response format. # - # @param api_error [Hash] error hash with :code and :message keys from {Servus::Support::Errors::ServiceError#api_error} + # @param error [Servus::Support::Errors::ServiceError] the error to render # @return [void] # # @example Default behavior - # # Renders: { code: :not_found, message: "User not found" } + # # Renders: { error: { code: :not_found, message: "User not found" } } # # With status: 404 - # render_service_object_error(result.error.api_error) # # @example Custom error rendering - # class ApplicationController < ActionController::Base - # def render_service_object_error(api_error) - # render json: { - # error: { - # type: api_error[:code], - # details: api_error[:message], - # timestamp: Time.current - # } - # }, status: api_error[:code] - # end + # def render_service_error(error) + # render json: { + # error: { + # type: error.api_error[:code], + # details: error.message, + # timestamp: Time.current + # } + # }, status: error.http_status # end # # @see Servus::Support::Errors::ServiceError#api_error - # @see #run_service - def render_service_object_error(api_error) - render json: api_error, status: api_error[:code] + # @see Servus::Support::Errors::ServiceError#http_status + def render_service_error(error) + render json: { error: error.api_error }, status: error.http_status end end end diff --git a/lib/servus/railtie.rb b/lib/servus/railtie.rb index 25fd1cd..40a1b5f 100644 --- a/lib/servus/railtie.rb +++ b/lib/servus/railtie.rb @@ -19,14 +19,22 @@ class Railtie < Rails::Railtie end end - # Load event handlers and clear on reload + # Load guards and event handlers, clear caches on reload config.to_prepare do + # Load custom guards from guards_dir + guards_path = Rails.root.join(Servus.config.guards_dir) + if Dir.exist?(guards_path) + Dir[File.join(guards_path, '**/*_guard.rb')].each do |file| + require_dependency file + end + end + Servus::Events::Bus.clear if Rails.env.development? # Eager load all event handlers events_path = Rails.root.join(Servus.config.events_dir) - Dir[File.join(events_path, '**/*_handler.rb')].each do |handler_file| - require_dependency handler_file + Dir[File.join(events_path, '**/*_handler.rb')].each do |file| + require_dependency file end end diff --git a/lib/servus/support/errors.rb b/lib/servus/support/errors.rb index 8abc4cf..5ec3d8c 100644 --- a/lib/servus/support/errors.rb +++ b/lib/servus/support/errors.rb @@ -4,31 +4,31 @@ module Servus module Support # Contains all error classes used by Servus services. # - # All error classes inherit from {ServiceError} and provide both a human-readable - # message and an API-friendly error response via {ServiceError#api_error}. + # All error classes inherit from {ServiceError} and provide: + # - {ServiceError#http_status} for the HTTP response status + # - {ServiceError#api_error} for the JSON response body # # @see ServiceError module Errors # Base error class for all Servus service errors. # - # This class provides the foundation for all service-related errors, including: - # - Default error messages via DEFAULT_MESSAGE constant - # - API-friendly error responses via {#api_error} - # - Automatic message fallback to default if none provided + # Subclasses define their HTTP status via {#http_status} and their + # API response format via {#api_error}. # # @example Creating a custom error type - # class MyCustomError < Servus::Support::Errors::ServiceError - # DEFAULT_MESSAGE = 'Something went wrong' + # class InsufficientFundsError < Servus::Support::Errors::ServiceError + # DEFAULT_MESSAGE = 'Insufficient funds' + # + # def http_status = :unprocessable_entity # # def api_error - # { code: :custom_error, message: message } + # { code: 'insufficient_funds', message: message } # end # end # # @example Using with failure method # def call - # return failure("User not found", type: Servus::Support::Errors::NotFoundError) - # # ... + # return failure("User not found", type: NotFoundError) # end class ServiceError < StandardError attr_reader :message @@ -38,188 +38,117 @@ class ServiceError < StandardError # Creates a new service error instance. # # @param message [String, nil] custom error message (uses DEFAULT_MESSAGE if nil) - # @return [ServiceError] the error instance - # - # @example With custom message - # error = ServiceError.new("Something went wrong") - # error.message # => "Something went wrong" - # - # @example With default message - # error = ServiceError.new - # error.message # => "An error occurred" def initialize(message = nil) @message = message || self.class::DEFAULT_MESSAGE - super("#{self.class}: #{message}") + super("#{self.class}: #{@message}") end - # Returns an API-friendly error response. + # Returns the HTTP status code for this error. # - # This method formats the error for API responses, providing both a - # symbolic code and the error message. Override in subclasses to customize - # the error code for specific HTTP status codes. + # @return [Symbol] Rails-compatible status symbol + def http_status = :bad_request + + # Returns an API-friendly error response. # # @return [Hash] hash with :code and :message keys - # - # @example - # error = ServiceError.new("Failed to process") - # error.api_error # => { code: :bad_request, message: "Failed to process" } - def api_error - { code: :bad_request, message: message } - end + def api_error = { code: http_status, message: message } end - # Represents a 400 Bad Request error. - # - # Use this error when the client sends malformed or invalid request data. - # - # @example - # def call - # return failure("Invalid JSON format", type: BadRequestError) - # end + # 400 Bad Request - malformed or invalid request data. class BadRequestError < ServiceError DEFAULT_MESSAGE = 'Bad request' - # 400 error response - # @return [Hash] The error response - def api_error - { code: :bad_request, message: message } - end + def http_status = :bad_request + def api_error = { code: http_status, message: message } end - # Represents a 401 Unauthorized error for authentication failures. - # - # Use this error when authentication credentials are missing, invalid, or expired. - # - # @example - # def call - # return failure("Invalid API key", type: AuthenticationError) unless valid_api_key? - # end + # 401 Unauthorized - authentication credentials missing or invalid. class AuthenticationError < ServiceError DEFAULT_MESSAGE = 'Authentication failed' - # @return [Hash] API error response with :unauthorized code - def api_error - { code: :unauthorized, message: message } - end + def http_status = :unauthorized + def api_error = { code: http_status, message: message } end - # Represents a 401 Unauthorized error (alias for AuthenticationError). - # - # Use this error for authorization failures when credentials are valid but - # lack sufficient permissions. - # - # @example - # def call - # return failure("Access denied", type: UnauthorizedError) unless user.admin? - # end + # 401 Unauthorized (alias for AuthenticationError). class UnauthorizedError < AuthenticationError DEFAULT_MESSAGE = 'Unauthorized' end - # Represents a 403 Forbidden error. - # - # Use this error when the user is authenticated but not authorized to perform - # the requested action. - # - # @example - # def call - # return failure("Insufficient permissions", type: ForbiddenError) unless can_access? - # end + # 403 Forbidden - authenticated but not authorized. class ForbiddenError < ServiceError DEFAULT_MESSAGE = 'Forbidden' - # 403 error response - # @return [Hash] The error response - def api_error - { code: :forbidden, message: message } - end + def http_status = :forbidden + def api_error = { code: http_status, message: message } end - # Represents a 404 Not Found error. - # - # Use this error when a requested resource cannot be found. - # - # @example - # def call - # user = User.find_by(id: @user_id) - # return failure("User not found", type: NotFoundError) unless user - # end + # 404 Not Found - requested resource does not exist. class NotFoundError < ServiceError DEFAULT_MESSAGE = 'Not found' - # @return [Hash] API error response with :not_found code - def api_error - { code: :not_found, message: message } - end + def http_status = :not_found + def api_error = { code: http_status, message: message } end - # Represents a 422 Unprocessable Entity error. - # - # Use this error when the request is well-formed but contains semantic errors - # that prevent processing (e.g., business logic violations). - # - # @example - # def call - # return failure("Order already shipped", type: UnprocessableEntityError) if @order.shipped? - # end + # 422 Unprocessable Entity - semantic errors in request. class UnprocessableEntityError < ServiceError DEFAULT_MESSAGE = 'Unprocessable entity' - # @return [Hash] API error response with :unprocessable_entity code - def api_error - { code: :unprocessable_entity, message: message } - end + def http_status = :unprocessable_entity + def api_error = { code: http_status, message: message } end - # Represents validation failures (inherits 422 status). - # - # Automatically raised by the framework when schema validation fails. - # Can also be used for custom validation errors. - # - # @example - # def call - # return failure("Email format invalid", type: ValidationError) unless valid_email? - # end + # 422 Validation Error - schema or business validation failed. class ValidationError < UnprocessableEntityError DEFAULT_MESSAGE = 'Validation failed' + + def api_error = { code: http_status, message: message } end - # Represents a 500 Internal Server Error. + # Guard validation failure with custom code. # - # Use this error for unexpected server-side failures. + # Guards define their own error code and HTTP status via the DSL. # # @example - # def call - # return failure("Database connection lost", type: InternalServerError) if db_down? - # end + # GuardError.new("Amount must be positive", code: 'invalid_amount', http_status: 422) + class GuardError < ServiceError + DEFAULT_MESSAGE = 'Guard validation failed' + + # @return [String] application-specific error code + attr_reader :code + + # @return [Symbol, Integer] HTTP status code + attr_reader :http_status + + # Creates a new guard error with metadata. + # + # @param message [String, nil] error message + # @param code [String] error code for API responses (default: 'guard_failed') + # @param http_status [Symbol, Integer] HTTP status (default: :unprocessable_entity) + def initialize(message = nil, code: 'guard_failed', http_status: :unprocessable_entity) + super(message) + @code = code + @http_status = http_status + end + + def api_error = { code: code, message: message } + end + + # 500 Internal Server Error - unexpected server-side failure. class InternalServerError < ServiceError DEFAULT_MESSAGE = 'Internal server error' - # @return [Hash] API error response with :internal_server_error code - def api_error - { code: :internal_server_error, message: message } - end + def http_status = :internal_server_error + def api_error = { code: http_status, message: message } end - # Represents a 503 Service Unavailable error. - # - # Use this error when a service dependency is temporarily unavailable. - # - # @example Using with rescue_from - # class MyService < Servus::Base - # rescue_from Net::HTTPError, use: ServiceUnavailableError - # - # def call - # make_external_api_call - # end - # end + # 503 Service Unavailable - dependency temporarily unavailable. class ServiceUnavailableError < ServiceError DEFAULT_MESSAGE = 'Service unavailable' - # @return [Hash] API error response with :service_unavailable code - def api_error - { code: :service_unavailable, message: message } - end + def http_status = :service_unavailable + def api_error = { code: http_status, message: message } end end end diff --git a/lib/servus/support/message_resolver.rb b/lib/servus/support/message_resolver.rb new file mode 100644 index 0000000..ca3f4f5 --- /dev/null +++ b/lib/servus/support/message_resolver.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module Servus + module Support + # Resolves message templates with interpolation support. + # + # Handles multiple template formats: + # - String: Static or with %{key} / %s interpolation + # - Symbol: I18n key lookup with fallback + # - Hash: Inline translations keyed by locale + # - Proc: Dynamic template evaluated at runtime + # + # @example Basic string interpolation + # resolver = MessageResolver.new( + # template: 'Hello, %s!', + # data: { name: 'World' } + # ) + # resolver.resolve # => "Hello, World!" + # + # @example With I18n symbol + # resolver = MessageResolver.new(template: :greeting) + # resolver.resolve # => I18n.t('greeting') or "Greeting" fallback + # + # @example With inline translations + # resolver = MessageResolver.new( + # template: { en: 'Hello', es: 'Hola' } + # ) + # resolver.resolve # => "Hello" (or "Hola" if I18n.locale == :es) + # + # @example With proc and context + # resolver = MessageResolver.new( + # template: -> { "Balance: #{balance}" } + # ) + # resolver.resolve(context: account) # => "Balance: 100" + # + class MessageResolver + # @return [String, Symbol, Hash, Proc, nil] the message template + attr_reader :template + + # @return [Hash, Proc, nil] the interpolation data or data-providing block + attr_reader :data + + # @return [String, nil] the I18n scope prefix for symbol templates + attr_reader :i18n_scope + + # Creates a new message resolver. + # + # @param template [String, Symbol, Hash, Proc, nil] the message template + # @param data [Hash, Proc, nil] interpolation data or block returning data + # @param i18n_scope [String] prefix for I18n lookups (default: nil) + def initialize(template:, data: nil, i18n_scope: nil) + @template = template + @data = data + @i18n_scope = i18n_scope + end + + # Resolves the template to a final string. + # + # @param context [Object, nil] object for evaluating Proc templates/data blocks + # @return [String] the resolved and interpolated message + def resolve(context: nil) + resolved_template = resolve_template(context) + resolved_data = resolve_data(context) + + interpolate(resolved_template, resolved_data) + end + + private + + # Resolves the template to a string based on its type. + # + # @param context [Object, nil] evaluation context for Proc templates + # @return [String] the resolved template string + def resolve_template(context) + case template + when Symbol then resolve_i18n_template + when Proc then resolve_proc_template(context) + when Hash then resolve_locale_template + else template.to_s + end + end + + # Resolves I18n symbol template with fallback. + # + # @return [String] the translated string or humanized fallback + def resolve_i18n_template + key = build_i18n_key + fallback = humanize_symbol(template) + + if defined?(I18n) + I18n.t(key, default: fallback) + else + fallback + end + end + + # Builds the full I18n key from template and scope. + # + # @return [String, Symbol] the I18n lookup key + def build_i18n_key + return template if template.to_s.include?('.') + return template unless i18n_scope + + "#{i18n_scope}.#{template}" + end + + # Converts a symbol to a human-readable string. + # + # @param sym [Symbol] the symbol to humanize + # @return [String] humanized string + def humanize_symbol(sym) + sym.to_s.tr('_', ' ').capitalize + end + + # Evaluates a Proc template in the given context. + # + # @param context [Object, nil] the evaluation context + # @return [String] the proc result as string + def resolve_proc_template(context) + result = context ? context.instance_exec(&template) : template.call + result.to_s + end + + # Resolves a Hash template by current locale. + # + # @return [String] the localized string + def resolve_locale_template + locale = current_locale + (template[locale] || template[:en] || template.values.first).to_s + end + + # Returns the current I18n locale or :en. + # + # @return [Symbol] the current locale + def current_locale + defined?(I18n) ? I18n.locale : :en + end + + # Resolves data to a Hash for interpolation. + # + # @param context [Object, nil] evaluation context for Proc data + # @return [Hash] the interpolation data + def resolve_data(context) + case data + when Proc then context ? context.instance_exec(&data) : data.call + when Hash then data + else {} + end + end + + # Interpolates data into the template string. + # + # @param template_str [String] the template with placeholders + # @param data_hash [Hash] the interpolation data + # @return [String] the interpolated string + def interpolate(template_str, data_hash) + return template_str if data_hash.empty? + + template_str % data_hash + rescue KeyError, ArgumentError => e + warn "MessageResolver interpolation failed: #{e.message}" + template_str + end + end + end +end diff --git a/lib/servus/version.rb b/lib/servus/version.rb index 3e23ec6..eb24b4e 100644 --- a/lib/servus/version.rb +++ b/lib/servus/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Servus - VERSION = '0.1.6' + VERSION = '0.2.0' end diff --git a/spec/dummy/.ruby-version b/spec/dummy/.ruby-version new file mode 100644 index 0000000..e3cc07a --- /dev/null +++ b/spec/dummy/.ruby-version @@ -0,0 +1 @@ +ruby-3.4.4 diff --git a/spec/dummy/Gemfile b/spec/dummy/Gemfile new file mode 100644 index 0000000..389c930 --- /dev/null +++ b/spec/dummy/Gemfile @@ -0,0 +1,14 @@ +source "https://rubygems.org" + +gem "rails", "~> 8.0.4" +gem "puma", ">= 5.0" + +# Load servus from parent directory +gem "servus", path: "../.." + +# Windows does not include zoneinfo files +gem "tzinfo-data", platforms: %i[windows jruby] + +group :development, :test do + gem "debug", platforms: %i[mri windows], require: "debug/prelude" +end \ No newline at end of file diff --git a/spec/dummy/Gemfile.lock b/spec/dummy/Gemfile.lock new file mode 100644 index 0000000..336c8a7 --- /dev/null +++ b/spec/dummy/Gemfile.lock @@ -0,0 +1,225 @@ +PATH + remote: ../.. + specs: + servus (0.1.6) + active_model_serializers (~> 0.10.0) + activesupport (~> 8.0) + json-schema (~> 5) + +GEM + remote: https://rubygems.org/ + specs: + actioncable (8.0.4) + actionpack (= 8.0.4) + activesupport (= 8.0.4) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.4) + actionpack (= 8.0.4) + activejob (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) + mail (>= 2.8.0) + actionmailer (8.0.4) + actionpack (= 8.0.4) + actionview (= 8.0.4) + activejob (= 8.0.4) + activesupport (= 8.0.4) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.4) + actionview (= 8.0.4) + activesupport (= 8.0.4) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.0.4) + actionpack (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.4) + activesupport (= 8.0.4) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + active_model_serializers (0.10.15) + actionpack (>= 4.1) + activemodel (>= 4.1) + case_transform (>= 0.2) + jsonapi-renderer (>= 0.1.1.beta1, < 0.3) + activejob (8.0.4) + activesupport (= 8.0.4) + globalid (>= 0.3.6) + activemodel (8.0.4) + activesupport (= 8.0.4) + activerecord (8.0.4) + activemodel (= 8.0.4) + activesupport (= 8.0.4) + timeout (>= 0.4.0) + activestorage (8.0.4) + actionpack (= 8.0.4) + activejob (= 8.0.4) + activerecord (= 8.0.4) + activesupport (= 8.0.4) + marcel (~> 1.0) + activesupport (8.0.4) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (3.3.1) + builder (3.3.0) + case_transform (0.2) + activesupport + concurrent-ruby (1.3.5) + connection_pool (2.5.5) + crass (1.0.6) + date (3.5.0) + debug (1.11.0) + irb (~> 1.10) + reline (>= 0.3.8) + drb (2.2.3) + erb (6.0.0) + erubi (1.13.1) + globalid (1.3.0) + activesupport (>= 6.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + io-console (0.8.1) + irb (1.15.3) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json-schema (5.2.2) + addressable (~> 2.8) + bigdecimal (~> 3.1) + jsonapi-renderer (0.2.2) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + mini_mime (1.1.5) + minitest (5.26.2) + net-imap (0.5.12) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.18.10-x86_64-linux-gnu) + racc (~> 1.4) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + psych (5.2.6) + date + stringio + public_suffix (7.0.0) + puma (7.1.0) + nio4r (~> 2.0) + racc (1.8.1) + rack (3.2.4) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (8.0.4) + actioncable (= 8.0.4) + actionmailbox (= 8.0.4) + actionmailer (= 8.0.4) + actionpack (= 8.0.4) + actiontext (= 8.0.4) + actionview (= 8.0.4) + activejob (= 8.0.4) + activemodel (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) + bundler (>= 1.15.0) + railties (= 8.0.4) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.0.4) + actionpack (= 8.0.4) + activesupport (= 8.0.4) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rake (13.3.1) + rdoc (6.16.1) + erb + psych (>= 4.0.0) + tsort + reline (0.6.3) + io-console (~> 0.5) + securerandom (0.4.1) + stringio (3.1.9) + thor (1.4.0) + timeout (0.4.4) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + uri (1.1.1) + useragent (0.16.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.7.3) + +PLATFORMS + x86_64-linux-gnu + +DEPENDENCIES + debug + puma (>= 5.0) + rails (~> 8.0.4) + servus! + tzinfo-data + +BUNDLED WITH + 2.6.9 diff --git a/spec/dummy/README.md b/spec/dummy/README.md new file mode 100644 index 0000000..7db80e4 --- /dev/null +++ b/spec/dummy/README.md @@ -0,0 +1,24 @@ +# README + +This README would normally document whatever steps are necessary to get the +application up and running. + +Things you may want to cover: + +* Ruby version + +* System dependencies + +* Configuration + +* Database creation + +* Database initialization + +* How to run the test suite + +* Services (job queues, cache servers, search engines, etc.) + +* Deployment instructions + +* ... diff --git a/spec/dummy/Rakefile b/spec/dummy/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/spec/dummy/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/spec/dummy/app/controllers/application_controller.rb b/spec/dummy/app/controllers/application_controller.rb new file mode 100644 index 0000000..4ac8823 --- /dev/null +++ b/spec/dummy/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::API +end diff --git a/spec/dummy/app/controllers/concerns/.keep b/spec/dummy/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/dummy/app/models/concerns/.keep b/spec/dummy/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/dummy/bin/dev b/spec/dummy/bin/dev new file mode 100755 index 0000000..5f91c20 --- /dev/null +++ b/spec/dummy/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/spec/dummy/bin/rails b/spec/dummy/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/spec/dummy/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/spec/dummy/bin/rake b/spec/dummy/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/spec/dummy/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/spec/dummy/bin/setup b/spec/dummy/bin/setup new file mode 100755 index 0000000..2285724 --- /dev/null +++ b/spec/dummy/bin/setup @@ -0,0 +1,26 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/spec/dummy/config.ru b/spec/dummy/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/spec/dummy/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb new file mode 100644 index 0000000..747dcc4 --- /dev/null +++ b/spec/dummy/config/application.rb @@ -0,0 +1,44 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +# require "active_job/railtie" +# require "active_record/railtie" +# require "active_storage/engine" +require "action_controller/railtie" +# require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +require "action_view/railtie" +# require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Dummy + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.0 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Only loads a smaller set of middleware suitable for API only apps. + # Middleware like session, flash, cookies can be added back manually. + # Skip views, helpers and assets when generating a new resource. + config.api_only = true + end +end diff --git a/spec/dummy/config/boot.rb b/spec/dummy/config/boot.rb new file mode 100644 index 0000000..2820116 --- /dev/null +++ b/spec/dummy/config/boot.rb @@ -0,0 +1,3 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/spec/dummy/config/credentials.yml.enc b/spec/dummy/config/credentials.yml.enc new file mode 100644 index 0000000..cb6fc2f --- /dev/null +++ b/spec/dummy/config/credentials.yml.enc @@ -0,0 +1 @@ +2EjRQpzPkK+/tzexd6eHAxzQ/+7aue3BHUDdmkaT1I1XYLaQ2i643dkVDiyX57EnPRrU/8RbhXvxeXAFUSX3b/5O2LYJUTSDixS67RW+NIRigzio/z5ibAuvAx9a1xIZnt7vhQm6FZgW+91ToEHq30uwxhdTbfkbJsdB3TemgjBMJFW7YSf42zYkhYhSteT4rpzPttlnJveuKc2l+JHvf8+p0SA+RslSH5jqgsSVoM1OkF+MWVbK4tOCz6fXlGGrJ29MeEsO39axf078BcBCcK7PwbrFkB37xmGoDp6KBVLeRNiJZ+vB1JhyPSg8BDQNlsQW6iJHsMiNX6wjfyquVh69qGPJNQF9iyJm3K6mohD6WD205IySzAVIW7G+FoHMP7PbqptWxmUbbc9E+7Wz1o6NY0UIpQO++LnKe2lCbwpgbdtbe76sOpaEZTJ/cLC/Q1sCwTgHGo/YvihqbKlz3nBv8nlbl8eYl3AnDnCteol1f+H6Dt+a/sOO--I5XtGk/uuybivqAT--sbS1WaalzqOf79xFOcgXWg== \ No newline at end of file diff --git a/spec/dummy/config/environment.rb b/spec/dummy/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/spec/dummy/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/spec/dummy/config/environments/development.rb b/spec/dummy/config/environments/development.rb new file mode 100644 index 0000000..8d9dfb8 --- /dev/null +++ b/spec/dummy/config/environments/development.rb @@ -0,0 +1,40 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/spec/dummy/config/environments/production.rb b/spec/dummy/config/environments/production.rb new file mode 100644 index 0000000..01a6418 --- /dev/null +++ b/spec/dummy/config/environments/production.rb @@ -0,0 +1,58 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!) + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + # config.cache_store = :mem_cache_store + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/spec/dummy/config/environments/test.rb b/spec/dummy/config/environments/test.rb new file mode 100644 index 0000000..14bc29e --- /dev/null +++ b/spec/dummy/config/environments/test.rb @@ -0,0 +1,42 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/spec/dummy/config/initializers/cors.rb b/spec/dummy/config/initializers/cors.rb new file mode 100644 index 0000000..0c5dd99 --- /dev/null +++ b/spec/dummy/config/initializers/cors.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Avoid CORS issues when API is called from the frontend app. +# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests. + +# Read more: https://github.com/cyu/rack-cors + +# Rails.application.config.middleware.insert_before 0, Rack::Cors do +# allow do +# origins "example.com" +# +# resource "*", +# headers: :any, +# methods: [:get, :post, :put, :patch, :delete, :options, :head] +# end +# end diff --git a/spec/dummy/config/initializers/filter_parameter_logging.rb b/spec/dummy/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c0b717f --- /dev/null +++ b/spec/dummy/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/spec/dummy/config/initializers/inflections.rb b/spec/dummy/config/initializers/inflections.rb new file mode 100644 index 0000000..3860f65 --- /dev/null +++ b/spec/dummy/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/spec/dummy/config/locales/en.yml b/spec/dummy/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/spec/dummy/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/spec/dummy/config/master.key b/spec/dummy/config/master.key new file mode 100644 index 0000000..bf903de --- /dev/null +++ b/spec/dummy/config/master.key @@ -0,0 +1 @@ +aed50681df1bb78144387276381cad52 \ No newline at end of file diff --git a/spec/dummy/config/puma.rb b/spec/dummy/config/puma.rb new file mode 100644 index 0000000..787e4ce --- /dev/null +++ b/spec/dummy/config/puma.rb @@ -0,0 +1,38 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb new file mode 100644 index 0000000..a125ef0 --- /dev/null +++ b/spec/dummy/config/routes.rb @@ -0,0 +1,10 @@ +Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/spec/dummy/lib/tasks/.keep b/spec/dummy/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/dummy/log/.keep b/spec/dummy/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/dummy/log/development.log b/spec/dummy/log/development.log new file mode 100644 index 0000000..620be1c --- /dev/null +++ b/spec/dummy/log/development.log @@ -0,0 +1,16 @@ +Calling GreetUserService with args: {name: "bob"} +GreetUserService succeeded in 0.0s +Calling GreetUserService with args: {name: []} +Calling GreetUserService with args: {name: []} +Calling GreetUserService with args: {name: []} +Calling GreetUserService with args: {name: []} +Calling GreetUserService with args: {name: []} +Calling GreetUserService with args: {name: []} +Calling GreetUserService with args: {greeting: "Hello"} +GreetUserService succeeded in 0.0s +Calling GreetUserService with args: {greeting: "Hello$"} +Calling GreetUserService with args: {greeting: "Hello$"} +Calling GreetUserService with args: {greeting: "Hello$"} +GreetUserService succeeded in 0.0s +Calling GreetUserService with args: {greeting: "Hola"} +GreetUserService succeeded in 0.0s diff --git a/spec/dummy/public/robots.txt b/spec/dummy/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/spec/dummy/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/spec/dummy/script/.keep b/spec/dummy/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/dummy/tmp/.keep b/spec/dummy/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/dummy/tmp/pids/.keep b/spec/dummy/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/dummy/vendor/.keep b/spec/dummy/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/servus/base_guards_spec.rb b/spec/servus/base_guards_spec.rb new file mode 100644 index 0000000..91b0569 --- /dev/null +++ b/spec/servus/base_guards_spec.rb @@ -0,0 +1,339 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Servus::Base, 'Guards Integration' do + let(:user_class) do + Struct.new(:active, :banned, :status, keyword_init: true) do + def self.name + 'User' + end + end + end + + describe 'guard methods' do + it 'provides guard methods via method_missing' do + test_class = user_class + service_class = stub_const('GuardTestService1', Class.new(described_class) do + define_method(:initialize) { |user:| @user = user } + define_method(:test_class) { test_class } + + def call + enforce_truthy!(on: @user, check: :active) + success({ result: 'processed' }) + end + end) + + result = service_class.call(user: test_class.new(active: true)) + expect(result.success?).to be true + expect(result.data).to eq({ result: 'processed' }) + end + + it 'stops execution when guard fails' do + test_class = user_class + service_class = stub_const('GuardTestService2', Class.new(described_class) do + define_method(:initialize) { |user:| @user = user } + + def call + enforce_truthy!(on: @user, check: :active) + success({ result: 'processed' }) + end + end) + + result = service_class.call(user: test_class.new(active: false)) + expect(result.success?).to be false + expect(result.error.message).to include('must be truthy') + end + + it 'works with multiple guards' do + test_class = user_class + service_class = stub_const('GuardTestService3', Class.new(described_class) do + define_method(:initialize) do |name:, user:| + @name = name + @user = user + end + + def call + enforce_presence!(name: @name) + enforce_truthy!(on: @user, check: :active) + success({ result: 'processed' }) + end + end) + + # All guards pass + result = service_class.call(name: 'user123', user: test_class.new(active: true)) + expect(result.success?).to be true + + # First guard fails + result = service_class.call(name: nil, user: test_class.new(active: true)) + expect(result.success?).to be false + expect(result.error.message).to include('must be present') + + # Second guard fails + result = service_class.call(name: 'user123', user: test_class.new(active: false)) + expect(result.success?).to be false + expect(result.error.message).to include('must be truthy') + end + + it 'works in nested private methods' do + test_class = user_class + service_class = stub_const('GuardTestService4', Class.new(described_class) do + define_method(:initialize) do |name:, user:| + @name = name + @user = user + end + + def call + validate_inputs + success({ result: 'processed' }) + end + + private + + def validate_inputs + enforce_presence!(name: @name) + enforce_falsey!(on: @user, check: :banned) + end + end) + + # Guards pass + result = service_class.call(name: 'user123', user: test_class.new(banned: false)) + expect(result.success?).to be true + + # Guard fails in nested method + result = service_class.call(name: nil, user: test_class.new(banned: false)) + expect(result.success?).to be false + expect(result.error.message).to include('must be present') + end + + it 'works in deeply nested methods' do + test_class = user_class + service_class = stub_const('GuardTestService5', Class.new(described_class) do + define_method(:initialize) do |name:, user:| + @name = name + @user = user + end + + def call + level1 + success({ result: 'processed' }) + end + + private + + def level1 + level2 + end + + def level2 + level3 + end + + def level3 + enforce_presence!(name: @name) + enforce_state!(on: @user, check: :status, is: :active) + end + end) + + # Guards pass + result = service_class.call(name: 'user123', user: test_class.new(status: :active)) + expect(result.success?).to be true + + # Guard fails in deeply nested method + result = service_class.call(name: 'user123', user: test_class.new(status: :suspended)) + expect(result.success?).to be false + expect(result.error.message).to include('must be active') + end + + it 'raises NoMethodError for non-existent guard' do + service_class = stub_const('GuardTestService6', Class.new(described_class) do + def call + ensure_non_existent!(value: 123) + success({}) + end + end) + + expect { service_class.call }.to raise_error(NoMethodError, /ensure_non_existent!/) + end + end + + describe 'backward compatibility with raise' do + it 'still works with traditional raise approach' do + test_class = user_class + service_class = stub_const('GuardTestService7', Class.new(described_class) do + define_method(:initialize) { |user:| @user = user } + + def call + raise Servus::Support::Errors::ServiceError, 'User must be active' unless @user.active + + success({ result: 'processed' }) + end + end) + + # Success case + result = service_class.call(user: test_class.new(active: true)) + expect(result.success?).to be true + + # Failure case - raise still works + expect { service_class.call(user: test_class.new(active: false)) } + .to raise_error(Servus::Support::Errors::ServiceError, 'User must be active') + end + + it 'can mix guards and raises' do + test_class = user_class + service_class = stub_const('GuardTestService8', Class.new(described_class) do + define_method(:initialize) do |name:, user:| + @name = name + @user = user + end + + def call + # Use guard for validation + enforce_presence!(name: @name) + + # Use raise for exceptional error + raise Servus::Support::Errors::ServiceError, 'System unavailable' if system_down? + + # Use guard for business rule + enforce_truthy!(on: @user, check: :active) + + success({ result: 'processed' }) + end + + private + + def system_down? + false + end + end) + + result = service_class.call(name: 'user123', user: test_class.new(active: true)) + expect(result.success?).to be true + + # Guard failure returns failure response + result = service_class.call(name: nil, user: test_class.new(active: true)) + expect(result.success?).to be false + expect(result.error.message).to include('must be present') + end + end + + describe 'respond_to?' do + it 'returns true for registered guard methods' do + service_class = stub_const('GuardTestService9', Class.new(described_class) do + def call + success({}) + end + end) + + instance = service_class.new + expect(instance.respond_to?(:enforce_presence!)).to be true + expect(instance.respond_to?(:enforce_truthy!)).to be true + expect(instance.respond_to?(:enforce_falsey!)).to be true + expect(instance.respond_to?(:enforce_state!)).to be true + end + + it 'returns false for non-existent guard methods' do + service_class = stub_const('GuardTestService10', Class.new(described_class) do + def call + success({}) + end + end) + + instance = service_class.new + expect(instance.respond_to?(:ensure_non_existent!)).to be false + end + end + + describe 'error response structure' do + it 'returns proper failure response with guard metadata' do + test_class = user_class + service_class = stub_const('GuardTestService11', Class.new(described_class) do + define_method(:initialize) { |user:| @user = user } + + def call + enforce_truthy!(on: @user, check: :active) + success({ result: 'processed' }) + end + end) + + result = service_class.call(user: test_class.new(active: false)) + + expect(result.success?).to be false + expect(result.error).to be_a(Servus::Support::Errors::ServiceError) + expect(result.error.message).to eq('User.active must be truthy (got false)') + end + end + + describe 'predicate guard methods' do + it 'returns true when guard passes' do + test_class = user_class + service_class = stub_const('GuardTestService12', Class.new(described_class) do + define_method(:initialize) { |user:| @user = user } + + def call + if check_truthy?(on: @user, check: :active) + success({ validated: true }) + else + success({ validated: false }) + end + end + end) + + result = service_class.call(user: test_class.new(active: true)) + expect(result.success?).to be true + expect(result.data[:validated]).to be true + end + + it 'returns false when guard fails without throwing' do + test_class = user_class + service_class = stub_const('GuardTestService13', Class.new(described_class) do + define_method(:initialize) { |user:| @user = user } + + def call + if check_truthy?(on: @user, check: :active) + success({ validated: true }) + else + success({ validated: false }) + end + end + end) + + result = service_class.call(user: test_class.new(active: false)) + expect(result.success?).to be true + expect(result.data[:validated]).to be false + end + end + + describe 'state guard with multiple values' do + it 'passes when attribute matches any expected value' do + test_class = user_class + service_class = stub_const('GuardTestService14', Class.new(described_class) do + define_method(:initialize) { |user:| @user = user } + + def call + enforce_state!(on: @user, check: :status, is: %i[active trial]) + success({ result: 'processed' }) + end + end) + + result = service_class.call(user: test_class.new(status: :trial)) + expect(result.success?).to be true + end + + it 'fails when attribute matches none of expected values' do + test_class = user_class + service_class = stub_const('GuardTestService15', Class.new(described_class) do + define_method(:initialize) { |user:| @user = user } + + def call + enforce_state!(on: @user, check: :status, is: %i[active trial]) + success({ result: 'processed' }) + end + end) + + result = service_class.call(user: test_class.new(status: :suspended)) + expect(result.success?).to be false + expect(result.error.message).to include('one of') + end + end +end diff --git a/spec/servus/config_spec.rb b/spec/servus/config_spec.rb index 748fc01..05ef534 100644 --- a/spec/servus/config_spec.rb +++ b/spec/servus/config_spec.rb @@ -16,4 +16,34 @@ Servus.config.strict_event_validation = true end end + + describe '#guards_dir' do + let(:default_dir) { 'app/guards' } + + it 'defaults to app/guards' do + expect(Servus.config.guards_dir).to eq(default_dir) + end + + it 'can be customized' do + Servus.config.guards_dir = 'lib/guards' + expect(Servus.config.guards_dir).to eq('lib/guards') + end + + after { Servus.config.guards_dir = default_dir } + end + + describe '#include_default_guards' do + let(:default_value) { true } + + it 'defaults to true' do + expect(Servus.config.include_default_guards).to be(default_value) + end + + it 'can be disabled' do + Servus.config.include_default_guards = false + expect(Servus.config.include_default_guards).to be false + end + + after { Servus.config.include_default_guards = default_value } + end end diff --git a/spec/servus/guard_spec.rb b/spec/servus/guard_spec.rb new file mode 100644 index 0000000..2b430ef --- /dev/null +++ b/spec/servus/guard_spec.rb @@ -0,0 +1,327 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Servus::Guard do + describe '.http_status' do + it 'declares the HTTP status code' do + guard_class = Class.new(described_class) do + http_status 422 + end + + expect(guard_class.http_status_code).to eq(422) + end + end + + describe '.error_code' do + it 'declares the error code' do + guard_class = Class.new(described_class) do + error_code 'insufficient_balance' + end + + expect(guard_class.error_code_value).to eq('insufficient_balance') + end + end + + describe '.message' do + it 'declares a static message template' do + guard_class = Class.new(described_class) do + message 'Value must be positive' + end + + expect(guard_class.message_template).to eq('Value must be positive') + end + + it 'declares a message template with interpolation block' do + guard_class = Class.new(described_class) do + message 'Balance: %s' do + { amount: 100 } + end + end + + expect(guard_class.message_template).to eq('Balance: %s') + expect(guard_class.message_block).to be_a(Proc) + end + + it 'supports Symbol for I18n keys' do + guard_class = Class.new(described_class) do + message :insufficient_balance + end + + expect(guard_class.message_template).to eq(:insufficient_balance) + end + + it 'supports Hash for inline translations' do + guard_class = Class.new(described_class) do + message(en: 'English', es: 'Español') + end + + expect(guard_class.message_template).to be_a(Hash) + expect(guard_class.message_template[:en]).to eq('English') + end + + it 'supports Proc for dynamic templates' do + guard_class = Class.new(described_class) do + message -> { 'Dynamic message' } + end + + expect(guard_class.message_template).to be_a(Proc) + end + end + + describe '.inherited' do + it 'defines bang method on Servus::Guards when class has a name' do + guard_class = Class.new(described_class) do + def test(**) = true + end + stub_const('SufficientBalance', guard_class) + + # Manually trigger method definition (inherited hook fired before stub_const) + described_class.send(:register_guard_methods, guard_class) + + expect(Servus::Guards.method_defined?(:enforce_sufficient_balance!)).to be true + end + + it 'defines predicate method on Servus::Guards when class has a name' do + guard_class = Class.new(described_class) do + def test(**) = true + end + stub_const('ValidAmount', guard_class) + + # Manually trigger method definition + described_class.send(:register_guard_methods, guard_class) + + expect(Servus::Guards.method_defined?(:check_valid_amount?)).to be true + end + + it 'skips method definition for anonymous classes' do + # Anonymous classes should not crash or define methods + guard_class = Class.new(described_class) + + # Should not define any new methods (no name to derive method from) + expect { guard_class }.not_to raise_error + end + end + + describe '#initialize' do + it 'stores kwargs' do + guard_class = Class.new(described_class) + guard = guard_class.new(account: 'test', amount: 100) + + expect(guard.kwargs).to eq({ account: 'test', amount: 100 }) + end + end + + describe '#test' do + it 'raises NotImplementedError if not overridden' do + guard_class = Class.new(described_class) + guard = guard_class.new + + expect { guard.test }.to raise_error(NotImplementedError, /must implement #test/) + end + + it 'can be overridden with explicit parameters' do + guard_class = Class.new(described_class) do + def test(amount:) + amount > 0 + end + end + + guard = guard_class.new(amount: 100) + expect(guard.test(amount: 100)).to be true + + guard = guard_class.new(amount: -10) + expect(guard.test(amount: -10)).to be false + end + end + + describe '#message' do + context 'with static string template' do + it 'returns the static message' do + guard_class = Class.new(described_class) do + message 'Static error message' + end + + guard = guard_class.new + expect(guard.message).to eq('Static error message') + end + end + + context 'with string template and interpolation' do + it 'interpolates data from the message block' do + account_double = double(balance: 100) + + guard_class = Class.new(described_class) do + message 'Insufficient balance: need %s, have %s' do + { + required: amount, + available: account.balance + } + end + + def test(account:, amount:) + account.balance >= amount + end + end + + guard = guard_class.new(account: account_double, amount: 150) + expect(guard.message).to eq('Insufficient balance: need 150, have 100') + end + end + + context 'with Symbol template (I18n)' do + it 'resolves I18n key when I18n is available' do + skip 'I18n not available in test environment' unless defined?(I18n) + + allow(I18n).to receive(:t).with('guards.insufficient_balance', any_args) + .and_return('Saldo insuficiente') + + guard_class = Class.new(described_class) do + message :insufficient_balance + end + + guard = guard_class.new + expect(guard.message).to eq('Saldo insuficiente') + end + + it 'falls back to humanized key when I18n is not available' do + guard_class = Class.new(described_class) do + message :insufficient_balance + end + + guard = guard_class.new + expect(guard.message).to eq('Insufficient balance') + end + end + + context 'with Hash template (inline translations)' do + it 'returns the message for the current locale' do + skip 'I18n not available in test environment' unless defined?(I18n) + + allow(I18n).to receive(:locale).and_return(:es) + + guard_class = Class.new(described_class) do + message(en: 'English message', es: 'Mensaje en español') + end + + guard = guard_class.new + expect(guard.message).to eq('Mensaje en español') + end + + it 'falls back to :en when locale not found' do + skip 'I18n not available in test environment' unless defined?(I18n) + + allow(I18n).to receive(:locale).and_return(:fr) + + guard_class = Class.new(described_class) do + message(en: 'English message', es: 'Mensaje en español') + end + + guard = guard_class.new + expect(guard.message).to eq('English message') + end + + it 'returns first value when I18n is not available' do + guard_class = Class.new(described_class) do + message(en: 'English message', es: 'Mensaje en español') + end + + guard = guard_class.new + expect(guard.message).to eq('English message') + end + end + + context 'with Proc template (dynamic)' do + it 'evaluates the proc at runtime' do + guard_class = Class.new(described_class) do + message -> { "Dynamic: #{limit_type}" } + + def test(limit_type:) + @limit_type = limit_type + true + end + + attr_reader :limit_type + end + + guard = guard_class.new(limit_type: 'daily') + guard.test(limit_type: 'daily') + expect(guard.message).to eq('Dynamic: daily') + end + end + end + + describe '#method_missing' do + it 'provides access to kwargs as methods' do + guard_class = Class.new(described_class) do + message 'Amount: %s' do + { value: amount } + end + end + + guard = guard_class.new(amount: 100) + expect(guard.message).to eq('Amount: 100') + end + + it 'raises NoMethodError for non-existent keys' do + guard_class = Class.new(described_class) + guard = guard_class.new(amount: 100) + + expect { guard.non_existent_method }.to raise_error(NoMethodError) + end + end + + describe '#respond_to_missing?' do + it 'returns true for kwargs keys' do + guard_class = Class.new(described_class) + guard = guard_class.new(amount: 100) + + expect(guard.respond_to?(:amount)).to be true + end + + it 'returns false for non-existent keys' do + guard_class = Class.new(described_class) + guard = guard_class.new(amount: 100) + + expect(guard.respond_to?(:non_existent)).to be false + end + end + + describe 'complete guard example' do + it 'works end-to-end with all features' do + account_double = double(balance: 100) + + guard_class = Class.new(described_class) do + http_status 422 + error_code 'insufficient_balance' + + message 'Insufficient balance: need %s, have %s' do + { + required: amount, + available: account.balance + } + end + + def test(account:, amount:) + account.balance >= amount + end + end + + # Test passing case + guard = guard_class.new(account: account_double, amount: 50) + expect(guard.test(account: account_double, amount: 50)).to be true + + # Test failing case + guard = guard_class.new(account: account_double, amount: 150) + expect(guard.test(account: account_double, amount: 150)).to be false + expect(guard.message).to eq('Insufficient balance: need 150, have 100') + + # Test error generation + error = guard.error + expect(error).to be_a(Servus::Support::Errors::GuardError) + expect(error.code).to eq('insufficient_balance') + expect(error.message).to eq('Insufficient balance: need 150, have 100') + expect(error.http_status).to eq(422) + end + end +end diff --git a/spec/servus/guards/falsey_spec.rb b/spec/servus/guards/falsey_spec.rb new file mode 100644 index 0000000..3211cbf --- /dev/null +++ b/spec/servus/guards/falsey_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Guards::FalseyGuard do + let(:test_class) do + Struct.new(:banned, :deleted, :flagged, keyword_init: true) do + def self.name + 'TestPost' + end + end + end + + describe '#test' do + context 'with single attribute' do + it 'passes when attribute is false' do + object = test_class.new(banned: false) + guard = described_class.new(on: object, check: :banned) + + expect(guard.test(on: object, check: :banned)).to be true + end + + it 'passes when attribute is nil' do + object = test_class.new(banned: nil) + guard = described_class.new(on: object, check: :banned) + + expect(guard.test(on: object, check: :banned)).to be true + end + + it 'fails when attribute is true' do + object = test_class.new(banned: true) + guard = described_class.new(on: object, check: :banned) + + expect(guard.test(on: object, check: :banned)).to be false + end + + it 'fails when attribute is a truthy value' do + object = test_class.new(banned: 'yes') + guard = described_class.new(on: object, check: :banned) + + expect(guard.test(on: object, check: :banned)).to be false + end + end + + context 'with multiple attributes' do + it 'passes when all attributes are falsey' do + object = test_class.new(banned: false, deleted: nil, flagged: false) + guard = described_class.new(on: object, check: %i[banned deleted flagged]) + + expect(guard.test(on: object, check: %i[banned deleted flagged])).to be true + end + + it 'fails when any attribute is truthy' do + object = test_class.new(banned: false, deleted: true, flagged: false) + guard = described_class.new(on: object, check: %i[banned deleted flagged]) + + expect(guard.test(on: object, check: %i[banned deleted flagged])).to be false + end + + it 'fails when first attribute is truthy' do + object = test_class.new(banned: true, deleted: false, flagged: false) + guard = described_class.new(on: object, check: %i[banned deleted flagged]) + + expect(guard.test(on: object, check: %i[banned deleted flagged])).to be false + end + end + end + + describe '#error' do + it 'returns a GuardError with correct metadata' do + object = test_class.new(banned: true) + guard = described_class.new(on: object, check: :banned) + error = guard.error + + expect(error).to be_a(Servus::Support::Errors::GuardError) + expect(error.code).to eq('must_be_falsey') + expect(error.http_status).to eq(422) + end + + it 'includes class name in error message' do + object = test_class.new(banned: true) + guard = described_class.new(on: object, check: :banned) + + expect(guard.error.message).to include('TestPost') + end + + it 'includes attribute name in error message' do + object = test_class.new(banned: true) + guard = described_class.new(on: object, check: :banned) + + expect(guard.error.message).to include('banned') + end + + it 'includes actual value in error message' do + object = test_class.new(banned: true) + guard = described_class.new(on: object, check: :banned) + + expect(guard.error.message).to include('true') + end + + context 'with multiple attributes' do + it 'shows first failing attribute in message' do + object = test_class.new(banned: false, deleted: true, flagged: true) + guard = described_class.new(on: object, check: %i[banned deleted flagged]) + + expect(guard.error.message).to include('deleted') + expect(guard.error.message).not_to include('flagged') + end + end + end + + describe 'method registration' do + it 'defines enforce_falsey! on Servus::Guards' do + expect(Servus::Guards.method_defined?(:enforce_falsey!)).to be true + end + + it 'defines check_falsey? on Servus::Guards' do + expect(Servus::Guards.method_defined?(:check_falsey?)).to be true + end + end +end diff --git a/spec/servus/guards/presence_spec.rb b/spec/servus/guards/presence_spec.rb new file mode 100644 index 0000000..9509162 --- /dev/null +++ b/spec/servus/guards/presence_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Servus::Guards::PresenceGuard do + describe '#test' do + context 'with all present values' do + it 'returns true for non-nil values' do + guard = described_class.new(user: 'user123', account: 'account456') + expect(guard.test(user: 'user123', account: 'account456')).to be true + end + + it 'returns true for non-empty strings' do + guard = described_class.new(email: 'test@example.com') + expect(guard.test(email: 'test@example.com')).to be true + end + + it 'returns true for non-empty arrays' do + guard = described_class.new(items: [1, 2, 3]) + expect(guard.test(items: [1, 2, 3])).to be true + end + + it 'returns true for non-empty hashes' do + guard = described_class.new(data: { key: 'value' }) + expect(guard.test(data: { key: 'value' })).to be true + end + + it 'returns true for numeric values including zero' do + guard = described_class.new(amount: 0) + expect(guard.test(amount: 0)).to be true + end + + it 'returns true for false boolean' do + guard = described_class.new(flag: false) + expect(guard.test(flag: false)).to be true + end + end + + context 'with nil values' do + it 'returns false for nil' do + guard = described_class.new(user: nil) + expect(guard.test(user: nil)).to be false + end + + it 'returns false when any value is nil' do + guard = described_class.new(user: 'user123', account: nil) + expect(guard.test(user: 'user123', account: nil)).to be false + end + end + + context 'with empty values' do + it 'returns false for empty string' do + guard = described_class.new(email: '') + expect(guard.test(email: '')).to be false + end + + it 'returns false for empty array' do + guard = described_class.new(items: []) + expect(guard.test(items: [])).to be false + end + + it 'returns false for empty hash' do + guard = described_class.new(data: {}) + expect(guard.test(data: {})).to be false + end + + it 'returns false when any value is empty' do + guard = described_class.new(user: 'user123', email: '') + expect(guard.test(user: 'user123', email: '')).to be false + end + end + + context 'with multiple values' do + it 'returns true when all values are present' do + guard = described_class.new( + user: 'user123', + account: 'account456', + device: 'device789' + ) + expect(guard.test( + user: 'user123', + account: 'account456', + device: 'device789' + )).to be true + end + + it 'returns false when any value is missing' do + guard = described_class.new( + user: 'user123', + account: nil, + device: 'device789' + ) + expect(guard.test( + user: 'user123', + account: nil, + device: 'device789' + )).to be false + end + end + end + + describe '#error' do + it 'returns GuardError with correct metadata' do + guard = described_class.new(user: nil) + error = guard.error + + expect(error).to be_a(Servus::Support::Errors::GuardError) + expect(error.code).to eq('must_be_present') + expect(error.http_status).to eq(422) + end + + it 'shows first failing key with nil value' do + guard = described_class.new(user: nil, account: nil) + expect(guard.error.message).to eq('user must be present (got nil)') + end + + it 'shows first failing key with empty string value' do + guard = described_class.new(email: '') + expect(guard.error.message).to eq('email must be present (got "")') + end + + it 'shows first failing key with empty array value' do + guard = described_class.new(items: []) + expect(guard.error.message).to eq('items must be present (got [])') + end + + it 'shows first failing key when multiple fail' do + guard = described_class.new(user: 'present', account: nil, device: '') + expect(guard.error.message).to eq('account must be present (got nil)') + end + end + + describe 'metadata' do + it 'has correct HTTP status' do + expect(described_class.http_status_code).to eq(422) + end + + it 'has correct error code' do + expect(described_class.error_code_value).to eq('must_be_present') + end + end + + describe 'method definition' do + it 'defines enforce_presence! on Servus::Guards' do + expect(Servus::Guards.method_defined?(:enforce_presence!)).to be true + end + + it 'defines check_presence? on Servus::Guards' do + expect(Servus::Guards.method_defined?(:check_presence?)).to be true + end + end +end diff --git a/spec/servus/guards/state_spec.rb b/spec/servus/guards/state_spec.rb new file mode 100644 index 0000000..867246c --- /dev/null +++ b/spec/servus/guards/state_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Guards::StateGuard do + let(:test_class) do + Struct.new(:status, :state, :tier, keyword_init: true) do + def self.name + 'TestOrder' + end + end + end + + describe '#test' do + context 'with single expected value' do + it 'passes when attribute matches expected value' do + object = test_class.new(status: :pending) + guard = described_class.new(on: object, check: :status, is: :pending) + + expect(guard.test(on: object, check: :status, is: :pending)).to be true + end + + it 'fails when attribute does not match expected value' do + object = test_class.new(status: :shipped) + guard = described_class.new(on: object, check: :status, is: :pending) + + expect(guard.test(on: object, check: :status, is: :pending)).to be false + end + + it 'passes with string values' do + object = test_class.new(status: 'active') + guard = described_class.new(on: object, check: :status, is: 'active') + + expect(guard.test(on: object, check: :status, is: 'active')).to be true + end + + it 'fails when symbol does not match string' do + object = test_class.new(status: :active) + guard = described_class.new(on: object, check: :status, is: 'active') + + expect(guard.test(on: object, check: :status, is: 'active')).to be false + end + end + + context 'with multiple expected values' do + it 'passes when attribute matches any expected value' do + object = test_class.new(status: :trial) + guard = described_class.new(on: object, check: :status, is: %i[active trial]) + + expect(guard.test(on: object, check: :status, is: %i[active trial])).to be true + end + + it 'passes when attribute matches first expected value' do + object = test_class.new(status: :active) + guard = described_class.new(on: object, check: :status, is: %i[active trial]) + + expect(guard.test(on: object, check: :status, is: %i[active trial])).to be true + end + + it 'fails when attribute matches none of expected values' do + object = test_class.new(status: :suspended) + guard = described_class.new(on: object, check: :status, is: %i[active trial]) + + expect(guard.test(on: object, check: :status, is: %i[active trial])).to be false + end + end + end + + describe '#error' do + context 'with single expected value' do + it 'returns a GuardError with correct metadata' do + object = test_class.new(status: :shipped) + guard = described_class.new(on: object, check: :status, is: :pending) + error = guard.error + + expect(error).to be_a(Servus::Support::Errors::GuardError) + expect(error.code).to eq('invalid_state') + expect(error.http_status).to eq(422) + end + + it 'includes class name in error message' do + object = test_class.new(status: :shipped) + guard = described_class.new(on: object, check: :status, is: :pending) + + expect(guard.error.message).to include('TestOrder') + end + + it 'includes attribute name in error message' do + object = test_class.new(status: :shipped) + guard = described_class.new(on: object, check: :status, is: :pending) + + expect(guard.error.message).to include('status') + end + + it 'includes expected value in error message' do + object = test_class.new(status: :shipped) + guard = described_class.new(on: object, check: :status, is: :pending) + + expect(guard.error.message).to include('pending') + end + + it 'includes actual value in error message' do + object = test_class.new(status: :shipped) + guard = described_class.new(on: object, check: :status, is: :pending) + + expect(guard.error.message).to include('shipped') + end + end + + context 'with multiple expected values' do + it 'shows "one of" in error message' do + object = test_class.new(status: :suspended) + guard = described_class.new(on: object, check: :status, is: %i[active trial]) + + expect(guard.error.message).to include('one of') + end + + it 'lists all expected values in error message' do + object = test_class.new(status: :suspended) + guard = described_class.new(on: object, check: :status, is: %i[active trial]) + + expect(guard.error.message).to include('active') + expect(guard.error.message).to include('trial') + end + + it 'shows actual value in error message' do + object = test_class.new(status: :suspended) + guard = described_class.new(on: object, check: :status, is: %i[active trial]) + + expect(guard.error.message).to include('suspended') + end + end + end + + describe 'method registration' do + it 'defines enforce_state! on Servus::Guards' do + expect(Servus::Guards.method_defined?(:enforce_state!)).to be true + end + + it 'defines check_state? on Servus::Guards' do + expect(Servus::Guards.method_defined?(:check_state?)).to be true + end + end +end diff --git a/spec/servus/guards/truthy_spec.rb b/spec/servus/guards/truthy_spec.rb new file mode 100644 index 0000000..1685855 --- /dev/null +++ b/spec/servus/guards/truthy_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Guards::TruthyGuard do + let(:test_class) do + Struct.new(:active, :verified, :confirmed, keyword_init: true) do + def self.name + 'TestUser' + end + end + end + + describe '#test' do + context 'with single attribute' do + it 'passes when attribute is truthy' do + object = test_class.new(active: true) + guard = described_class.new(on: object, check: :active) + + expect(guard.test(on: object, check: :active)).to be true + end + + it 'fails when attribute is false' do + object = test_class.new(active: false) + guard = described_class.new(on: object, check: :active) + + expect(guard.test(on: object, check: :active)).to be false + end + + it 'fails when attribute is nil' do + object = test_class.new(active: nil) + guard = described_class.new(on: object, check: :active) + + expect(guard.test(on: object, check: :active)).to be false + end + + it 'passes when attribute is a truthy value' do + object = test_class.new(active: 'yes') + guard = described_class.new(on: object, check: :active) + + expect(guard.test(on: object, check: :active)).to be true + end + end + + context 'with multiple attributes' do + it 'passes when all attributes are truthy' do + object = test_class.new(active: true, verified: true, confirmed: true) + guard = described_class.new(on: object, check: %i[active verified confirmed]) + + expect(guard.test(on: object, check: %i[active verified confirmed])).to be true + end + + it 'fails when any attribute is falsey' do + object = test_class.new(active: true, verified: false, confirmed: true) + guard = described_class.new(on: object, check: %i[active verified confirmed]) + + expect(guard.test(on: object, check: %i[active verified confirmed])).to be false + end + + it 'fails when first attribute is falsey' do + object = test_class.new(active: false, verified: true, confirmed: true) + guard = described_class.new(on: object, check: %i[active verified confirmed]) + + expect(guard.test(on: object, check: %i[active verified confirmed])).to be false + end + end + end + + describe '#error' do + it 'returns a GuardError with correct metadata' do + object = test_class.new(active: false) + guard = described_class.new(on: object, check: :active) + error = guard.error + + expect(error).to be_a(Servus::Support::Errors::GuardError) + expect(error.code).to eq('must_be_truthy') + expect(error.http_status).to eq(422) + end + + it 'includes class name in error message' do + object = test_class.new(active: false) + guard = described_class.new(on: object, check: :active) + + expect(guard.error.message).to include('TestUser') + end + + it 'includes attribute name in error message' do + object = test_class.new(active: false) + guard = described_class.new(on: object, check: :active) + + expect(guard.error.message).to include('active') + end + + it 'includes actual value in error message' do + object = test_class.new(active: false) + guard = described_class.new(on: object, check: :active) + + expect(guard.error.message).to include('false') + end + + context 'with multiple attributes' do + it 'shows first failing attribute in message' do + object = test_class.new(active: true, verified: false, confirmed: true) + guard = described_class.new(on: object, check: %i[active verified confirmed]) + + expect(guard.error.message).to include('verified') + expect(guard.error.message).not_to include('confirmed') + end + end + end + + describe 'method registration' do + it 'defines enforce_truthy! on Servus::Guards' do + expect(Servus::Guards.method_defined?(:enforce_truthy!)).to be true + end + + it 'defines check_truthy? on Servus::Guards' do + expect(Servus::Guards.method_defined?(:check_truthy?)).to be true + end + end +end diff --git a/spec/servus/helpers/controller_helpers_spec.rb b/spec/servus/helpers/controller_helpers_spec.rb index 0895a4e..b7d7fca 100644 --- a/spec/servus/helpers/controller_helpers_spec.rb +++ b/spec/servus/helpers/controller_helpers_spec.rb @@ -20,6 +20,10 @@ def render(options) end error_class = Class.new(StandardError) do + def http_status + :bad_request + end + def api_error { code: :bad_request, message: 'Bad Request' } end @@ -62,18 +66,18 @@ def self.call(**) expect(controller.instance_variable_get(:@result).data).to be_nil expect(controller.instance_variable_get(:@rendered)).to eq( - json: { code: :bad_request, message: 'Bad Request' }, + json: { error: { code: :bad_request, message: 'Bad Request' } }, status: :bad_request ) end end - describe '#render_service_object_error' do - it 'renders error' do - controller.render_service_object_error(error_class.new.api_error) + describe '#render_service_error' do + it 'renders error with http_status and api_error body' do + controller.render_service_error(error_class.new) expect(controller.instance_variable_get(:@rendered)).to eq( - json: { code: :bad_request, message: 'Bad Request' }, + json: { error: { code: :bad_request, message: 'Bad Request' } }, status: :bad_request ) end diff --git a/spec/servus/support/message_resolver_spec.rb b/spec/servus/support/message_resolver_spec.rb new file mode 100644 index 0000000..9652c3f --- /dev/null +++ b/spec/servus/support/message_resolver_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +RSpec.describe Servus::Support::MessageResolver do + describe '#resolve' do + context 'with string template' do + it 'returns static string as-is' do + resolver = described_class.new(template: 'Hello, World!') + expect(resolver.resolve).to eq('Hello, World!') + end + + it 'interpolates named placeholders' do + resolver = described_class.new( + template: 'Hello, %s!', + data: { name: 'Alice' } + ) + expect(resolver.resolve).to eq('Hello, Alice!') + end + + it 'interpolates multiple placeholders' do + resolver = described_class.new( + template: '%s, %s! You have %d messages.', + data: { greeting: 'Hello', name: 'Bob', count: 5 } + ) + expect(resolver.resolve).to eq('Hello, Bob! You have 5 messages.') + end + + it 'handles empty data gracefully' do + resolver = described_class.new(template: 'No placeholders here') + expect(resolver.resolve).to eq('No placeholders here') + end + end + + context 'with symbol template (I18n)' do + context 'without I18n defined' do + before do + hide_const('I18n') if defined?(I18n) + end + + it 'returns humanized symbol as fallback' do + resolver = described_class.new(template: :insufficient_balance) + expect(resolver.resolve).to eq('Insufficient balance') + end + + it 'handles underscores in symbol' do + resolver = described_class.new(template: :invalid_amount_error) + expect(resolver.resolve).to eq('Invalid amount error') + end + end + + context 'with I18n defined' do + before do + stub_const('I18n', Class.new do + def self.t(key, default:) + if key == 'guards.insufficient_balance' + 'Balance is too low' + else + default + end + end + + def self.locale + :en + end + end) + end + + it 'looks up key with scope prefix' do + resolver = described_class.new( + template: :insufficient_balance, + i18n_scope: 'guards' + ) + expect(resolver.resolve).to eq('Balance is too low') + end + + it 'falls back to humanized symbol when translation missing' do + resolver = described_class.new( + template: :unknown_key, + i18n_scope: 'guards' + ) + expect(resolver.resolve).to eq('Unknown key') + end + + it 'uses full key when it contains a dot' do + resolver = described_class.new(template: :'errors.custom.message') + expect(resolver.resolve).to eq('Errors.custom.message') + end + end + end + + context 'with hash template (inline translations)' do + let(:template) do + { en: 'Hello', es: 'Hola', fr: 'Bonjour' } + end + + context 'without I18n defined' do + before do + hide_const('I18n') if defined?(I18n) + end + + it 'defaults to :en locale' do + resolver = described_class.new(template: template) + expect(resolver.resolve).to eq('Hello') + end + + it 'falls back to :en when current locale missing' do + resolver = described_class.new(template: { de: 'Hallo', en: 'Hello' }) + expect(resolver.resolve).to eq('Hello') + end + + it 'falls back to first value when :en missing' do + resolver = described_class.new(template: { de: 'Hallo', fr: 'Bonjour' }) + expect(resolver.resolve).to eq('Hallo') + end + end + + context 'with I18n defined' do + it 'uses current locale' do + stub_const('I18n', Class.new do + def self.locale + :es + end + end) + + resolver = described_class.new(template: template) + expect(resolver.resolve).to eq('Hola') + end + end + end + + context 'with proc template' do + it 'evaluates proc without context' do + resolver = described_class.new(template: -> { 'Dynamic message' }) + expect(resolver.resolve).to eq('Dynamic message') + end + + it 'evaluates proc in context' do + context_object = Struct.new(:name).new('Charlie') + resolver = described_class.new(template: -> { "Hello, #{name}!" }) + expect(resolver.resolve(context: context_object)).to eq('Hello, Charlie!') + end + + it 'converts proc result to string' do + resolver = described_class.new(template: -> { 42 }) + expect(resolver.resolve).to eq('42') + end + end + + context 'with nil template' do + it 'returns empty string' do + resolver = described_class.new(template: nil) + expect(resolver.resolve).to eq('') + end + end + + context 'with data as proc' do + it 'evaluates data proc without context' do + resolver = described_class.new( + template: 'Count: %d', + data: -> { { count: 10 } } + ) + expect(resolver.resolve).to eq('Count: 10') + end + + it 'evaluates data proc in context' do + context_object = Struct.new(:items).new([1, 2, 3]) + resolver = described_class.new( + template: 'Items: %d', + data: -> { { count: items.length } } + ) + expect(resolver.resolve(context: context_object)).to eq('Items: 3') + end + end + + context 'error handling' do + it 'returns template when interpolation fails due to missing key' do + resolver = described_class.new( + template: 'Hello, %s!', + data: { wrong_key: 'Alice' } + ) + expect { resolver.resolve }.to output(/interpolation failed/).to_stderr + expect(resolver.resolve).to eq('Hello, %s!') + end + end + end +end