├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── attributary.gemspec ├── lib ├── attributary.rb └── attributary │ ├── all.rb │ ├── config.rb │ ├── core_ext.rb │ ├── dsl.rb │ ├── dsl │ ├── accessors.rb │ ├── castings.rb │ ├── error.rb │ ├── helpers.rb │ └── validations.rb │ ├── errors.rb │ ├── initializer.rb │ ├── serializer.rb │ ├── type.rb │ ├── types │ ├── array_type.rb │ ├── big_decimal_type.rb │ ├── boolean_type.rb │ ├── fixnum_type.rb │ ├── float_type.rb │ ├── hash_type.rb │ ├── integer_type.rb │ ├── string_type.rb │ └── symbol_type.rb │ └── version.rb └── spec ├── spec_helper.rb └── unit └── attributary └── attribute_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.gem 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Josh Brody 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 all 13 | 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 THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Attributary 2 | 3 | Like `ActiveModel::Attributes` or `Virtus` but not. No dependencies. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'attributary' 11 | ``` 12 | 13 | And then execute: 14 | 15 | $ bundle 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install attributary 20 | 21 | ## Usage 22 | 23 | ```Ruby 24 | class Character 25 | include Attributary::DSL 26 | include Attributary::Initializer 27 | 28 | attribute :age, :integer 29 | attribute :description, :string, :validate => :check_description 30 | attribute :gender, :string, :collection => ['male', 'female', 'other'] 31 | 32 | # if you don't need to initialize anything yourself, you can omit this 33 | def initialize(name, options = {}) 34 | @name = name 35 | attributary_attributes(options) 36 | end 37 | 38 | private 39 | 40 | def check_description 41 | description.length <= 1024 42 | end 43 | end 44 | 45 | character = Character.new("Tommy", :age => 16, :description => "Hi I am Tommy", :gender => 'male') 46 | character.age # 16 47 | character.description # Hi I am Tommy 48 | character.gender # male 49 | 50 | character.age = 18 51 | character.age # 18 52 | ``` 53 | 54 | Lots more info on the [Wiki](https://github.com/joshmn/attributary/wiki) 55 | 56 | ## Contributing 57 | 58 | Bug reports and pull requests are welcome on GitHub at https://github.com/joshmn/attributary -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | task default: :spec 3 | -------------------------------------------------------------------------------- /attributary.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'attributary/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'attributary' 7 | spec.version = Attributary::VERSION 8 | spec.authors = ['Josh Brody'] 9 | spec.email = ['josh@josh.mn'] 10 | spec.licenses = ['MIT'] 11 | 12 | spec.summary = 'Like ActiveModel::Attributes but less fluffy and more attribute-y.' 13 | spec.description = 'Like `ActiveModel::Attributes` but less fluffy and more attribute-y.' 14 | spec.homepage = 'https://github.com/joshmn/attributary' 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features)/}) 18 | end 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | spec.files += Dir['lib/**/*.rb'] 23 | 24 | spec.add_development_dependency 'bundler', '~> 1.15' 25 | spec.add_development_dependency 'rake', '~> 10.0' 26 | spec.add_development_dependency 'pry', '~> 0.11.3' 27 | spec.add_development_dependency 'rspec', '~> 3.0' 28 | end 29 | -------------------------------------------------------------------------------- /lib/attributary.rb: -------------------------------------------------------------------------------- 1 | require 'attributary/errors' 2 | require 'attributary/config' 3 | require 'attributary/core_ext' 4 | require 'attributary/type' 5 | require 'attributary/initializer' 6 | require 'attributary/serializer' 7 | require 'attributary/dsl' 8 | require 'attributary/version' 9 | 10 | module Attributary 11 | end 12 | -------------------------------------------------------------------------------- /lib/attributary/all.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module All 3 | include Attributary::DSL 4 | include Attributary::Initializer 5 | include Attributary::Serializer 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/attributary/config.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | def self.configuration 3 | @configuration || Config.new 4 | end 5 | 6 | def self.configuration=(val) 7 | @configuration = val 8 | end 9 | 10 | def self.configure 11 | self.configuration ||= Config.new 12 | yield(configuration) 13 | end 14 | 15 | class Config 16 | attr_accessor :validation_error, :collection_error, :strict_mode, :dsl_name, :raise_errors 17 | def initialize 18 | @validation_error = ::Attributary::ValidationError 19 | @collection_error = ::Attributary::CollectionValidationError 20 | @strict_mode = false 21 | @dsl_name = :attribute 22 | @raise_errors = false 23 | end 24 | 25 | def raise_errors? 26 | @raise_errors 27 | end 28 | 29 | def strict_mode? 30 | @strict_mode 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/attributary/core_ext.rb: -------------------------------------------------------------------------------- 1 | class String 2 | unless String.respond_to?(:constantize) 3 | def constantize 4 | Object.const_get(self) 5 | end 6 | end 7 | 8 | unless String.respond_to?(:safe_constantize) 9 | def safe_constantize 10 | constantize 11 | rescue StandardError 12 | nil 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/attributary/dsl.rb: -------------------------------------------------------------------------------- 1 | require 'attributary/dsl/accessors' 2 | require 'attributary/dsl/castings' 3 | require 'attributary/dsl/error' 4 | require 'attributary/dsl/helpers' 5 | require 'attributary/dsl/validations' 6 | 7 | module Attributary 8 | module DSL 9 | 10 | def self.included(base) 11 | base.extend ClassMethods 12 | base.include InstanceMethods 13 | end 14 | 15 | module InstanceMethods 16 | def attributary_errors 17 | self.class._attributary_errors 18 | end 19 | end 20 | 21 | module ClassMethods 22 | include Accessors 23 | include Castings 24 | include Helpers 25 | include Validations 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/attributary/dsl/accessors.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module DSL 3 | module Accessors 4 | 5 | define_method(Attributary.configuration.dsl_name) do |name, type, options = {}| 6 | options[:type] = type 7 | _attributary_attribute_set[name] = options 8 | _attributary_writer(name, type, options) 9 | _attributary_reader(name, type, options) 10 | end 11 | 12 | def _attributary_writer(name, type, options) 13 | define_method("#{name}=") do |value| 14 | value = self.class._attributary_cast_to(type, value) unless self.class._attributary_config.strict_mode? 15 | write = true 16 | write = self.class._attributary_check_collection(name, value, options[:collection]) if options[:collection].is_a?(Array) 17 | write = self.class._attributary_validate_attribute(name, value, options[:validate]) if options[:validate].is_a?(Proc) 18 | if options[:validate].is_a?(Symbol) 19 | unless send(options[:validate]) 20 | self.class._attributary_handle_error(name, value, :validation) 21 | write = false 22 | end 23 | end 24 | if write 25 | instance_variable_set(:"@#{name}", value) 26 | self.class._attributary_errors.delete(name) 27 | end 28 | end 29 | end 30 | 31 | def _attributary_reader(name, type, options) 32 | define_method(name) do 33 | instance_variable_get(:"@#{name}") || self.class._attributary_default_for_method(type, options[:default]) 34 | end 35 | if type == :boolean 36 | define_method("#{name}?") do 37 | send(name.to_s) 38 | end 39 | end 40 | end 41 | 42 | def _attributary_default_for_method(type, value) 43 | return nil if value.nil? 44 | _attributary_cast_to(type, value) 45 | end 46 | 47 | end 48 | end 49 | end -------------------------------------------------------------------------------- /lib/attributary/dsl/castings.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module DSL 3 | module Castings 4 | 5 | def _attributary_cast_to(type, value) 6 | cast_klass = _attributary_cast_class(type) 7 | cast_klass.cast_to(value) 8 | end 9 | 10 | def _attributary_cast_class(type) 11 | cast_klass_name = _attributary_cast_class_name(type) 12 | cast_klass = cast_klass_name.safe_constantize 13 | if cast_klass.nil? 14 | raise NameError, "#{cast_klass_name} is not a valid type." 15 | end 16 | unless cast_klass.respond_to?(:cast_to) 17 | raise NoMethodError, "#{cast_klass} should have a class-method of cast_to" 18 | end 19 | cast_klass 20 | end 21 | 22 | def _attributary_cast_class_name(type) 23 | "Attributary::Types::#{type.to_s.split('_').map(&:capitalize).join}Type" 24 | end 25 | 26 | end 27 | end 28 | end -------------------------------------------------------------------------------- /lib/attributary/dsl/error.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module DSL 3 | class AttributaryError 4 | attr_reader :name, :klass, :message 5 | def initialize(name, klass, message) 6 | @name = name 7 | @klass = klass 8 | @message = message 9 | end 10 | end 11 | 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/attributary/dsl/helpers.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module DSL 3 | module Helpers 4 | def attributary(&block) 5 | block.call(_attributary_config) 6 | end 7 | 8 | def _attributary_config 9 | @_attributary_config ||= Attributary::Config.new 10 | end 11 | 12 | def _attributary_attribute_set 13 | @_attributary_attribute_set ||= {} 14 | end 15 | 16 | def _attributary_errors 17 | @_attributary_errors ||= {} 18 | end 19 | 20 | def attributary_errors 21 | _attributary_errors 22 | end 23 | 24 | def _attributary_attributes 25 | hash = {} 26 | _attributary_attribute_set.keys.each do |k| 27 | hash[k] = instance_variable_get(:"@#{k}") 28 | end 29 | hash 30 | end 31 | 32 | def _attributary_valid? 33 | _attributary_errors.empty? 34 | end 35 | 36 | end 37 | end 38 | end -------------------------------------------------------------------------------- /lib/attributary/dsl/validations.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module DSL 3 | module Validations 4 | 5 | def _attributary_handle_error(attribute, value, type, options = {}) 6 | message = options[:message] || "#{attribute} value #{value} is invalid." 7 | error = _attributary_config.send("#{type}_error") 8 | if error.is_a?(Proc) 9 | error = error.call(attribute, value) 10 | end 11 | if quiet_errors? 12 | _add_attributary_error(attribute, error.class, message) 13 | return 14 | end 15 | raise error, message 16 | end 17 | 18 | def _attributary_check_collection(attribute, value, collection) 19 | unless collection.include?(value) 20 | _attributary_handle_error(attribute, value, :collection, :message => "Attribute #{attribute} `#{value}' is not in the collection #{collection}") 21 | return false 22 | end 23 | true 24 | end 25 | 26 | def _attributary_validate_attribute(attribute, value, validator) 27 | return true if validator.nil? 28 | unless validator.call(value) 29 | _attributary_handle_error(attribute, value, :validation) 30 | false 31 | end 32 | true 33 | end 34 | 35 | def _add_attributary_error(name, klass, message) 36 | self._attributary_errors[name] = AttributaryError.new(name, klass, message) 37 | end 38 | 39 | def quiet_errors? 40 | !_attributary_config.raise_errors? 41 | end 42 | end 43 | end 44 | end -------------------------------------------------------------------------------- /lib/attributary/errors.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | class ValidationError < StandardError; end 3 | class CollectionValidationError < StandardError; end 4 | end -------------------------------------------------------------------------------- /lib/attributary/initializer.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Initializer 3 | def initialize(options = {}) 4 | attributary_initialize(options) 5 | end 6 | 7 | def attributary_initialize(options = {}) 8 | options.each do |k, v| 9 | send("#{k}=", v) if self.class._attributary_attribute_set[k] 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/attributary/serializer.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Serializer 3 | def self.included(base) 4 | base.include InstanceMethods 5 | end 6 | 7 | module InstanceMethods 8 | def read_attribute_for_serialization(attribute_name) 9 | send(attribute_name) 10 | end 11 | 12 | def as_json 13 | hash = {} 14 | self.class._attributary_attribute_set.keys.each do |key| 15 | hash[key] = read_attribute_for_serialization(key) 16 | end 17 | hash 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/attributary/type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class Type 4 | def self.cast_to(value) 5 | value 6 | end 7 | end 8 | end 9 | end 10 | 11 | require 'attributary/types/array_type' 12 | require 'attributary/types/big_decimal_type' 13 | require 'attributary/types/boolean_type' 14 | require 'attributary/types/float_type' 15 | require 'attributary/types/hash_type' 16 | require 'attributary/types/integer_type' 17 | require 'attributary/types/string_type' 18 | require 'attributary/types/symbol_type' -------------------------------------------------------------------------------- /lib/attributary/types/array_type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class ArrayType < Type 4 | def self.cast_to(value) 5 | value.to_a 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/attributary/types/big_decimal_type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class BigDecimal < Type 4 | def self.cast_to(value) 5 | BigDecimal(value.to_s) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/attributary/types/boolean_type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class BooleanType < Type 4 | def self.cast_to(value) 5 | if ['false', 0, '0', false, nil, ''].include?(value) 6 | false 7 | else 8 | true 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/attributary/types/fixnum_type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class FixnumType < IntegerType 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/attributary/types/float_type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class FloatType < Type 4 | def self.cast_to(value) 5 | value.to_f 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/attributary/types/hash_type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class HashType < Type 4 | def self.cast_to(value) 5 | value.is_a?(Hash) ? value : value.to_h 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/attributary/types/integer_type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class IntegerType < Type 4 | def self.cast_to(value) 5 | value.to_i 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/attributary/types/string_type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class StringType < Type 4 | def self.cast_to(value) 5 | value.to_s 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/attributary/types/symbol_type.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | module Types 3 | class SymbolType < Type 4 | def self.cast_to(value) 5 | value.to_s.to_sym 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/attributary/version.rb: -------------------------------------------------------------------------------- 1 | module Attributary 2 | VERSION = '0.1.6'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "attributary" 3 | 4 | RSpec.configure do |config| 5 | config.example_status_persistence_file_path = ".rspec_status" 6 | config.disable_monkey_patching! 7 | config.expect_with :rspec do |c| 8 | c.syntax = :expect 9 | end 10 | end 11 | 12 | class Character 13 | 14 | include Attributary::DSL 15 | 16 | class CustomError < StandardError; end 17 | 18 | attributary do |c| 19 | c.validation_error = proc do |attribute, value| 20 | if attribute == :least_favorite_language 21 | CustomError 22 | else 23 | Attributary::ValidationError 24 | end 25 | end 26 | c.raise_errors = true 27 | end 28 | 29 | attribute :first_name, :string, :default => "Peter" 30 | attribute :last_name, :string 31 | attribute :age, :integer, :default => '1' 32 | attribute :favorite_color, :string, :collection => ['red', 'blue', 'green'] 33 | attribute :favorite_language, :string, :validate => proc { |value| value == 'ruby' } 34 | attribute :least_favorite_language, :string, :validate => :check_least_favorite_language 35 | 36 | private 37 | 38 | def check_least_favorite_language 39 | least_favorite_language == 'ruby' 40 | end 41 | 42 | end 43 | 44 | class Person 45 | 46 | include Attributary::DSL 47 | include Attributary::Initializer 48 | 49 | attribute :first_name, :string, :default => "Kyle" 50 | attribute :last_name, :string 51 | end 52 | 53 | 54 | class Fish 55 | 56 | include Attributary::DSL 57 | include Attributary::Initializer 58 | include Attributary::Serializer 59 | 60 | attribute :first_name, :string, :default => "Scooby" 61 | attribute :last_name, :string, :default => "Doo" 62 | 63 | end 64 | 65 | class Food 66 | include Attributary::DSL 67 | 68 | attribute :type, :string, :collection => ['fruit', 'vegetable'], :default => 'vegetable' 69 | end -------------------------------------------------------------------------------- /spec/unit/attributary/attribute_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe Attributary do 4 | 5 | let(:klass) { Character.new } 6 | 7 | context 'reading and writing' do 8 | it "responds to first_name" do 9 | expect(klass.respond_to?(:first_name)).to eq(true) 10 | end 11 | 12 | it "responds to first_name=" do 13 | expect(klass.respond_to?(:"first_name=")).to eq(true) 14 | end 15 | 16 | it "reads and writes first_name" do 17 | expect(klass.first_name).to eq("Peter") 18 | klass.first_name = "Bob" 19 | expect(klass.first_name).to eq("Bob") 20 | end 21 | 22 | it "doesn't have a default last_name" do 23 | expect(klass.last_name).to eq(nil) 24 | klass.last_name = "Panama" 25 | expect(klass.last_name).to eq("Panama") 26 | end 27 | end 28 | 29 | context 'casting' do 30 | it "casts the default" do 31 | expect(klass.age.class).to eq(Fixnum) 32 | end 33 | 34 | it 'casts an incorrect type to the correct type' do 35 | klass.age = '12' 36 | expect(klass.age).to eq(12) 37 | end 38 | end 39 | 40 | 41 | context 'options#collection' do 42 | it 'raises an error if invalid' do 43 | expect { 44 | klass.favorite_color = 'white' 45 | }.to raise_error Attributary::CollectionValidationError 46 | end 47 | 48 | it 'does not cast' do 49 | expect(klass.favorite_color).to eq(nil) 50 | klass.favorite_color = :green 51 | expect(klass.favorite_color).to eq('green') 52 | end 53 | end 54 | 55 | context 'options#validates' do 56 | it 'validates with a proc' do 57 | expect { 58 | klass.favorite_language = 'python' 59 | }.to raise_error Attributary::ValidationError 60 | end 61 | it 'validates with a method' do 62 | expect { 63 | klass.least_favorite_language = 'ruby' 64 | }.to raise_error Character::CustomError 65 | end 66 | end 67 | 68 | context 'with initializing' do 69 | let(:klass) { Person.new(:last_name => "Cool") } 70 | 71 | it 'has a default first_name' do 72 | expect(klass.first_name).to eq("Kyle") 73 | expect(klass.last_name).to eq("Cool") 74 | end 75 | end 76 | 77 | context 'serializer' do 78 | let(:klass) { Fish.new } 79 | 80 | it 'returns a hash of the instance variables' do 81 | expect(klass.as_json).to eq({:first_name => "Scooby", :last_name => "Doo"}) 82 | end 83 | end 84 | 85 | context 'does not raise error' do 86 | let(:klass) { Food.new } 87 | 88 | it 'is invalid but shuts up about it' do 89 | klass.type = 'sugar' 90 | expect(klass.type).to eq("vegetable") 91 | expect(klass.attributary_errors.size).to eq(1) 92 | klass.type = 'fruit' 93 | expect(klass.attributary_errors.size).to eq(0) 94 | end 95 | end 96 | 97 | end --------------------------------------------------------------------------------