Repository: pawurb/smart_init Branch: main Commit: 915c50550a7f Files: 14 Total size: 19.8 KB Directory structure: gitextract_5m36g2_7/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib/ │ ├── smart_init/ │ │ ├── main.rb │ │ └── version.rb │ └── smart_init.rb ├── smart_init.gemspec └── test/ ├── test_hash_api.rb ├── test_hash_public_accessors.rb ├── test_hash_public_mixed.rb └── test_hash_public_readers.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: Ruby CI on: push: branches: [ main ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: ruby-version: ['3.4', '3.3', '3.2', '3.1', '3.0', '2.7'] steps: - uses: actions/checkout@v3 - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby-version }} - name: Setup dependencies run: | gem install bundler -v 2.4.22 sudo apt-get update --allow-releaseinfo-change bundle config set --local path 'vendor/bundle' bundle install - name: Run tests run: | bundle exec rake test ================================================ FILE: .gitignore ================================================ Gemfile.lock .ruby-version pkg/ *.gem coverage/ .byebug_history ================================================ FILE: Gemfile ================================================ source "https://rubygems.org" gemspec ================================================ FILE: LICENSE.txt ================================================ Copyright (c) 2021 Paweł Urbanek MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Smart Init - Simple service objects in Ruby [![Gem Version](https://badge.fury.io/rb/smart_init.svg)](https://badge.fury.io/rb/smart_init) [![GH Actions](https://github.com/pawurb/smart_init/actions/workflows/ci.yml/badge.svg)](https://github.com/pawurb/smart_init/actions) Do you find yourself writing a lot of boilerplate code like this? ```ruby def initialize(network_provider, api_token) @network_provider = network_provider @api_token = api_token end def self.call(network_provider, api_token) new(network_provider, api_token).call end ``` This gem provides a simple DSL for getting rid of it. It offers an alternative to using `Struct.new` which does not check for number of parameters provided in initializer, exposes getters and instantiates unecessary class instances. **Smart Init** offers a unified API convention for stateless service objects, accepting values in initializer and exposing one public class method `call` which instantiates new objects and accepts arguments passed to initializer. Check out [this blog post](https://pawelurbanek.com/2018/02/12/ruby-on-rails-service-objects-and-testing-in-isolation/) for my reasoning behind this approach to service object pattern. ## Installation In your Gemfile ```ruby gem 'smart_init' ``` ## API You can use it either by extending a module: ```ruby require 'smart_init' class ApiClient extend SmartInit initialize_with :network_provider, :api_token end ``` or subclassing: ```ruby class ApiClient < SmartInit::Base initialize_with :network_provider, :api_token end ``` Now you can just: ```ruby object = ApiClient.new(network_provider: Faraday.new, api_token: 'secret_token') # , @api_token="secret_token"> ``` If you omit a required attribute an `ArgumentError` will be thrown: ```ruby client = ApiClient.new(network_provider: Faraday.new) # ArgumentError (missing required attribute api_token) ``` ### Making the object callable You can use the `is_callable` method: ```ruby class Calculator < SmartInit::Base initialize_with :data is_callable def call ... result end end Calculator.call(data: data) => result ``` Optionally you can customize a callable method name: ```ruby class Routine < SmartInit::Base initialize_with :params is_callable method_name: :run! def run! ... end end Routine.run!(params: params) ``` ### Default arguments You can use hash based, default argument values: ```ruby class Adder < SmartInit::Base initialize_with :num_a, num_b: 2 is_callable def call num_a + num_b end end Adder.call(num_a: 2) => 4 Adder.call(num_a: 2, num_b: 3) => 5 ``` ### Readers access Contrary to using Struct, by default the reader methods are not publicly exposed: ```ruby client = ApiClient.new(network_provider: Faraday.new, api_token: 'secret_token') client.api_token => # NoMethodError (private method `api_token' called for #) ``` Optionally you can make all or subset of readers public using the `public_readers` config option. It accepts `true` or an array of method names as an argument. ```ruby class PublicApiClient < SmartInit::Base initialize_with :network_provider, :api_token, public_readers: true end client = PublicApiClient.new(network_provider: Faraday.new, api_token: 'secret_token') client.network_provider => # client.api_token => 'secret_token' ``` ```ruby class SemiPublicApiClient < SmartInit::Base initialize_with :network_provider, :api_token, public_readers: [:network_provider] end client = SemiPublicApiClient.new(network_provider: Faraday.new, api_token: 'secret_token') client.network_provider => # client.api_token => 'secret_token' => # NoMethodError (private method `api_token' called for #) ``` ### Accessors access Similarly, this is how it would look if you tried to use a writer method: ```ruby client = ApiClient.new(network_provider: Faraday.new, api_token: 'secret_token') client.api_token = 'new_token' => # NoMethodError (private method `api_token=' called for #) ``` Optionally you can make all or subset of accessors public using the `public_accessors` config option. It accepts `true` or an array of method names as an argument. This will provide both reader and writer methods publicly. ```ruby class PublicApiClient < SmartInit::Base initialize_with :network_provider, :api_token, public_accessors: true end client = PublicApiClient.new(network_provider: Faraday.new, api_token: 'secret_token') client.network_provider => # client.network_provider = Typhoeus::Request.new(...) => # client.api_token => 'secret_token' client.api_token = 'new_token' => 'new_token' ``` ```ruby class SemiPublicApiClient < SmartInit::Base initialize_with :network_provider, :api_token, public_accessors: [:network_provider] end client = SemiPublicApiClient.new(network_provider: Faraday.new, api_token: 'secret_token') client.network_provider => # client.network_provider = Typhoeus::Request.new(...) => # client.api_token => # NoMethodError (private method `api_token' called for #) client.api_token = 'new_token' => # NoMethodError (undefined method `api_token=' called for #) ``` Finally, you can mix them together like this: ```ruby class PublicReadersSemiPublicAccessorsApiClient < SmartInit::Base initialize_with :network_provider, :api_token, :timeout, public_readers: true, public_accessors: [:network_provider] end client = PublicReadersSemiPublicAccessorsApiClient.new( network_provider: Faraday.new, api_token: 'secret_token', timeout_length: 100 ) client.network_provider => # client.network_provider = Typhoeus::Request.new(...) => # client.api_token => 'secret_token' client.api_token = 'new_token' => # NoMethodError (undefined method `api_token=' called for #) client.timeout_length => 100 client.timeout_length = 150 => # NoMethodError (undefined method `timeout_length=' called for #) ``` ```ruby class SemiPublicReadersSemiPublicAccessorsApiClient < SmartInit::Base initialize_with :network_provider, :api_token, :timeout, public_readers: [:timeout], public_accessors: [:network_provider] end client = SemiPublicReadersSemiPublicAccessorsApiClient.new( network_provider: Faraday.new, api_token: 'secret_token', timeout_length: 100 ) client.network_provider => # client.network_provider = Typhoeus::Request.new(...) => # client.api_token => # NoMethodError (private method `api_token' called for #) client.api_token = 'new_token' => # NoMethodError (undefined method `api_token=' called for #) client.timeout_length => 100 client.timeout_length = 150 => # NoMethodError (undefined method `timeout_length=' called for #) ``` ================================================ FILE: Rakefile ================================================ require "bundler/gem_tasks" require "rake/testtask" Rake::TestTask.new do |t| t.libs << "test" end desc "Run tests" task :default => :test ================================================ FILE: lib/smart_init/main.rb ================================================ # frozen_string_literal: true module SmartInit def is_callable(opts = {}) method_name = if name_from_opts = opts[:method_name] name_from_opts.to_sym else :call end define_singleton_method method_name do |**parameters| new(**parameters).public_send(method_name) end end def initialize_with_hash(*required_attrs, **default_values_and_opts) public_readers = default_values_and_opts.delete(:public_readers) || [] public_accessors = default_values_and_opts.delete(:public_accessors) || [] if public_readers == true || public_accessors == true public_readers = required_attrs + default_values_and_opts.keys public_accessors = required_attrs + default_values_and_opts.keys if public_accessors == true else public_readers += public_accessors end define_method :initialize do |**parameters| required_attrs.each do |required_attr| unless parameters.has_key?(required_attr) raise ArgumentError, "missing required attribute #{required_attr}" end end parameters.keys.each do |param| if !(required_attrs + [:public_readers, :public_accessors]).include?(param) && !default_values_and_opts.keys.include?(param) raise ArgumentError, "invalid attribute '#{param}'" end end (required_attrs + default_values_and_opts.keys).each do |attribute| value = if parameters.has_key?(attribute) parameters.fetch(attribute) else default_values_and_opts[attribute] end instance_variable_set("@#{attribute}", value) end end instance_eval do all_readers = (required_attrs + default_values_and_opts.keys) attr_reader(*all_readers) (all_readers - public_readers).each do |reader| private reader end attr_writer(*public_accessors) end end alias initialize_with initialize_with_hash end class SmartInit::Base extend SmartInit end ================================================ FILE: lib/smart_init/version.rb ================================================ # frozen_string_literal: true module SmartInit VERSION = "5.1.0" end ================================================ FILE: lib/smart_init.rb ================================================ # frozen_string_literal: true require "smart_init/main" module SmartInit end ================================================ FILE: smart_init.gemspec ================================================ # -*- encoding: utf-8 -*- lib = File.expand_path("../lib", __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "smart_init/version" Gem::Specification.new do |s| s.name = "smart_init" s.version = SmartInit::VERSION s.authors = ["pawurb"] s.email = ["p.urbanek89@gmail.com"] s.summary = %q{ Remove Ruby initializer boilerplate code } s.description = %q{ A smart DSL for ruby initializers boilerplate } s.homepage = "http://github.com/pawurb/smart_init" s.files = `git ls-files`.split("\n") s.test_files = s.files.grep(%r{^(test)/}) s.require_paths = ["lib"] s.license = "MIT" s.add_development_dependency "rake" s.add_development_dependency "byebug" s.add_development_dependency "dbg-rb" s.add_development_dependency "test-unit" s.add_development_dependency "rufo" if s.respond_to?(:metadata=) s.metadata = { "rubygems_mfa_required" => "true" } end end ================================================ FILE: test/test_hash_api.rb ================================================ # frozen_string_literal: true require "test/unit" require_relative "../lib/smart_init/main" class TestService extend SmartInit initialize_with :attribute_1, :attribute_2 is_callable def call [attribute_1, attribute_2] end end class TestServiceDefaults extend SmartInit initialize_with :attribute_1, attribute_2: "default_value_2", attribute_3: "default_value_3" is_callable def call [attribute_1, attribute_2, attribute_3] end end class TestServiceAllDefaults extend SmartInit initialize_with attribute_1: "default_value_1", attribute_2: "default_value_2", attribute_3: "default_value_3" is_callable def call [attribute_1, attribute_2, attribute_3] end end class TestHashIntegerDefaults extend SmartInit initialize_with :attribute_1, attribute_2: 2 is_callable def call [attribute_1, attribute_2] end end class HashApiTest < Test::Unit::TestCase def test_keywords assert_equal TestService.call(attribute_1: "a", attribute_2: "b"), ["a", "b"] assert_raise ArgumentError do TestService.new( attribute_1: "a", ) end end def test_keywords_defaults assert_equal TestServiceDefaults.call(attribute_1: "a"), ["a", "default_value_2", "default_value_3"] assert_equal TestServiceDefaults.call(attribute_1: "a", attribute_2: "b"), ["a", "b", "default_value_3"] end def test_private_readers service = TestServiceDefaults.new(attribute_1: "a") error = assert_raise NoMethodError do service.attribute_2 end assert_match("private method", error.message) end def test_integer_defaults assert_equal TestHashIntegerDefaults.call(attribute_1: 1), [1, 2] end def test_missing_attributes assert_raise ArgumentError do TestService.call(attribute_1: "a", invalid_attribute: "b") end end def test_invalid_input assert_raise ArgumentError do TestService.call("invalid_input here") end end def test_all_defaults assert_equal TestServiceAllDefaults.call, ["default_value_1", "default_value_2", "default_value_3"] end def test_falsey_values assert_equal TestService.call(attribute_1: false, attribute_2: nil), [false, nil] end def test_invalid_keywords assert_raise ArgumentError do TestService.call(attribute_1: "a", attribute_2: "b", invalid_attribute: "c") end end end ================================================ FILE: test/test_hash_public_accessors.rb ================================================ # frozen_string_literal: true require "test/unit" require_relative "../lib/smart_init/main" class TestAllPublicAccessors extend SmartInit initialize_with :attribute_1, :attribute_2, public_accessors: true is_callable def call [attribute_1, attribute_2] end end class TestSomePublicAccessors extend SmartInit initialize_with :attribute_1, :attribute_2, public_accessors: [:attribute_1] is_callable def call [attribute_1, attribute_2] end end class TestDefaultPublicAccessors extend SmartInit initialize_with :attribute_1, attribute_2: 2, public_accessors: [:attribute_2] def call [attribute_1, attribute_2] end end class HashApiPublicTestAccessors < Test::Unit::TestCase def test_all_public service = TestAllPublicAccessors.new(attribute_1: "a", attribute_2: "b") assert_nothing_raised do service.attribute_1 = "c" service.attribute_2 = "d" end assert_equal service.attribute_1, "c" assert_equal service.attribute_2, "d" end def test_some_public service = TestSomePublicAccessors.new(attribute_1: "a", attribute_2: "b") assert_nothing_raised do service.attribute_1 = "c" end assert_equal service.attribute_1, "c" assert_raise NoMethodError do service.attribute_2 end assert_raise NoMethodError do service.attribute_2 = "d" end end def test_default_public service = TestDefaultPublicAccessors.new(attribute_1: "a") assert_nothing_raised do service.attribute_2 = 3 end assert_equal service.attribute_2, 3 assert_raise NoMethodError do service.attribute_1 = "b" end assert_raise NoMethodError do service.attribute_1 end end end ================================================ FILE: test/test_hash_public_mixed.rb ================================================ # frozen_string_literal: true require "test/unit" require_relative "../lib/smart_init/main" class TestSomePublicMixed extend SmartInit initialize_with :attribute_1, :attribute_2, :attribute_3, :attribute_4, public_readers: [:attribute_1], public_accessors: [:attribute_2, :attribute_3] is_callable def call [attribute_1, attribute_2, attribute_3, attribute_4] end end class TestAllReadersSomeAccessorsPublic extend SmartInit initialize_with :attribute_1, :attribute_2, public_readers: true, public_accessors: [:attribute_2] def call [attribute_1, attribute_2] end end class HashApiPublicTest < Test::Unit::TestCase def test_readers_some_public_mixed service = TestSomePublicMixed.new( attribute_1: "a", attribute_2: "b", attribute_3: "c", attribute_4: "d", ) assert_nothing_raised do service.attribute_1 service.attribute_2 service.attribute_3 end assert_raise NoMethodError do service.attribute_4 end end def test_writers_some_public_mixed service = TestSomePublicMixed.new( attribute_1: "a", attribute_2: "b", attribute_3: "c", attribute_4: "d", ) assert_nothing_raised do service.attribute_2 = "e" service.attribute_3 = "f" end assert_equal service.attribute_2, "e" assert_equal service.attribute_3, "f" assert_raise NoMethodError do service.attribute_4 = "g" end end def test_readers_all_readers_some_accessors_public service = TestAllReadersSomeAccessorsPublic.new( attribute_1: "a", attribute_2: "b", ) assert_nothing_raised do service.attribute_1 service.attribute_2 end end def test_writers_all_readers_some_accessors_public service = TestAllReadersSomeAccessorsPublic.new( attribute_1: "a", attribute_2: "b", ) assert_raise NoMethodError do service.attribute_1 = "c" end assert_nothing_raised do service.attribute_2 = "d" end assert_equal service.attribute_2, "d" end end ================================================ FILE: test/test_hash_public_readers.rb ================================================ # frozen_string_literal: true require "test/unit" require_relative "../lib/smart_init/main" class TestAllPublic extend SmartInit initialize_with :attribute_1, :attribute_2, public_readers: true is_callable def call [attribute_1, attribute_2] end end class TestSomePublic extend SmartInit initialize_with :attribute_1, :attribute_2, public_readers: [:attribute_1] is_callable def call [attribute_1, attribute_2] end end class TestDefaultPublic extend SmartInit initialize_with :attribute_1, attribute_2: 2, public_readers: [:attribute_2] def call [attribute_1, attribute_2] end end class TestDefaultAllPublic extend SmartInit initialize_with :attribute_1, attribute_2: 2, public_readers: true def call [attribute_1, attribute_2] end end class HashApiPublicTest < Test::Unit::TestCase def test_all_public service = TestAllPublic.new(attribute_1: "a", attribute_2: "b") assert_equal service.attribute_1, "a" assert_equal service.attribute_2, "b" end def test_some_public service = TestSomePublic.new(attribute_1: "a", attribute_2: "b") assert_equal service.attribute_1, "a" assert_raise NoMethodError do service.attribute_2 end end def test_default_public service = TestDefaultPublic.new(attribute_1: "a") assert_equal service.attribute_2, 2 assert_raise NoMethodError do service.attribute_1 end end def test_default_all_public service = TestDefaultAllPublic.new(attribute_1: "a") assert_equal service.attribute_1, "a" assert_equal service.attribute_2, 2 end end