Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Unreleased

## 2.1.5 - 2025-11-12
## 2.2.0 - 2025-11-12

* `argument` can now optionally take a `type` option, which will be checked against the provided value when calling the object.
* adds optional `returns` statement to typecheck the returned type of the object
* Fixes simplecov setup and adds code coverage.

## 2.1.5 - 2025-11-12

* adds simplecov

Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
injectable (2.1.5)
injectable (2.2.0)

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -94,7 +94,7 @@ CHECKSUMS
coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
injectable (2.1.5)
injectable (2.2.0)
json (2.13.0) sha256=a4bdf1ce8db5617ec6c59e021db4a398e54b57b335e1fa417ac7badc3fb7c1a0
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
Expand Down
173 changes: 171 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,18 +332,187 @@ argument :browser, default: 'Unknown'

If you don't pass the `:default` option the argument will be required.

### Type checking for arguments

You can optionally declare a `:type` for an argument to enable runtime type
validation. Examples:

```rb
argument :user, type: User
argument :values, type: Array
argument :report, type: Hash
```

Rules:

- `:type` is optional — if omitted, no type checking is performed.
- If `:type` is provided and you also provide a `:default`, the default must
be either `nil` or an instance of the declared type. Otherwise an
ArgumentError is raised at declaration time.
- At runtime, when `#call` is invoked, any non-nil argument value passed will
be validated against the declared type. If the value is not an instance of
the declared type, an ArgumentError is raised with a helpful message.

Example error message:

```text
ArgumentError: argument user passed is a Integer, needs to be a User
```

Notes:

- Passing `nil` is allowed when the default is `nil` or when you explicitly
pass `nil` at call time. If you'd like stricter behavior (for example,
forbidding nil), we can add an `allow_nil: false` option in a follow-up.

## Return type checking

You can declare the expected return type of a service with the `returns`
macro. This enables runtime validation of the value returned by `#call`.

Example:

```ruby
class FindUser
include Injectable

argument :id
returns User, nullable: false

def call
user_query.call(id)
end
end
```

Behavior:

- If `nullable: false` and the service returns `nil`, an ArgumentError is raised.
- If the service returns a non-nil value that is not an instance of the declared
type, an ArgumentError is raised.
- If `nullable: true`, `nil` is accepted as a valid return value.

Example error messages:

```text
ArgumentError: return value is nil, expected User
ArgumentError: return value is a Integer, needs to be a User
```

Examples

Single return type

```ruby
class FindUser
include Injectable

argument :id
returns User, nullable: false

def call
user_query.call(id)
end
end

FindUser.call(id: 1)
```

Array (collection) return type

```ruby
class ListUserIds
include Injectable

returns Array, of: Integer, nullable: false, allow_nils: false

def call
User.pluck(:id) # returns an Array of Integer
end
end

ListUserIds.call
```

ActiveRecord/Relation-like collection

```ruby
class AllUsers
include Injectable

returns Enumerable, of: User, nullable: false, allow_nils: false

def call
User.where(active: true) # returns an ActiveRecord::Relation of User
end
end

AllUsers.call
```

## Development

Advanced return examples

```ruby
# Nullable single return (allowed nil)
class MaybeUser
include Injectable

returns User, nullable: true

def call
nil
end
end

MaybeUser.call # => nil is allowed
```

```ruby
# Collection with nil elements allowed
class UsersWithPossibleNils
include Injectable

returns Array, of: User, nullable: false, allow_nils: true

def call
[User.new, nil, User.new]
end
end

UsersWithPossibleNils.call # => allowed because allow_nils: true
```

```ruby
# Collection with nil elements NOT allowed (will raise)
class UsersNoNils
include Injectable

returns Array, of: User, nullable: false, allow_nils: false

def call
[User.new, nil]
end
end

# Runtime error raised when calling:
# ArgumentError: collection contains nil but allow_nils is false
```

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).

