├── Gemfile ├── .rspec ├── lib ├── manufacturable │ ├── version.rb │ ├── object_factory.rb │ ├── railtie.rb │ ├── dispatcher.rb │ ├── config.rb │ ├── injector.rb │ ├── simple_registrar.rb │ ├── factory.rb │ ├── item.rb │ ├── registrar.rb │ └── builder.rb └── manufacturable.rb ├── assets └── factory.png ├── spec ├── support │ └── rails.rb ├── spec_helper.rb ├── manufacturable │ ├── object_factory_spec.rb │ ├── railtie_spec.rb │ ├── config_spec.rb │ ├── registrar_spec.rb │ ├── factory_spec.rb │ ├── item_spec.rb │ └── builder_spec.rb ├── manufacturable_spec.rb └── integration │ └── manufacturable_spec.rb ├── Rakefile ├── bin ├── setup └── console ├── .gitignore ├── .travis.yml ├── .github └── workflows │ └── ruby.yml ├── LICENSE.txt ├── manufacturable.gemspec ├── CODE_OF_CONDUCT.md └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format progress 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/manufacturable/version.rb: -------------------------------------------------------------------------------- 1 | module Manufacturable 2 | VERSION = "2.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /assets/factory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/first-try-software/manufacturable/HEAD/assets/factory.png -------------------------------------------------------------------------------- /spec/support/rails.rb: -------------------------------------------------------------------------------- 1 | module Rails 2 | class Railtie 3 | def self.initializer(config, &block) 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /lib/manufacturable/object_factory.rb: -------------------------------------------------------------------------------- 1 | module Manufacturable 2 | class ObjectFactory 3 | extend Factory 4 | 5 | manufactures Object 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | Gemfile.lock 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start do 3 | add_filter '/spec/' 4 | end 5 | 6 | require "bundler/setup" 7 | require "manufacturable" 8 | 9 | RSpec.configure do |config| 10 | config.example_status_persistence_file_path = ".rspec_status" 11 | 12 | config.disable_monkey_patching! 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "manufacturable" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | cache: bundler 4 | rvm: 5 | - 2.7.1 6 | before_install: gem install bundler -v 2.1.4 7 | before_script: 8 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 9 | - chmod +x ./cc-test-reporter 10 | - ./cc-test-reporter before-build 11 | script: 12 | - bundle exec rspec 13 | after_script: 14 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT -------------------------------------------------------------------------------- /lib/manufacturable/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'manufacturable/config' 2 | 3 | module Manufacturable 4 | class Railtie 5 | def self.load 6 | load_railtie if rails_defined? 7 | end 8 | 9 | def self.load_railtie 10 | Class.new(Rails::Railtie).initializer('manufacturable.require_paths') do |app| 11 | Manufacturable::Config.require_method = app.config.eager_load ? :require : :require_dependency 12 | end 13 | end 14 | 15 | def self.rails_defined? 16 | defined?(Rails) 17 | end 18 | end 19 | end 20 | 21 | Manufacturable::Railtie.load -------------------------------------------------------------------------------- /spec/manufacturable/object_factory_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Manufacturable::ObjectFactory do 2 | subject(:factory) { described_class } 3 | 4 | describe '.build' do 5 | subject(:build) { factory.build(key, *args) } 6 | 7 | let(:key) { :key } 8 | let(:args) { [1, 2, 3] } 9 | 10 | before do 11 | allow(Manufacturable::Builder).to receive(:build) 12 | 13 | build 14 | end 15 | 16 | it 'delegates to the builder with the correct arguments' do 17 | expect(Manufacturable::Builder).to have_received(:build).with(Object, key, *args) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/manufacturable/dispatcher.rb: -------------------------------------------------------------------------------- 1 | module Manufacturable 2 | class Dispatcher 3 | attr_reader :message, :receiver 4 | 5 | def initialize(message:, receiver:) 6 | @message = message 7 | @receiver = receiver 8 | end 9 | 10 | def method_missing(name, *args, **kwargs) 11 | return super unless respond_to?(name) 12 | 13 | Manufacturable.build_one(receiver, name, *args, **kwargs).public_send(message) 14 | end 15 | 16 | def respond_to_missing?(name, include_private = false) 17 | Manufacturable.builds?(receiver, name) || super 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/manufacturable/config.rb: -------------------------------------------------------------------------------- 1 | module Manufacturable 2 | class Config 3 | class << self 4 | attr_writer :require_method 5 | 6 | def paths 7 | @paths ||= [] 8 | end 9 | 10 | def load_paths 11 | paths.each { |path| require_path(path) } 12 | end 13 | 14 | private 15 | 16 | def require_method 17 | @require_method || :require 18 | end 19 | 20 | def require_path(path) 21 | Dir["#{path}/**/*.rb"].each { |file| require_file(file) } 22 | end 23 | 24 | def require_file(file) 25 | Kernel.public_send(require_method, file) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | ruby-version: ['3.0', '3.1', '3.2'] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby-version }} 26 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 27 | - name: Run tests 28 | run: bundle exec rspec 29 | -------------------------------------------------------------------------------- /lib/manufacturable/injector.rb: -------------------------------------------------------------------------------- 1 | require 'manufacturable/simple_registrar' 2 | 3 | module Manufacturable 4 | class Injector 5 | class << self 6 | def inject(klass, *args, **kwargs) 7 | params = dependencies_for(klass).merge(**kwargs) 8 | klass.new(*args, **params) 9 | end 10 | 11 | private 12 | 13 | def dependencies_for(klass) 14 | return {} unless klass.private_method_defined?(:initialize) 15 | 16 | params = klass 17 | .instance_method(:initialize) 18 | .parameters 19 | .select { |(type, name)| type == :keyreq } 20 | .map(&:last) 21 | 22 | SimpleRegistrar.entries(*params) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/manufacturable/simple_registrar.rb: -------------------------------------------------------------------------------- 1 | module Manufacturable 2 | class SimpleRegistrar 3 | class << self 4 | def register(key, value) 5 | self.new(registry, key).register(value) 6 | end 7 | 8 | def entries(*keys) 9 | registry.slice(*keys) 10 | end 11 | 12 | private 13 | 14 | def registry 15 | @registry ||= Hash.new 16 | end 17 | end 18 | 19 | def initialize(registry, key) 20 | @registry, @key = registry, key 21 | end 22 | 23 | def register(value) 24 | @registry[registry_key] = value 25 | end 26 | 27 | private 28 | 29 | def registry_key 30 | @registry_key ||= (@key.respond_to?(:to_sym) && @key.to_sym) || @key 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/manufacturable/factory.rb: -------------------------------------------------------------------------------- 1 | require 'manufacturable/builder' 2 | 3 | module Manufacturable 4 | module Factory 5 | def manufactures(klass) 6 | @type = klass 7 | end 8 | 9 | def build(key, *args) 10 | return if @type.nil? 11 | 12 | Builder.build(@type, key, *args) 13 | end 14 | 15 | def build_one(key, *args) 16 | return if @type.nil? 17 | 18 | Builder.build_one(@type, key, *args) 19 | end 20 | 21 | def build_many(key, *args) 22 | build_all(key, *args) 23 | end 24 | 25 | def build_all(key, *args) 26 | return [] if @type.nil? 27 | 28 | Builder.build_all(@type, key, *args) 29 | end 30 | 31 | def builds?(key) 32 | Builder.builds?(@type, key) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/manufacturable/item.rb: -------------------------------------------------------------------------------- 1 | require 'manufacturable/registrar' 2 | 3 | module Manufacturable 4 | module Item 5 | def new(*args, **kwargs, &block) 6 | key = kwargs.delete(:manufacturable_item_key) 7 | instance = kwargs.empty? ? super(*args, &block) : super 8 | instance.instance_variable_set(:@manufacturable_item_key, key) 9 | instance 10 | end 11 | 12 | def corresponds_to(key, type = self.superclass) 13 | key = key == type ? Registrar::ALL_KEY : key 14 | Registrar.register(type, key, self) 15 | end 16 | 17 | def corresponds_to_all(type = self.superclass) 18 | corresponds_to(Registrar::ALL_KEY, type) 19 | end 20 | 21 | def default_manufacturable(type = self.superclass) 22 | corresponds_to(Registrar::DEFAULT_KEY, type) 23 | end 24 | 25 | def self.extended(base) 26 | base.include(InstanceMethods) 27 | end 28 | 29 | module InstanceMethods 30 | attr_reader :manufacturable_item_key 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/manufacturable.rb: -------------------------------------------------------------------------------- 1 | require 'manufacturable/version' 2 | require 'manufacturable/config' 3 | require 'manufacturable/factory' 4 | require 'manufacturable/item' 5 | require 'manufacturable/object_factory' 6 | require 'manufacturable/railtie' 7 | require 'manufacturable/simple_registrar' 8 | require 'manufacturable/dispatcher' 9 | 10 | module Manufacturable 11 | def self.build(*args, **kwargs, &block) 12 | Builder.build(*args, **kwargs, &block) 13 | end 14 | 15 | def self.build_one(*args, **kwargs, &block) 16 | Builder.build_one(*args, **kwargs, &block) 17 | end 18 | 19 | def self.build_many(*args, **kwargs, &block) 20 | Builder.build_all(*args, **kwargs, &block) 21 | end 22 | 23 | def self.build_all(*args, **kwargs, &block) 24 | Builder.build_all(*args, **kwargs, &block) 25 | end 26 | 27 | def self.builds?(type, key) 28 | Builder.builds?(type, key) 29 | end 30 | 31 | def self.register_dependency(key, value) 32 | SimpleRegistrar.register(key, value) 33 | end 34 | 35 | def self.reset! 36 | Registrar.reset! 37 | end 38 | 39 | def self.config 40 | yield(Config) 41 | Config.load_paths 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Alan Ridlehoover and Fito von Zastrow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/manufacturable/registrar.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module Manufacturable 4 | class Registrar 5 | ALL_KEY = :__all__ 6 | DEFAULT_KEY = :__default__ 7 | 8 | class << self 9 | def register(type, key, value) 10 | self.new(registry, type, key).register(value) 11 | end 12 | 13 | def get(type, key) 14 | self.new(registry, type, key).get 15 | end 16 | 17 | def registered_types 18 | registry.keys 19 | end 20 | 21 | def registered_keys(type) 22 | registry[type].keys 23 | end 24 | 25 | def reset! 26 | registry.clear 27 | end 28 | 29 | private 30 | 31 | def registry 32 | @registry ||= Hash.new { |h,k| h[k] = Hash.new } 33 | end 34 | end 35 | 36 | def initialize(registry, type, key) 37 | @registry, @type, @key = registry, type, key 38 | end 39 | 40 | def register(value) 41 | assign_set if set.nil? 42 | set.add(value) 43 | end 44 | 45 | def get 46 | merged_values.empty? ? default_values : merged_values 47 | end 48 | 49 | private 50 | 51 | def registry_key 52 | @registry_key ||= (@key.respond_to?(:to_sym) && @key.to_sym) || @key 53 | end 54 | 55 | def assign_set 56 | @registry[@type][registry_key] = Set.new 57 | end 58 | 59 | def set 60 | @registry[@type][registry_key] 61 | end 62 | 63 | def merged_values 64 | key_values.merge(all_values) 65 | end 66 | 67 | def key_values 68 | get_for(registry_key) 69 | end 70 | 71 | def all_values 72 | get_for(ALL_KEY) 73 | end 74 | 75 | def default_values 76 | get_for(DEFAULT_KEY) 77 | end 78 | 79 | def get_for(key) 80 | @registry[@type][key] || Set.new 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/manufacturable/builder.rb: -------------------------------------------------------------------------------- 1 | require 'manufacturable/injector' 2 | require 'manufacturable/registrar' 3 | 4 | module Manufacturable 5 | class Builder 6 | def self.build(*args, **kwargs, &block) 7 | self.new(*args, **kwargs, &block).build 8 | end 9 | 10 | def self.build_one(*args, **kwargs, &block) 11 | self.new(*args, **kwargs, &block).build_one 12 | end 13 | 14 | def self.build_all(*args, **kwargs, &block) 15 | self.new(*args, **kwargs, &block).build_all 16 | end 17 | 18 | def self.builds?(*args, **kwargs, &block) 19 | self.new(*args, **kwargs, &block).builds? 20 | end 21 | 22 | def build 23 | return_first? ? instances.first : instances 24 | end 25 | 26 | def build_one 27 | last_instance 28 | end 29 | 30 | def build_all 31 | instances 32 | end 33 | 34 | def builds? 35 | Registrar.registered_keys(type).include?(key) 36 | end 37 | 38 | private 39 | 40 | attr_reader :type, :key, :args, :kwargs, :block 41 | 42 | def initialize(*args, **kwargs, &block) 43 | @type, @key, *@args = args 44 | @kwargs, @block = kwargs, block 45 | end 46 | 47 | def return_first? 48 | instances.size < 2 49 | end 50 | 51 | def instances 52 | @instances ||= klasses.map { |klass| inject(klass) } 53 | end 54 | 55 | def last_instance 56 | inject(last_klass) unless last_klass.nil? 57 | end 58 | 59 | def inject(klass) 60 | Injector.inject(klass, *args, **kwargs_with_key).tap { |instance| block&.call(instance) } 61 | end 62 | 63 | def klasses 64 | Registrar.get(type, key).to_a 65 | end 66 | 67 | def kwargs_with_key 68 | kwargs.merge(manufacturable_item_key: key) 69 | end 70 | 71 | def last_klass 72 | klasses.last 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/manufacturable/railtie_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Manufacturable::Railtie do 2 | let!(:rails_railtie) do 3 | module Rails 4 | class Railtie 5 | def self.initializer(param, &block) 6 | end 7 | end 8 | end 9 | end 10 | 11 | describe '.load' do 12 | subject(:load) { described_class.load } 13 | 14 | before do 15 | allow(described_class).to receive(:rails_defined?).and_return(rails_defined?) 16 | end 17 | 18 | context 'when Rails is NOT defined' do 19 | let(:rails_defined?) { false } 20 | 21 | before { load } 22 | 23 | it 'does not define a railtie' do 24 | expect( 25 | ObjectSpace.each_object(Class).select { |klass| klass < Rails::Railtie } 26 | ).to be_empty 27 | end 28 | end 29 | 30 | context 'when Rails is defined' do 31 | let(:rails_defined?) { true } 32 | 33 | let(:app) { instance_double('app', config: config) } 34 | let(:config) { instance_double('config', eager_load: eager_load?) } 35 | 36 | before do 37 | allow(Rails::Railtie).to receive(:initializer).and_yield(app) 38 | allow(Manufacturable::Config).to receive(:require_method=) 39 | 40 | load 41 | end 42 | 43 | context 'when the rails app does NOT use eager loading' do 44 | let(:eager_load?) { false } 45 | 46 | it 'sets require method to require' do 47 | expect(Manufacturable::Config).to have_received(:require_method=).with(:require_dependency) 48 | end 49 | end 50 | 51 | context 'when the rails app uses eager loading' do 52 | let(:eager_load?) { true } 53 | 54 | it 'sets require method to require' do 55 | expect(Manufacturable::Config).to have_received(:require_method=).with(:require) 56 | end 57 | end 58 | end 59 | end 60 | end -------------------------------------------------------------------------------- /manufacturable.gemspec: -------------------------------------------------------------------------------- 1 | require_relative './lib/manufacturable/version' 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "manufacturable" 5 | spec.version = Manufacturable::VERSION 6 | spec.authors = ["Alan Ridlehoover", "Fito von Zastrow"] 7 | spec.email = ["administators@firsttry.software"] 8 | 9 | spec.summary = %q{Manufacturable is a factory that builds self-registering objects.} 10 | spec.description = %q{Manufacturable is a factory that builds self-registering objects. It leverages self-registration to move factory setup from case statements, hashes, and configuration files to a simple DSL within the instantiable classes themselves. Giving classes the responsibility of registering themselves with the factory does two things. It allows the factory to be extended without modification. And, it leaves the factory with only one responsibility: building objects.} 11 | spec.homepage = "https://github.com/first-try-software/manufacturable" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") 14 | 15 | spec.metadata = { 16 | "bug_tracker_uri" => "https://github.com/first-try-software/manufacturable/issues", 17 | "homepage_uri" => spec.homepage, 18 | "source_code_uri" => "https://github.com/first-try-software/manufacturable" 19 | } 20 | 21 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 22 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|assets)/}) } 23 | end 24 | 25 | spec.bindir = "exe" 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_development_dependency "bundler", "~> 2.0" 30 | spec.add_development_dependency "rake", "~> 12.0" 31 | spec.add_development_dependency "rspec", "~> 3.0" 32 | spec.add_development_dependency "rspec_junit_formatter", "~>0.4" 33 | spec.add_development_dependency "simplecov", "~>0.17.0" 34 | end 35 | -------------------------------------------------------------------------------- /spec/manufacturable/config_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Manufacturable::Config do 2 | after { described_class.paths.clear } 3 | 4 | describe '.paths' do 5 | subject(:paths) { described_class.paths } 6 | 7 | context 'when there are NO manufacturable paths' do 8 | it 'returns an empty array' do 9 | expect(paths).to eq([]) 10 | end 11 | end 12 | 13 | context 'when there are manufacturable paths' do 14 | let(:path1) { 'path1' } 15 | let(:path2) { 'path2' } 16 | 17 | before do 18 | paths << path1 19 | paths << path2 20 | end 21 | 22 | it 'returns an array containing the manufacturable paths' do 23 | expect(paths).to eq([path1, path2]) 24 | end 25 | end 26 | end 27 | 28 | describe '.load_paths' do 29 | subject(:load_paths) { described_class.load_paths } 30 | 31 | let(:path1_files) { [file1, file2] } 32 | let(:path2_files) { [file3, file4] } 33 | let(:file1) { 'file1' } 34 | let(:file2) { 'file2' } 35 | let(:file3) { 'file3' } 36 | let(:file4) { 'file4' } 37 | let(:path1) { 'path1' } 38 | let(:path2) { 'path2' } 39 | 40 | before do 41 | described_class.paths << path1 42 | described_class.paths << path2 43 | 44 | allow(Dir).to receive(:[]).with("#{path1}/**/*.rb").and_return(path1_files) 45 | allow(Dir).to receive(:[]).with("#{path2}/**/*.rb").and_return(path2_files) 46 | described_class.require_method = require_method 47 | end 48 | 49 | context 'when a require method is NOT set' do 50 | let(:require_method) { nil } 51 | 52 | before do 53 | allow(Kernel).to receive(:require) 54 | 55 | load_paths 56 | end 57 | 58 | it 'requires all files under all manufacturable paths' do 59 | [file1, file2, file3, file4].each { |file| expect(Kernel).to have_received(:require).with(file) } 60 | end 61 | end 62 | 63 | context 'when a require method is set' do 64 | let(:require_method) { :require_dependency } 65 | 66 | before do 67 | allow(Kernel).to receive(require_method) 68 | 69 | load_paths 70 | end 71 | 72 | it 'requires all files under all manufacturable paths' do 73 | [file1, file2, file3, file4].each { |file| expect(Kernel).to have_received(require_method).with(file) } 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/manufacturable_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Manufacturable do 2 | it "has a version number" do 3 | expect(Manufacturable::VERSION).not_to be nil 4 | end 5 | 6 | describe '.build' do 7 | subject(:build) { described_class.build(args) } 8 | 9 | let(:args) { 'args' } 10 | 11 | before do 12 | allow(Manufacturable::Builder).to receive(:build) 13 | 14 | build 15 | end 16 | 17 | it 'delegates to the Builder' do 18 | expect(Manufacturable::Builder).to have_received(:build).with(args) 19 | end 20 | end 21 | 22 | describe '.build_one' do 23 | subject(:build_one) { described_class.build_one(args) } 24 | 25 | let(:args) { 'args' } 26 | 27 | before do 28 | allow(Manufacturable::Builder).to receive(:build_one) 29 | 30 | build_one 31 | end 32 | 33 | it 'delegates to the Builder' do 34 | expect(Manufacturable::Builder).to have_received(:build_one).with(args) 35 | end 36 | end 37 | 38 | describe '.build_many' do 39 | subject(:build_many) { described_class.build_many(args) } 40 | 41 | let(:args) { 'args' } 42 | 43 | before do 44 | allow(Manufacturable::Builder).to receive(:build_all) 45 | 46 | build_many 47 | end 48 | 49 | it 'delegates to the Builder' do 50 | expect(Manufacturable::Builder).to have_received(:build_all).with(args) 51 | end 52 | end 53 | 54 | describe '.build_all' do 55 | subject(:build_all) { described_class.build_all(args) } 56 | 57 | let(:args) { 'args' } 58 | 59 | before do 60 | allow(Manufacturable::Builder).to receive(:build_all) 61 | 62 | build_all 63 | end 64 | 65 | it 'delegates to the Builder' do 66 | expect(Manufacturable::Builder).to have_received(:build_all).with(args) 67 | end 68 | end 69 | 70 | describe '.builds?' do 71 | subject(:builds?) { described_class.builds?(type, key) } 72 | 73 | let(:type) { :type } 74 | let(:key) { :key } 75 | 76 | before do 77 | allow(Manufacturable::Builder).to receive(:builds?) 78 | 79 | builds? 80 | end 81 | 82 | it 'delegates to the Builder' do 83 | expect(Manufacturable::Builder).to have_received(:builds?).with(type, key) 84 | end 85 | end 86 | 87 | describe '.reset!' do 88 | subject(:reset!) { described_class.reset! } 89 | 90 | before do 91 | allow(Manufacturable::Registrar).to receive(:reset!) 92 | 93 | reset! 94 | end 95 | 96 | it 'delegates to the Registrar' do 97 | expect(Manufacturable::Registrar).to have_received(:reset!) 98 | end 99 | end 100 | 101 | describe '.config' do 102 | subject(:config) { described_class.config(&block) } 103 | 104 | let(:block) { Proc.new {} } 105 | 106 | before do 107 | allow(Manufacturable::Config).to receive(:load_paths) 108 | end 109 | 110 | it 'calls the block with the config class' do 111 | described_class.config { |arg| expect(arg).to eq(Manufacturable::Config) } 112 | end 113 | 114 | it 'loads the configured paths' do 115 | config 116 | 117 | expect(Manufacturable::Config).to have_received(:load_paths) 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/manufacturable/registrar_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Manufacturable::Registrar do 2 | subject(:registrar) { described_class } 3 | 4 | let(:type) { :type } 5 | let(:key) { :key } 6 | let(:klass) { class_double('Klass') } 7 | 8 | before { registrar.reset! } 9 | after { registrar.reset! } 10 | 11 | describe '.register' do 12 | subject(:register) { registrar.register(type, key, klass) } 13 | 14 | before { register } 15 | 16 | it 'adds the class to the registry under the type and key' do 17 | expect(registrar.get(type, key)).to include(klass) 18 | end 19 | end 20 | 21 | describe '.get' do 22 | subject(:get) { registrar.get(type, key) } 23 | 24 | context 'when the type does NOT exist in the registry' do 25 | it 'returns an empty set' do 26 | expect(get).to be_a_kind_of(Set).and(be_empty) 27 | end 28 | end 29 | 30 | context 'when the type exists in the registry' do 31 | context 'and the set corresponding to the type and key is empty' do 32 | before { registrar.register(type, :key2, klass) } 33 | 34 | context 'and there is NOT a default set' do 35 | it 'returns an empty set' do 36 | expect(get).to be_empty 37 | end 38 | end 39 | 40 | context 'and there is a default set' do 41 | let(:default_klass) { class_double('DefaultKlass') } 42 | 43 | before { registrar.register(type, Manufacturable::Registrar::DEFAULT_KEY, default_klass) } 44 | 45 | it 'returns the default set' do 46 | expect(get).to include(default_klass) 47 | end 48 | end 49 | end 50 | 51 | context 'and the set corresponding to the type and key is NOT empty' do 52 | let(:all_klass) { class_double('AllKlass') } 53 | 54 | before do 55 | registrar.register(type, Manufacturable::Registrar::ALL_KEY, all_klass) 56 | registrar.register(type, key, klass) 57 | end 58 | 59 | it 'returns a set containing all types registered for that type' do 60 | expect(get).to include(all_klass) 61 | end 62 | 63 | it 'returns a set containing all types registered for that key' do 64 | expect(get).to include(klass) 65 | end 66 | end 67 | end 68 | end 69 | 70 | describe '.registered_types' do 71 | subject(:registered_types) { registrar.registered_types } 72 | 73 | before { registrar.register(type, key, klass) } 74 | 75 | it 'returns the registered types' do 76 | expect(registered_types).to eq([type]) 77 | end 78 | end 79 | 80 | describe '.registered_keys' do 81 | subject(:registered_keys) { registrar.registered_keys(type) } 82 | 83 | before { registrar.register(type, key, klass) } 84 | 85 | it 'returns the registered keys for the type' do 86 | expect(registered_keys).to eq([key]) 87 | end 88 | end 89 | 90 | describe '.reset!' do 91 | subject(:reset!) { registrar.reset! } 92 | 93 | before do 94 | registrar.register(type, key, klass) 95 | end 96 | 97 | it 'clears the registry' do 98 | expect { reset! } 99 | .to change { registrar.get(type, key) } 100 | .from(including(klass)) 101 | .to(be_empty) 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/manufacturable/factory_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Manufacturable::Factory do 2 | subject(:factory) { Class.new { extend Manufacturable::Factory } } 3 | 4 | let(:klass) { class_double('Klass') } 5 | let(:key) { 'key' } 6 | let(:args) { 'args' } 7 | 8 | describe '.manufactures' do 9 | subject(:manufactures) { factory.manufactures(klass) } 10 | 11 | before { manufactures } 12 | 13 | it 'remembers what type of factory it is' do 14 | expect(factory.instance_variable_get(:@type)).to eq(klass) 15 | end 16 | end 17 | 18 | describe '.build' do 19 | subject(:build) { factory.build(key, *args) } 20 | 21 | context 'when manufactures has NOT been called' do 22 | it { should be_nil } 23 | end 24 | 25 | context 'when manufactures has been called' do 26 | before do 27 | allow(Manufacturable::Builder).to receive(:build) 28 | factory.manufactures(klass) 29 | build 30 | end 31 | 32 | it 'delegates to the builder' do 33 | expect(Manufacturable::Builder).to have_received(:build).with(klass, key, args) 34 | end 35 | end 36 | end 37 | 38 | describe '.build_one' do 39 | subject(:build_one) { factory.build_one(key, *args) } 40 | 41 | context 'when manufactures has NOT been called' do 42 | it { should be_nil } 43 | end 44 | 45 | context 'when manufactures has been called' do 46 | before do 47 | allow(Manufacturable::Builder).to receive(:build_one) 48 | factory.manufactures(klass) 49 | build_one 50 | end 51 | 52 | it 'delegates to the builder' do 53 | expect(Manufacturable::Builder).to have_received(:build_one).with(klass, key, args) 54 | end 55 | end 56 | end 57 | 58 | describe '.build_many' do 59 | subject(:build_many) { factory.build_many(key, *args) } 60 | 61 | context 'when manufactures has NOT been called' do 62 | it { should be_empty } 63 | end 64 | 65 | context 'when manufactures has been called' do 66 | before do 67 | allow(Manufacturable::Builder).to receive(:build_all) 68 | factory.manufactures(klass) 69 | build_many 70 | end 71 | 72 | it 'delegates to the builder' do 73 | expect(Manufacturable::Builder).to have_received(:build_all).with(klass, key, args) 74 | end 75 | end 76 | end 77 | 78 | describe '.build_all' do 79 | subject(:build_all) { factory.build_all(key, *args) } 80 | 81 | context 'when manufactures has NOT been called' do 82 | it { should be_empty } 83 | end 84 | 85 | context 'when manufactures has been called' do 86 | before do 87 | allow(Manufacturable::Builder).to receive(:build_all) 88 | factory.manufactures(klass) 89 | build_all 90 | end 91 | 92 | it 'delegates to the builder' do 93 | expect(Manufacturable::Builder).to have_received(:build_all).with(klass, key, args) 94 | end 95 | end 96 | end 97 | 98 | describe '.builds?' do 99 | subject(:builds?) { factory.builds?(key) } 100 | 101 | before do 102 | allow(Manufacturable::Builder).to receive(:builds?) 103 | factory.manufactures(klass) 104 | builds? 105 | end 106 | 107 | it 'delegates to the builder' do 108 | expect(Manufacturable::Builder).to have_received(:builds?).with(klass, key) 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at support@firsttry.software. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /spec/integration/manufacturable_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'Manufacturable' do 2 | describe 'trying to build a class before registering anything' do 3 | it 'returns nil' do 4 | expect(Manufacturable.build(Object, :anything)).to be_nil 5 | end 6 | end 7 | 8 | describe 'building a class registered on the Object namespace' do 9 | let!(:sedan) { Class.new { extend Manufacturable::Item; corresponds_to :four_door } } 10 | 11 | it 'returns an instance of Sedan' do 12 | expect(Manufacturable.build(Object, :four_door)).to be_a_kind_of(sedan) 13 | end 14 | end 15 | 16 | describe 'building a class registered on the namespace of an explicit superclass' do 17 | let(:automobile) { Class.new { extend Manufacturable::Item } } 18 | let!(:sedan) { Class.new(automobile) { corresponds_to :four_door } } 19 | 20 | it 'returns an instance of Sedan' do 21 | expect(Manufacturable.build(automobile, :four_door)).to be_a_kind_of(sedan) 22 | end 23 | end 24 | 25 | describe 'building a default manufacturable' do 26 | let(:automobile) { Class.new { extend Manufacturable::Item } } 27 | let!(:sedan) { Class.new(automobile) { default_manufacturable } } 28 | 29 | it 'returns an instance of Sedan' do 30 | expect(Manufacturable.build(automobile, :unknown_key)).to be_a_kind_of(sedan) 31 | end 32 | end 33 | 34 | describe 'building a class with named params' do 35 | let(:automobile) { Class.new { extend Manufacturable::Item; def initialize(color:); end } } 36 | let!(:sedan) { Class.new(automobile) { corresponds_to :sedan } } 37 | 38 | around do |example| 39 | original_stderror = $stderr 40 | $stderr = StringIO.new 41 | example.run 42 | $stderr = original_stderror 43 | end 44 | 45 | it 'does not warn' do 46 | Manufacturable.build_one(automobile, :sedan, color: 'blue') 47 | 48 | expect($stderr.string).to be_empty 49 | end 50 | end 51 | 52 | describe 'building multiple classes registered with the same key' do 53 | let(:component) { Class.new { extend Manufacturable::Item } } 54 | let!(:engine) { Class.new(component) { corresponds_to :sedan } } 55 | let!(:transmission) { Class.new(component) { corresponds_to :sedan } } 56 | 57 | it 'returns an instance of Sedan' do 58 | expect(Manufacturable.build(component, :sedan)) 59 | .to match_array([a_kind_of(engine), a_kind_of(transmission)]) 60 | end 61 | end 62 | 63 | describe 'building a class that corresponds to all keys in a namespace' do 64 | let(:component) { Class.new { extend Manufacturable::Item } } 65 | let!(:standard_engine) { Class.new(component) { corresponds_to :sedan } } 66 | let!(:performance_engine) { Class.new(component) { corresponds_to :coupe } } 67 | let!(:transmission) { Class.new(component) { corresponds_to_all } } 68 | 69 | it 'returns an instance of Sedan' do 70 | expect(Manufacturable.build(component, :sedan)) 71 | .to match_array([a_kind_of(standard_engine), a_kind_of(transmission)]) 72 | expect(Manufacturable.build(component, :coupe)) 73 | .to match_array([a_kind_of(performance_engine), a_kind_of(transmission)]) 74 | end 75 | end 76 | 77 | describe 'injecting a registered dependency' do 78 | let(:car) { Class.new { extend Manufacturable::Item } } 79 | let(:driver) { instance_double('driver', name: 'Müller') } 80 | let!(:bmw_m3) do 81 | Class.new(car) do 82 | corresponds_to :sedan 83 | 84 | attr_reader :driver 85 | 86 | def initialize(driver:) 87 | @driver = driver 88 | end 89 | end 90 | end 91 | 92 | it 'builds an instance of sedan with the dependency' do 93 | Manufacturable.register_dependency(:driver, driver) 94 | 95 | expect(Manufacturable.build(car, :sedan)).to have_attributes(driver: driver) 96 | end 97 | end 98 | 99 | describe 'dispatching a message to a receiver' do 100 | let(:action) { Class.new { extend Manufacturable::Item; def initialize(observer); @observer = observer; end } } 101 | let!(:check_engine) { Class.new(action) { corresponds_to :check_engine; def perform; @observer.report(:engine_checked); end } } 102 | let!(:change_oil) { Class.new(action) { corresponds_to :change_oil; def perform; @observer.report(:oil_changed); end } } 103 | 104 | it 'builds an instance and dispatches the message to it' do 105 | observer = instance_double('observer', report: true) 106 | dispatcher = Manufacturable::Dispatcher.new(receiver: action, message: :perform) 107 | 108 | dispatcher.check_engine(observer) 109 | dispatcher.change_oil(observer) 110 | 111 | expect(observer).to have_received(:report).with(:engine_checked) 112 | expect(observer).to have_received(:report).with(:oil_changed) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/manufacturable/item_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Manufacturable::Item do 2 | let(:item) { Class.new(base_klass) { extend Manufacturable::Item } } 3 | let(:base_klass) { Class.new } 4 | let(:type) { base_klass } 5 | 6 | before do 7 | allow(Manufacturable::Registrar).to receive(:register) 8 | end 9 | 10 | describe '.corresponds_to' do 11 | subject(:corresponds_to) { item.corresponds_to(*args) } 12 | 13 | let(:args) { [key] } 14 | 15 | before { corresponds_to } 16 | 17 | context 'when the type is NOT provided' do 18 | context 'when the key does NOT match the item type' do 19 | let(:key) { :key } 20 | 21 | it 'registers itself with the key' do 22 | expect(Manufacturable::Registrar).to have_received(:register).with(type, key, item) 23 | end 24 | end 25 | 26 | context 'when the key matches the item type' do 27 | let(:key) { type } 28 | 29 | it 'registers itself for all items of that type' do 30 | expect(Manufacturable::Registrar).to have_received(:register).with(type, Manufacturable::Registrar::ALL_KEY, item) 31 | end 32 | end 33 | end 34 | 35 | context 'when the type is provided' do 36 | let(:args) { [key, explicit_type] } 37 | let(:key) { :key } 38 | let(:explicit_type) { :explicit_type } 39 | 40 | it 'registers itself with the key and type' do 41 | expect(Manufacturable::Registrar).to have_received(:register).with(explicit_type, key, item) 42 | end 43 | end 44 | end 45 | 46 | describe '.corresponds_to_all' do 47 | subject(:corresponds_to_all) { item.corresponds_to_all(*args) } 48 | 49 | let(:args) { [] } 50 | 51 | before { corresponds_to_all } 52 | 53 | it 'registers itself for all items of that type' do 54 | expect(Manufacturable::Registrar).to have_received(:register).with(type, Manufacturable::Registrar::ALL_KEY, item) 55 | end 56 | 57 | context 'when the type is provided' do 58 | let(:args) { [explicit_type] } 59 | let(:explicit_type) { :explicit_type } 60 | 61 | it 'registers itself for all items of the specified type' do 62 | expect(Manufacturable::Registrar).to have_received(:register).with(explicit_type, Manufacturable::Registrar::ALL_KEY, item) 63 | end 64 | end 65 | end 66 | 67 | describe '.default_manufacturable' do 68 | subject(:default_manufacturable) { item.default_manufacturable(*args) } 69 | 70 | let(:args) { [] } 71 | 72 | before { default_manufacturable } 73 | 74 | it 'registers itself as the default item for that type' do 75 | expect(Manufacturable::Registrar).to have_received(:register).with(type, Manufacturable::Registrar::DEFAULT_KEY, item) 76 | end 77 | 78 | context 'when the type is provided' do 79 | let(:args) { [explicit_type] } 80 | let(:explicit_type) { :explicit_type } 81 | 82 | it 'registers itself as the default item for the specified type' do 83 | expect(Manufacturable::Registrar).to have_received(:register).with(explicit_type, Manufacturable::Registrar::DEFAULT_KEY, item) 84 | end 85 | end 86 | end 87 | 88 | describe '.new' do 89 | subject { item.new(*args, **kwargs) } 90 | 91 | let(:args) { [] } 92 | let(:kwargs) { {} } 93 | 94 | context 'when the extending class does NOT define an initializer' do 95 | let(:item) { Class.new { extend Manufacturable::Item } } 96 | 97 | context 'and a manufacturable_item_key argument is NOT passed' do 98 | 99 | it 'sets manufacturable_item_key to nil' do 100 | expect(subject.manufacturable_item_key).to be_nil 101 | end 102 | end 103 | 104 | context 'and a manufacturable_item_key argument is passed' do 105 | let(:kwargs) { { manufacturable_item_key: key } } 106 | let(:key) { 'key' } 107 | 108 | it 'sets manufacturable_item_key to the provided value' do 109 | expect(subject.manufacturable_item_key).to eq(key) 110 | end 111 | end 112 | end 113 | 114 | context 'when the extending class defines an initializer with positional arguments' do 115 | let(:item) { Class.new { extend Manufacturable::Item; def initialize(foo); @foo = foo; end } } 116 | 117 | context 'and a manufacturable_item_key argument is NOT passed' do 118 | let(:args) { [:bar] } 119 | let(:kwargs) { {} } 120 | 121 | it 'sets manufacturable_item_key to nil' do 122 | expect(subject.manufacturable_item_key).to be_nil 123 | end 124 | end 125 | 126 | context 'and a manufacturable_item_key argument is passed' do 127 | let(:args) { [:bar] } 128 | let(:kwargs) { { manufacturable_item_key: key } } 129 | let(:key) { 'key' } 130 | 131 | it 'sets manufacturable_item_key to the provided value' do 132 | expect(subject.manufacturable_item_key).to eq(key) 133 | end 134 | end 135 | end 136 | 137 | context 'when the extending class defines an initializer with named arguments' do 138 | let(:item) { Class.new { extend Manufacturable::Item; def initialize(foo:); @foo = foo; end } } 139 | 140 | context 'and a manufacturable_item_key argument is NOT passed' do 141 | let(:args) { [] } 142 | let(:kwargs) { {foo: :bar} } 143 | 144 | it 'sets manufacturable_item_key to nil' do 145 | expect(subject.manufacturable_item_key).to be_nil 146 | end 147 | end 148 | 149 | context 'and a manufacturable_item_key argument is passed' do 150 | let(:kwargs) { { foo: :bar, manufacturable_item_key: key } } 151 | let(:key) { 'key' } 152 | 153 | it 'sets manufacturable_item_key to the provided value' do 154 | expect(subject.manufacturable_item_key).to eq(key) 155 | end 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Factory 2 | 3 | # Manufacturable 4 | 5 | [![Gem Version](https://badge.fury.io/rb/manufacturable.svg)](https://badge.fury.io/rb/manufacturable) 6 | [![Build Status](https://github.com/first-try-software/manufacturable/actions/workflows/ruby.yml/badge.svg) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/ecc365446449c0142b0e/maintainability)](https://codeclimate.com/github/first-try-software/manufacturable/maintainability) 8 | [![Test Coverage](https://api.codeclimate.com/v1/badges/ecc365446449c0142b0e/test_coverage)](https://codeclimate.com/github/first-try-software/manufacturable/test_coverage) 9 | 10 | Manufacturable is a factory that builds self-registering objects. 11 | 12 | It leverages self-registration to move factory setup from case statements, 13 | hashes, and configuration files to a simple DSL within the instantiable 14 | classes themselves. Giving classes the responsibility of registering 15 | themselves with the factory does two things. It allows the factory to be 16 | [extended without modification][ocp]. And, it leaves the factory with only 17 | [one responsibility][srp]: building objects. 18 | 19 | ## Motivation 20 | 21 | We wrote Manufacturable, so we wouldn't have to keep modifying our factory 22 | code every time we needed to add functionality to our applications. For 23 | example, consider this factory: 24 | 25 | ```ruby 26 | class AutomobileFactory 27 | def self.build(type, *args) 28 | case type 29 | when :sedan 30 | Sedan.new(*args) 31 | when :coupe 32 | Coupe.new(*args) 33 | when :convertible 34 | Convertible.new(*args) 35 | end 36 | end 37 | end 38 | ``` 39 | 40 | If you want to start building `Hatchback` objects, you'll need to modify the 41 | factory. To solve this problem in Ruby, factories are often built using 42 | metaprogramming, like this: 43 | 44 | ```ruby 45 | class AutomobileFactory 46 | def self.build(type, *args) 47 | Object.const_get(type.capitalize)&.new(*args) 48 | end 49 | end 50 | ``` 51 | 52 | But, this very simple factory relies on a convention: the type symbol must 53 | match the name of the class. This means that classes with namespaces, or 54 | symbols with underscores will not work. In other words, you could not use the 55 | symbol `:four_door` to build a `Sedan` object. 56 | 57 | Manufacturable solves these problems by allowing classes to register themselves 58 | with the factory using a key of their choosing. This means you never have to 59 | modify the factory code again. 60 | 61 | ## Usage 62 | 63 | ### The Basics 64 | 65 | A class may register itself with Manufacturable like this: 66 | 67 | ```ruby 68 | class Sedan 69 | extend Manufacturable::Item 70 | 71 | corresponds_to :four_door 72 | end 73 | ``` 74 | 75 | Extending `Manufacturable::Item` adds the Manufacturable DSL to the class. 76 | Calling `corresponds_to` with a key registers that class with the factory. 77 | 78 | Once registered, a class may be instantiated like this: 79 | 80 | ```ruby 81 | Manufacturable.build(Object, :four_door, *args) 82 | ``` 83 | 84 | Note the first parameter. This is the parent class of the registered class. 85 | In this case, the parent class happens to be `Object`. So, Manufacturable 86 | registered the `Sedan` class under the `Object` namespace to prevent key 87 | collision. To instantiate the `Sedan`, we need to request the `:four_door` 88 | key from the `Object` namespace. 89 | 90 | For convenience, Manufacturable provides an `ObjectFactory` to build objects 91 | that are stored in the `Object` namespace: 92 | 93 | ```ruby 94 | Manufacturable::ObjectFactory.build(:four_door, *args) 95 | ``` 96 | 97 | In most cases, though, your class will actually inherit from a specific class 98 | other than `Object`. For example, it is likely that the `Sedan` class would 99 | inherit from an `Automobile` class. If that were the case, you would pass 100 | `Automobile` as the first parameter to `Manufacturable.build`: 101 | 102 | ```ruby 103 | class Automobile 104 | extend Manufacturable::Item 105 | end 106 | 107 | class Sedan < Automobile 108 | corresponds_to :four_door 109 | end 110 | 111 | Manufacturable.build(Automobile, :four_door, *args) 112 | ``` 113 | 114 | That's all you need to know to begin using Manufacturable. But, it's not all 115 | there is to know. Manunfacturable allows you to: 116 | 117 | * [Configure your own factory classes](#using-factory-classes) 118 | * [Define a default manufacturable item](#defining-a-default-manufacturable-item) 119 | * [Register multiple classes under the same key within a namespace](#registering-multiple-classes-under-the-same-key-within-a-namespace) 120 | * [Register a class to correspond with an entire namespace](#registering-a-class-to-correspond-with-an-entire-namespace) 121 | 122 | ### Using Factory Classes 123 | 124 | Manufacturable also has a DSL for creating factories: 125 | 126 | ```ruby 127 | class AutomobileFactory 128 | extend Manufacturable::Factory 129 | 130 | manufactures Automobile 131 | end 132 | ``` 133 | 134 | Extending `Manufacturable::Factory` adds the DSL to the factory class. 135 | Calling `manufactures` with a class designates it as the namespace for the 136 | factory. 137 | 138 | Once configured, you can use the `AutomobileFactory` to build objects from 139 | classes in the `Automobile` namespace: 140 | 141 | ```ruby 142 | AutomobileFactory.build(:four_door, *args) 143 | ``` 144 | 145 | ### Defining a Default Manufacturable Item 146 | 147 | What happens when Manufacturable is unable to find the key you're looking for? 148 | That depends on what you tell Manufacturable. By default, it will return `nil` 149 | when it does not find a class registered at a specific key. But, you can also 150 | configure Manufacturable's response. This allows you to implement the [null 151 | object pattern][nop]. 152 | 153 | ```ruby 154 | class NullAutomobile < Automobile 155 | default_manufacturable 156 | end 157 | ``` 158 | 159 | Now, your calling code does not have to check for `nil` before calling a method 160 | on the class: 161 | 162 | ```ruby 163 | AutomobileFactory.build(:lemon, *args).drive 164 | ``` 165 | 166 | ### Registering Multiple Classes Under the Same Key within a Namespace 167 | 168 | Manufacturable allows you to register multiple classes under the same key: 169 | 170 | ```ruby 171 | class StandardEngine < Component 172 | corresponds_to :sedan 173 | end 174 | 175 | class AutomaticTransmission < Component 176 | corresponds_to :sedan 177 | end 178 | 179 | class PowerfulEngine < Component 180 | corresponds_to :coupe 181 | end 182 | 183 | class ManualTransmission < Component 184 | corresponds_to :coupe 185 | end 186 | ``` 187 | 188 | Then, when you request that key, you'll receive an array containing a new 189 | instance of each class registered under that key. 190 | 191 | ```ruby 192 | ComponentFactory.build(:sedan, *args) 193 | # => [#, #] 194 | 195 | ComponentFactory.build(:coupe, *args) 196 | # => [#, #] 197 | ``` 198 | 199 | ### Registering a Class to Correspond with an Entire Namespace 200 | 201 | Manufacturable will also let you register a class that corresponds with all 202 | of the keys in a namespace: 203 | 204 | ```ruby 205 | class HeadLight < Component 206 | corresponds_to_all 207 | end 208 | ``` 209 | 210 | Now, the `ComponentFactory` will include `HeadLight` objects for both the 211 | `:sedan` and `:coupe`. 212 | 213 | ```ruby 214 | ComponentFactory.build(:sedan, *args) 215 | # => [ 216 | # #, 217 | # #, 218 | # # 219 | # ] 220 | 221 | ComponentFactory.build(:coupe, *args) 222 | # => [ 223 | # #, 224 | # #, 225 | # # 226 | # ] 227 | ``` 228 | 229 | ## Installation 230 | 231 | Add this line to your application's Gemfile: 232 | 233 | ```ruby 234 | gem 'manufacturable' 235 | ``` 236 | 237 | And then execute: 238 | 239 | $ bundle install 240 | 241 | Or install it yourself as: 242 | 243 | $ gem install manufacturable 244 | 245 | If you are using Manufacturable with Rails, you'll need an initializer to tell 246 | manufacturable where the classes are, so they can be autoloaded. 247 | 248 | ```ruby 249 | Manufacturable.config do |config| 250 | config.paths << Rails.root.join('app', 'automobiles') 251 | config.paths << Rails.root.join('app', 'components') 252 | end 253 | ``` 254 | 255 | ## Development 256 | 257 | After checking out the repo, run `bin/setup` to install dependencies. Then, 258 | run `rake spec` to run the tests. You can also run `bin/console` for an 259 | interactive prompt that will allow you to experiment. To install this gem 260 | onto your local machine, run `bundle exec rake install`. 261 | 262 | ## Contributing 263 | 264 | Bug reports and pull requests are welcome on [GitHub][git]. 265 | 266 | ## License 267 | 268 | The gem is available as open source under the terms of the [MIT License][mit]. 269 | 270 | ## Code of Conduct 271 | 272 | Everyone interacting in the Manufacturable project's codebases, issue trackers, 273 | chat rooms and mailing lists is expected to follow the [code of conduct][cod]. 274 | 275 | ## Acknowledgements 276 | 277 | Manufacturable was inspired by work we did at Entelo on [Industrialist][ind]. 278 | We will be forever grateful to the people at Entelo for giving us the 279 | opportunity to work on things we're still proud of today. 280 | 281 | [srp]: https://en.wikipedia.org/wiki/Single-responsibility_principle 282 | [ocp]: https://en.wikipedia.org/wiki/Open-closed_principle 283 | [nop]: https://en.wikipedia.org/wiki/Null_object_pattern 284 | [gem]: https://rubygems.org 285 | [git]: https://github.com/first-try-software/manufacturable 286 | [cod]: https://github.com/first-try-software/manufacturable/blob/main/CODE_OF_CONDUCT.md 287 | [mit]: https://opensource.org/licenses/MIT 288 | [ind]: https://github.com/entelo/industrialist 289 | -------------------------------------------------------------------------------- /spec/manufacturable/builder_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Manufacturable::Builder do 2 | let(:type) { 'type' } 3 | let(:key) { 'key' } 4 | let(:args) { 'args' } 5 | let(:kwargs) { { manufacturable_item_key: key } } 6 | let(:klasses) { Set.new } 7 | 8 | describe '.build' do 9 | subject(:build) { described_class.build(type, key, args) } 10 | 11 | before do 12 | allow(Manufacturable::Registrar).to receive(:get).and_return(klasses) 13 | end 14 | 15 | it 'gets the classes from the Registrar' do 16 | build 17 | 18 | expect(Manufacturable::Registrar).to have_received(:get).with(type, key) 19 | end 20 | 21 | context 'when the class set is empty' do 22 | let(:klasses) { Set.new } 23 | 24 | it { should be_nil } 25 | end 26 | 27 | context 'when the class set is NOT empty' do 28 | let(:klass) { class_double('Klass', new: klass_instance) } 29 | let(:klass_instance) { instance_double('Klass') } 30 | let(:klasses) { Set.new([klass]) } 31 | 32 | context 'and the set contains one class' do 33 | context 'and the params are NOT named' do 34 | it 'instantiates an object with the provided args' do 35 | build 36 | 37 | expect(klass).to have_received(:new).with(args, kwargs) 38 | end 39 | 40 | it 'returns an instance of the class in the set' do 41 | expect(build).to eq(klass_instance) 42 | end 43 | 44 | context 'and a block is given' do 45 | it 'yields the built instance to the block' do 46 | expect { |b| described_class.build(type, key, args, &b) }.to yield_with_args(klass_instance) 47 | end 48 | end 49 | end 50 | 51 | context 'and the params are named' do 52 | let(:klass) { Class.new { extend Manufacturable::Item; def initialize(param:); end } } 53 | 54 | around do |example| 55 | original_stderror = $stderr 56 | $stderr = StringIO.new 57 | example.run 58 | $stderr = original_stderror 59 | end 60 | 61 | it 'does not raise a warning' do 62 | described_class.build(type, key, param: 'param') 63 | 64 | expect($stderr.string).to be_empty 65 | end 66 | end 67 | end 68 | 69 | context 'and the set contains more than one class' do 70 | let(:klass1) { class_double('Klass1', new: klass1_instance) } 71 | let(:klass2) { class_double('Klass2', new: klass2_instance) } 72 | let(:klass1_instance) { instance_double('Klass1') } 73 | let(:klass2_instance) { instance_double('Klass2') } 74 | let(:klasses) { Set.new([klass1, klass2]) } 75 | 76 | it 'instantiates an object with the provided args' do 77 | build 78 | 79 | expect(klasses).to all(have_received(:new).with(args, kwargs)) 80 | end 81 | 82 | it 'returns an instance of every class in the set' do 83 | expect(build).to eq([klass1_instance, klass2_instance]) 84 | end 85 | 86 | context 'and a block is given' do 87 | it 'successively yields the built instances to the block' do 88 | expect { |b| described_class.build(type, key, args, &b) }.to yield_successive_args(klass1_instance, klass2_instance) 89 | end 90 | end 91 | end 92 | end 93 | end 94 | 95 | describe '.build_one' do 96 | subject(:build_one) { described_class.build_one(type, key, args) } 97 | 98 | before do 99 | allow(Manufacturable::Registrar).to receive(:get).and_return(klasses) 100 | end 101 | 102 | it 'gets the classes from the Registrar' do 103 | build_one 104 | 105 | expect(Manufacturable::Registrar).to have_received(:get).with(type, key) 106 | end 107 | 108 | context 'when the class set is empty' do 109 | let(:klasses) { Set.new } 110 | 111 | it { should be_nil } 112 | end 113 | 114 | context 'when the class set is NOT empty' do 115 | let(:klass) { class_double('Klass', new: klass_instance) } 116 | let(:klass_instance) { instance_double('Klass') } 117 | let(:klasses) { Set.new([klass]) } 118 | 119 | context 'and the set contains one class' do 120 | context 'and the params are NOT named' do 121 | it 'instantiates an object with the provided args' do 122 | build_one 123 | 124 | expect(klass).to have_received(:new).with(args, kwargs) 125 | end 126 | 127 | it 'returns an instance of the class in the set' do 128 | expect(build_one).to eq(klass_instance) 129 | end 130 | 131 | context 'and a block is given' do 132 | it 'yields the built instance to the block' do 133 | expect { |b| described_class.build_one(type, key, args, &b) }.to yield_with_args(klass_instance) 134 | end 135 | end 136 | end 137 | 138 | context 'and the params are named' do 139 | let(:klass) { Class.new { extend Manufacturable::Item; def initialize(param:); end } } 140 | 141 | around do |example| 142 | original_stderror = $stderr 143 | $stderr = StringIO.new 144 | example.run 145 | $stderr = original_stderror 146 | end 147 | 148 | it 'does not raise a warning' do 149 | described_class.build_one(type, key, param: 'param') 150 | 151 | expect($stderr.string).to be_empty 152 | end 153 | end 154 | end 155 | 156 | context 'and the set contains more than one class' do 157 | let(:klass1) { class_double('Klass1', new: klass1_instance) } 158 | let(:klass2) { class_double('Klass2', new: klass2_instance) } 159 | let(:klass1_instance) { instance_double('Klass1') } 160 | let(:klass2_instance) { instance_double('Klass2') } 161 | let(:klasses) { Set.new([klass1, klass2]) } 162 | 163 | it 'instantiates the last class with the provided args' do 164 | build_one 165 | 166 | expect(klass1).not_to have_received(:new).with(args, kwargs) 167 | expect(klass2).to have_received(:new).with(args, kwargs) 168 | end 169 | 170 | it 'returns an instance of the last class in the set' do 171 | expect(build_one).to eq(klass2_instance) 172 | end 173 | 174 | context 'and a block is given' do 175 | it 'yields the built instance to the block' do 176 | expect { |b| described_class.build_one(type, key, args, &b) }.to yield_with_args(klass2_instance) 177 | end 178 | end 179 | end 180 | end 181 | end 182 | 183 | describe '.build_all' do 184 | subject(:build_all) { described_class.build_all(type, key, args) } 185 | 186 | before do 187 | allow(Manufacturable::Registrar).to receive(:get).and_return(klasses) 188 | end 189 | 190 | it 'gets the classes from the Registrar' do 191 | build_all 192 | 193 | expect(Manufacturable::Registrar).to have_received(:get).with(type, key) 194 | end 195 | 196 | context 'when the class set is empty' do 197 | let(:klasses) { Set.new } 198 | 199 | it { should be_empty } 200 | end 201 | 202 | context 'when the class set is NOT empty' do 203 | let(:klass) { class_double('Klass', new: klass_instance) } 204 | let(:klass_instance) { instance_double('Klass') } 205 | let(:klasses) { Set.new([klass]) } 206 | 207 | context 'and the set contains one class' do 208 | context 'and the params are NOT named' do 209 | it 'instantiates an object with the provided args' do 210 | build_all 211 | 212 | expect(klass).to have_received(:new).with(args, kwargs) 213 | end 214 | 215 | it 'returns an array containing an instance of the class in the set' do 216 | expect(build_all).to eq([klass_instance]) 217 | end 218 | 219 | context 'and a block is given' do 220 | it 'yields the built instance to the block' do 221 | expect { |b| described_class.build_all(type, key, args, &b) }.to yield_with_args(klass_instance) 222 | end 223 | end 224 | end 225 | 226 | context 'and the params are named' do 227 | let(:klass) { Class.new { extend Manufacturable::Item; def initialize(param:); end } } 228 | 229 | around do |example| 230 | original_stderror = $stderr 231 | $stderr = StringIO.new 232 | example.run 233 | $stderr = original_stderror 234 | end 235 | 236 | it 'does not raise a warning' do 237 | described_class.build_all(type, key, param: 'param') 238 | 239 | expect($stderr.string).to be_empty 240 | end 241 | end 242 | end 243 | 244 | context 'and the set contains more than one class' do 245 | let(:klass1) { class_double('Klass1', new: klass1_instance) } 246 | let(:klass2) { class_double('Klass2', new: klass2_instance) } 247 | let(:klass1_instance) { instance_double('Klass1') } 248 | let(:klass2_instance) { instance_double('Klass2') } 249 | let(:klasses) { Set.new([klass1, klass2]) } 250 | 251 | it 'instantiates an object with the provided args' do 252 | build_all 253 | 254 | expect(klasses).to all(have_received(:new).with(args, kwargs)) 255 | end 256 | 257 | it 'returns an instance of every class in the set' do 258 | expect(build_all).to eq([klass1_instance, klass2_instance]) 259 | end 260 | 261 | context 'and a block is given' do 262 | it 'successively yields the built instances to the block' do 263 | expect { |b| described_class.build_all(type, key, args, &b) }.to yield_successive_args(klass1_instance, klass2_instance) 264 | end 265 | end 266 | end 267 | end 268 | end 269 | 270 | describe '.builds?' do 271 | subject(:builds?) { described_class.builds?(type, key) } 272 | 273 | before do 274 | allow(Manufacturable::Registrar).to receive(:registered_keys).with(type).and_return(registered_keys) 275 | end 276 | 277 | context 'when the key is registered for the type' do 278 | let(:registered_keys) { [key] } 279 | 280 | it 'returns true' do 281 | expect(builds?).to eq(true) 282 | end 283 | end 284 | 285 | context 'when the key is NOT registered for the type' do 286 | let(:registered_keys) { [:different_key] } 287 | 288 | it 'returns false' do 289 | expect(builds?).to eq(false) 290 | end 291 | end 292 | end 293 | end 294 | --------------------------------------------------------------------------------