diff --git a/README.md b/README.md index 72d2bed..471e61e 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,15 @@ rails generate light_services:install ```ruby class GreetService < Light::Services::Base # Arguments - arg :name - arg :age + arg :name, type: String + arg :age, type: Integer # Steps step :build_message step :send_message # Outputs - output :message + output :message, type: String private @@ -58,14 +58,14 @@ class GreetService < Light::Services::Base end ``` -## Advanced Example +## Advanced Example (with dry-types) ```ruby class User::ResetPassword < Light::Services::Base - # Arguments - arg :user, type: User, optional: true - arg :email, type: String, optional: true - arg :send_email, type: [TrueClass, FalseClass], default: true + # Arguments with dry-types for advanced validation + arg :user, type: Types::Instance(User).optional, optional: true + arg :email, type: Types::String.constrained(format: /@/), optional: true + arg :send_email, type: Types::Bool, default: true # Steps step :validate @@ -75,8 +75,8 @@ class User::ResetPassword < Light::Services::Base step :send_reset_email, if: :send_email? # Outputs - output :user, type: User - output :reset_token, type: String + output :user, type: Types::Instance(User) + output :reset_token, type: Types::Strict::String private diff --git a/docs/arguments.md b/docs/arguments.md index a7ec1c1..c1f787a 100644 --- a/docs/arguments.md +++ b/docs/arguments.md @@ -47,6 +47,10 @@ class HappyBirthdayService < ApplicationService end ``` +{% hint style="info" %} +You can enforce type definitions for all arguments by enabling `require_type` in your configuration. See [Configuration](configuration.md#enforcing-type-definitions) for details. +{% endhint %} + You can specify multiple allowed types using an array. ```ruby diff --git a/docs/configuration.md b/docs/configuration.md index 8b38e46..2e45592 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,6 +22,9 @@ Light::Services.configure do |config| config.break_on_warning = false # Stop step execution when a warning is added config.raise_on_warning = false # Raise an exception when a warning is added config.rollback_on_warning = false # Rollback transaction when a warning is added + + # Type enforcement + config.require_type = true # Require type option for all arguments and outputs end ``` @@ -38,6 +41,7 @@ end | `break_on_warning` | `false` | Stops executing remaining steps when a warning is added | | `raise_on_warning` | `false` | Raises `Light::Services::Error` when a warning is added | | `rollback_on_warning` | `false` | Rolls back the transaction when a warning is added | +| `require_type` | `true` | Raises `Light::Services::MissingTypeError` when arguments or outputs are defined without a `type` option | ## Per-Service Configuration @@ -160,6 +164,36 @@ end When `use_transactions` is `true`, Light Services uses `ActiveRecord::Base.transaction(requires_new: true)` to create savepoints, allowing nested services to rollback independently. {% endhint %} +## Enforcing Type Definitions + +By default, `require_type` is enabled to ensure all arguments and outputs have explicit type definitions. This helps catch missing types early and improves code quality. + +Defining an argument or output without a type will raise `Light::Services::MissingTypeError`: + +```ruby +class MyService < ApplicationService + arg :name # Raises MissingTypeError! + arg :name, type: String # OK + + output :result # Raises MissingTypeError! + output :result, type: Hash # OK +end +``` + +### Disabling Type Enforcement + +For legacy projects or gradual migration, you can disable type enforcement: + +```ruby +Light::Services.configure do |config| + config.require_type = false +end +``` + +{% hint style="warning" %} +Disabling `require_type` is not recommended. Explicit types improve code quality and catch errors early. +{% endhint %} + ## What's Next? Now that you understand configuration, learn about the core concepts: diff --git a/docs/outputs.md b/docs/outputs.md index ccef329..37d5891 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -70,6 +70,10 @@ class AI::Chat < ApplicationService end ``` +{% hint style="info" %} +You can enforce type definitions for all outputs by enabling `require_type` in your configuration. See [Configuration](configuration.md#enforcing-type-definitions) for details. +{% endhint %} + You can specify multiple allowed types using an array. ```ruby diff --git a/lib/light/services/config.rb b/lib/light/services/config.rb index 751db55..7c8d501 100644 --- a/lib/light/services/config.rb +++ b/lib/light/services/config.rb @@ -25,6 +25,8 @@ class Config break_on_warning: false, raise_on_warning: false, rollback_on_warning: false, + + require_type: true, }.freeze attr_accessor(*DEFAULTS.keys) diff --git a/lib/light/services/dsl/arguments_dsl.rb b/lib/light/services/dsl/arguments_dsl.rb index 8cc0c1d..7e2d1fd 100644 --- a/lib/light/services/dsl/arguments_dsl.rb +++ b/lib/light/services/dsl/arguments_dsl.rb @@ -41,6 +41,7 @@ def arg(name, opts = {}) Validation.validate_symbol_name!(name, :argument, self) Validation.validate_reserved_name!(name, :argument, self) Validation.validate_name_conflicts!(name, :argument, self) + Validation.validate_type_presence!(name, :argument, self, opts) own_arguments[name] = Settings::Field.new(name, self, opts.merge(field_type: FieldTypes::ARGUMENT)) @arguments = nil # Clear memoized arguments since we're modifying them diff --git a/lib/light/services/dsl/outputs_dsl.rb b/lib/light/services/dsl/outputs_dsl.rb index 95fcff9..fff9b6b 100644 --- a/lib/light/services/dsl/outputs_dsl.rb +++ b/lib/light/services/dsl/outputs_dsl.rb @@ -37,6 +37,7 @@ def output(name, opts = {}) Validation.validate_symbol_name!(name, :output, self) Validation.validate_reserved_name!(name, :output, self) Validation.validate_name_conflicts!(name, :output, self) + Validation.validate_type_presence!(name, :output, self, opts) own_outputs[name] = Settings::Field.new(name, self, opts.merge(field_type: FieldTypes::OUTPUT)) @outputs = nil # Clear memoized outputs since we're modifying them diff --git a/lib/light/services/dsl/validation.rb b/lib/light/services/dsl/validation.rb index 2c75b84..eb7a9a3 100644 --- a/lib/light/services/dsl/validation.rb +++ b/lib/light/services/dsl/validation.rb @@ -126,6 +126,21 @@ def self.has_step?(name_sym, service_class) # Check inherited steps (service_class.superclass.respond_to?(:steps) && service_class.superclass.steps.key?(name_sym)) end + + # Validate that a type is provided when require_type config is enabled + # + # @param name [Symbol] the field name + # @param field_type [Symbol] the type of field (:argument, :output) + # @param service_class [Class] the service class for error messages + # @param opts [Hash] the options hash to check for :type key + def self.validate_type_presence!(name, field_type, service_class, opts) + return unless Light::Services.config.require_type + return if opts.key?(:type) + + raise Light::Services::MissingTypeError, + "#{field_type.to_s.capitalize} `#{name}` in #{service_class} " \ + "must have a type specified (require_type is enabled)" + end end end end diff --git a/lib/light/services/exceptions.rb b/lib/light/services/exceptions.rb index 85c3b28..650cfa9 100644 --- a/lib/light/services/exceptions.rb +++ b/lib/light/services/exceptions.rb @@ -7,6 +7,7 @@ class ArgTypeError < Error; end class ReservedNameError < Error; end class InvalidNameError < Error; end class NoStepsError < Error; end + class MissingTypeError < Error; end # Backwards compatibility aliases (deprecated) NoStepError = Error diff --git a/spec/light/services/config_spec.rb b/spec/light/services/config_spec.rb index 65e9e6f..5dddd56 100644 --- a/spec/light/services/config_spec.rb +++ b/spec/light/services/config_spec.rb @@ -111,7 +111,15 @@ break_on_warning: false, raise_on_warning: false, rollback_on_warning: false, + require_type: true, }) end end + + describe "require_type accessor" do + it "has accessor for require_type" do + config.require_type = true + expect(config.require_type).to be(true) + end + end end diff --git a/spec/light/services/require_type_spec.rb b/spec/light/services/require_type_spec.rb new file mode 100644 index 0000000..1eb345f --- /dev/null +++ b/spec/light/services/require_type_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +RSpec.describe "require_type configuration" do # rubocop:disable RSpec/DescribeClass + around do |example| + original_value = Light::Services.config.require_type + example.run + Light::Services.config.require_type = original_value + end + + describe "when require_type is disabled" do + before do + Light::Services.config.require_type = false + end + + it "allows arguments without type" do + expect do + Class.new(Light::Services::Base) do + arg :name + end + end.not_to raise_error + end + + it "allows outputs without type" do + expect do + Class.new(Light::Services::Base) do + output :result + end + end.not_to raise_error + end + + it "allows arguments with type" do + expect do + Class.new(Light::Services::Base) do + arg :name, type: String + end + end.not_to raise_error + end + + it "allows outputs with type" do + expect do + Class.new(Light::Services::Base) do + output :result, type: Hash + end + end.not_to raise_error + end + end + + describe "when require_type is enabled (default)" do + before do + Light::Services.config.require_type = true + end + + describe "arguments" do + it "raises MissingTypeError when argument has no type" do + expect do + Class.new(Light::Services::Base) do + arg :name + end + end.to raise_error( + Light::Services::MissingTypeError, + /Argument `name` in .* must have a type specified \(require_type is enabled\)/, + ) + end + + it "raises MissingTypeError when argument only has optional" do + expect do + Class.new(Light::Services::Base) do + arg :name, optional: true + end + end.to raise_error( + Light::Services::MissingTypeError, + /Argument `name` in .* must have a type specified/, + ) + end + + it "raises MissingTypeError when argument only has default" do + expect do + Class.new(Light::Services::Base) do + arg :name, default: "test" + end + end.to raise_error( + Light::Services::MissingTypeError, + /Argument `name` in .* must have a type specified/, + ) + end + + it "allows arguments with type" do + expect do + Class.new(Light::Services::Base) do + arg :name, type: String + end + end.not_to raise_error + end + + it "allows arguments with type and other options" do + expect do + Class.new(Light::Services::Base) do + arg :name, type: String, optional: true, default: "default" + end + end.not_to raise_error + end + + it "allows arguments with multiple types" do + expect do + Class.new(Light::Services::Base) do + arg :id, type: [String, Integer] + end + end.not_to raise_error + end + end + + describe "outputs" do + it "raises MissingTypeError when output has no type" do + expect do + Class.new(Light::Services::Base) do + output :result + end + end.to raise_error( + Light::Services::MissingTypeError, + /Output `result` in .* must have a type specified \(require_type is enabled\)/, + ) + end + + it "raises MissingTypeError when output only has optional" do + expect do + Class.new(Light::Services::Base) do + output :result, optional: true + end + end.to raise_error( + Light::Services::MissingTypeError, + /Output `result` in .* must have a type specified/, + ) + end + + it "raises MissingTypeError when output only has default" do + expect do + Class.new(Light::Services::Base) do + output :result, default: {} + end + end.to raise_error( + Light::Services::MissingTypeError, + /Output `result` in .* must have a type specified/, + ) + end + + it "allows outputs with type" do + expect do + Class.new(Light::Services::Base) do + output :result, type: Hash + end + end.not_to raise_error + end + + it "allows outputs with type and other options" do + expect do + Class.new(Light::Services::Base) do + output :result, type: Hash, optional: true, default: -> { {} } + end + end.not_to raise_error + end + + it "allows outputs with multiple types" do + expect do + Class.new(Light::Services::Base) do + output :data, type: [Hash, Array] + end + end.not_to raise_error + end + end + + describe "mixed arguments and outputs" do + it "raises error for first field without type" do + expect do + Class.new(Light::Services::Base) do + arg :input, type: String + arg :name # This should raise + output :result, type: Hash + end + end.to raise_error( + Light::Services::MissingTypeError, + /Argument `name` in .* must have a type specified/, + ) + end + + it "allows service with all typed fields" do + expect do + Class.new(Light::Services::Base) do + arg :input, type: String + arg :count, type: Integer, optional: true, default: 10 + output :result, type: Hash + output :status, type: String + end + end.not_to raise_error + end + end + end + + describe "global configuration via configure block" do + it "can be set via configure block" do + Light::Services.configure do |config| + config.require_type = true + end + + expect(Light::Services.config.require_type).to be(true) + end + end +end