Please consider configuring [https://editorconfig.org/] on your favourite IDE/editor, so basic file formatting is consistent and avoids cross-platform issues. Some editors require [a plugin](https://editorconfig.org/#download), meanwhile others have it [pre-installed](https://editorconfig.org/#pre-installed).
Please consider configuring [EditorConfig](https://editorconfig.org/) on your favourite IDE/editor, so basic file formatting is consistent and avoids cross-platform issues. Some editors require [a plugin](https://editorconfig.org/#download), meanwhile others have it [pre-installed](https://editorconfig.org/#pre-installed).

## Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/rubiconmd/injectable. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
Bug reports and pull requests are welcome on GitHub at [injectablerb/injectable](https://github.com/injectablerb/injectable).

### Code of Conduct

This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.

## License

Expand Down
5 changes: 5 additions & 0 deletions lib/injectable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
require 'injectable/instance_methods'
require 'injectable/missing_dependencies_exception'
require 'injectable/method_already_exists_exception'
require 'injectable/validators/argument_declaration'
require 'injectable/validators/argument_type'
require 'injectable/validators/collection_returns'
require 'injectable/validators/single_returns'
require 'injectable/validators/returns'

# Convert your class into an injectable service
#
Expand Down
49 changes: 47 additions & 2 deletions lib/injectable/class_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ def self.extended(base)
base.class_eval do
simple_class_attribute :dependencies,
:call_arguments,
:initialize_arguments
:initialize_arguments,
:return_spec

self.dependencies = DependenciesGraph.new(namespace: base)
self.initialize_arguments = {}
Expand Down Expand Up @@ -120,6 +121,8 @@ def dependency(name, options = {}, &block)
# ```
#
# Every argument is required unless given an optional default value
# Option :type can be provided to enforce runtime type checking when the
# service is called. Example: `argument :user, type: User`.
# @param name Name of the argument
# @option options :default The default value of the argument
# @example
Expand All @@ -131,12 +134,36 @@ def dependency(name, options = {}, &block)
# argument :team_id, default: 1
# # => def call(team_id: 1)
# # => @team_id = team_id
# # => end
# # => end)
def argument(name, options = {})
Injectable::Validators::ArgumentDeclaration.validate!(name, options[:type], options[:default])

call_arguments[name] = options
attr_accessor name
end

# Declare the expected return type for the service's `#call` method.
# Example:
# returns User, nullable: false
# If a return type is declared, the value returned by `#call` will be
# validated at runtime. If the value is nil and `allow_nil` is false an
# ArgumentError will be raised. If the value is non-nil and not an
# instance of the declared type, an ArgumentError will be raised.
# New returns API
# Single object:
# returns(User, nullable: true/false)
# Collection:
# returns(CollectionClass, of: User, nullable: true/false, allow_nils: true/false)
def returns(type, of: nil, nullable: false, allow_nils: false)
spec = { nullable: nullable, allow_nils: allow_nils }

self.return_spec = if of.nil?
spec.merge(initialize_single_return(type))
else
spec.merge(initialize_collection_return(type, of))
end
end

def initialize_with(name, options = {})
initialize_arguments[name] = options
attr_accessor name
Expand All @@ -157,5 +184,23 @@ def required_call_arguments
def find_required_arguments(hash)
hash.reject { |_arg, options| options.key?(:default) }.keys
end

def initialize_single_return(type)
raise(ArgumentError, ':type for returns must be a Class or Module') unless type.is_a?(Module)

{ kind: :single, type: type }
end

def initialize_collection_return(collection_class, element_class)
raise(ArgumentError, ':of for returns must be a Class or Module') unless element_class.is_a?(Module)
raise(ArgumentError, ':collection for returns must be a Class or Module') unless collection_class.is_a?(Module)

unless collection_class.instance_methods.include?(:each)
raise(ArgumentError,
"#{collection_class} is not a collection-like class (must respond to :each) when specifying :of")
end

{ kind: :collection, collection: collection_class, elem: element_class }
end
end
end
12 changes: 10 additions & 2 deletions lib/injectable/instance_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ def call(args = {})
check_call_definition!
check_missing_arguments!(self.class.required_call_arguments, args)
variables_for!(self.class.call_arguments, args)
super()
result = super()

Injectable::Validators::Returns.validate!(self.class.return_spec, result)

result
end

private
Expand All @@ -37,7 +41,11 @@ def check_missing_arguments!(expected, args)

def variables_for!(subject, args)
subject.each do |arg, options|
instance_variable_set("@#{arg}", args.fetch(arg) { options[:default] })
value = args.fetch(arg) { options[:default] }

Injectable::Validators::ArgumentType.validate!(arg, options[:type], value)

instance_variable_set("@#{arg}", value)
end
end

Expand Down
33 changes: 33 additions & 0 deletions lib/injectable/validators/argument_declaration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module Injectable
module Validators
# Validate argument declaration options and normalize them.
# @name => name of the argument
# @type => declared class for the argument
# @default => declaration of default value for the argument
#
# @return nil if all is good
# raises ArgumentError when problems are found
class ArgumentDeclaration
class << self
def validate!(name, type = nil, default = nil)
return unless type

raise(ArgumentError, wrong_type_message(name)) unless type.is_a?(Module)
return unless default

raise ArgumentError, bad_default_type(name, default.class, type) unless default.is_a?(type)
end

private

def wrong_type_message(name)
":type for argument #{name} must be a Class or Module"
end

def bad_default_type(name, default_class, type)
"default for argument #{name} is a #{default_class}, needs to be a #{type}"
end
end
end
end
end
Loading