├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── redtape.rb └── redtape │ ├── attribute_whitelist.rb │ ├── model_factory.rb │ ├── populator │ ├── abstract.rb │ ├── has_many.rb │ ├── has_one.rb │ └── root.rb │ └── version.rb ├── redtape.gemspec └── spec ├── attribute_whitelist_spec.rb ├── automation_spec.rb ├── fixtures ├── db │ └── migrate │ │ ├── 20120912184745_everything.rb │ │ └── 20120912184746_create_phone_number.rb ├── models │ ├── address.rb │ ├── phone_number.rb │ └── user.rb └── redtape │ ├── nested_form_redtape.rb │ └── registration_redtape.rb ├── form_spec.rb ├── lint_spec.rb ├── nested_form_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | .rvmrc 19 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - ree 4 | - "1.8.7" 5 | - "1.9.2" 6 | - "1.9.3" 7 | - jruby-18mode 8 | - jruby-19mode 9 | - rbx-18mode 10 | - rbx-19mode 11 | - ruby-head 12 | branches: 13 | only: 14 | - master 15 | # uncomment this line if your project needs to run something other than `rake`: 16 | # # script: bundle exec rspec spec 17 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in redtape.gemspec 4 | gemspec 5 | 6 | group :development do 7 | gem 'minitest' 8 | gem 'virtus' 9 | gem 'rspec' 10 | gem 'rails' 11 | gem 'pry' 12 | 13 | platform :ruby do 14 | gem 'sqlite3' 15 | end 16 | 17 | platform :jruby do 18 | gem 'activerecord-jdbcsqlite3-adapter' 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 ClearFit 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redtape [![Build Status](https://secure.travis-ci.org/ClearFit/redtape.png)](http://travis-ci.org/ClearFit/redtape) 2 | 3 | Redtape provides an alternative to [ActiveRecord::NestedAttributes#accepts_nested_attributes_for](http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html#method-i-accepts_nested_attributes_for) in the form of, well, a Form! The initial implementation was heavily inspired by ["7 Ways to Decompose Fat Activerecord Models"](http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/) by [Bryan Helmkamp](https://github.com/brynary). 4 | 5 | In a nutshell, `accepts_nested_attributes_for` tightly couples your View to your Model. This is highly undesirable as it makes both harder to maintain. Instead, the Form provides a Controller delegate that mediates between the two, acting like an ActiveModel from the View and Controller's perspective but acting a proxy to the Model layer. 6 | 7 | ## Features 8 | 9 | * Automatically converting nested form data into the appropriate ActiveRecord object graph 10 | * Optional dependency injection of a data mapper to map form fields to ActiveRecord object fields 11 | * Optional form data whitelisting 12 | 13 | ## Installation 14 | 15 | Add this line to your application's Gemfile: 16 | 17 | gem 'redtape' 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install redtape 26 | 27 | ## Usage 28 | 29 | To use Redtape, you use a *Redtape::Form* in your controller and your nested *form_for*s where you would supply an ActiveRecord object. 30 | 31 | A *Redtape::Form* is simply an ActiveModel. So you just call *#save*, *#valid?*, and *#errors* on it like any other ActiveModel. 32 | 33 | Redtape will use your model's/models' validations to determine if the form data is correct. That is, you validate and save the same way you would with any `ActiveModel`. If any of the models are invalid, errors are added to the `Form` for handling within the View/Controller. 34 | 35 | Using a *Redtape::Form* goes something like this: 36 | 37 | ```html 38 | <%= form_for @form, :as => :whatever %> 39 | ... 40 | ``` 41 | 42 | ```ruby 43 | class SomethingController 44 | def new 45 | @form = Redtape::Form.new(self, params) 46 | end 47 | 48 | def create # should support update as well... 49 | @form = Redtape::Form.new(self, params) 50 | if @form.save 51 | # ... 52 | else 53 | # ... 54 | end 55 | end 56 | end 57 | ``` 58 | 59 | ### If you want to get to the AR object directory... 60 | 61 | Call *#model* thusly on your *Redtape::Form* instance: 62 | 63 | ```ruby 64 | @form = Redtape::Form.new(self, params) 65 | @form.model 66 | ``` 67 | 68 | ### If your controller name doesn't map directly to the form's ActiveRecord class... 69 | 70 | You just add an argument: 71 | 72 | ```ruby 73 | class SomethingController 74 | def create 75 | @form = Redtape::Form.new(self, params, :top_level_name => :user) 76 | # ... 77 | end 78 | ``` 79 | 80 | ### (Optional) Custom form field mapping to ActiveRecord objects 81 | 82 | A Redtape "data mapper" is just a class that implements a *#populate\_individual\_record* method such as: 83 | 84 | ```ruby 85 | module NestedFormRedtape 86 | def populate_individual_record(record, attrs) 87 | if record.is_a?(User) 88 | record.name = "#{attrs[:first_name]} #{attrs[:last_name]}" 89 | elsif record.is_a?(Address) 90 | record.attributes = record.attributes.merge(attrs) 91 | end 92 | end 93 | end 94 | ``` 95 | Yes, we are branching on classes. Yes, this usually is a smell to use polymorphism. In this case, the average data mapper is going to be pretty simple. As such, I didn't find this to be onerous. 96 | 97 | To use this custom data mapper, just mix it into your controller. Redtape detects the presence of your method and uses it instead of the default implementation. 98 | 99 | I tend to implement these as modules to simplify testing. I create an object that I nominally call a "\*Controller", mix in the module, and stub out a *#params* method. This gives me something close enough to a controller for testing while not requiring instantiating a real Rails Controller. For examples, see the spec directory. 100 | 101 | 102 | ### Optional whitelisting 103 | 104 | This should like familiar to anyone who has used the *:include* option on an ActiveRecord finder. 105 | 106 | ```ruby 107 | Redtape::Form.new(self, params, :whitelisted_attrs => { 108 | :user => [ 109 | :name, 110 | { :phone_number => [ :country_code, :area_code, :number ] }, 111 | { :addresses => [:address1, :address2, :city, :state, :zipcode] } 112 | ] 113 | } 114 | ``` 115 | 116 | Currently, if a whitelist validation occurs, a Redtape::WhitelistValidationError is raised containing a detailed error message of violating parameters. I figured you'd like to know 117 | 118 | ## What's left 119 | 120 | We'd really like to add the following to make Redtape even easier for folks to plug n' play: 121 | 122 | * A Rails generator to add the app/forms and (test/spec)/forms directories 123 | 124 | ## Contributing 125 | 126 | 1. Fork it 127 | 2. Create your feature branch (`git checkout -b my-new-feature`) 128 | 3. Commit your changes (`git commit -am 'Add some feature'`) 129 | 4. Push to the branch (`git push origin my-new-feature`) 130 | 5. Create new Pull Request 131 | 132 | Finally, we'd really like your feedback 133 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | 3 | 4 | require 'rubygems' 5 | require 'bundler' 6 | 7 | Bundler.require 8 | 9 | require 'rails' 10 | require 'rspec' 11 | require 'rspec/core/rake_task' 12 | 13 | require "bundler/gem_tasks" 14 | 15 | 16 | RSpec::Core::RakeTask.new(:spec) 17 | 18 | task :default => :spec 19 | -------------------------------------------------------------------------------- /lib/redtape.rb: -------------------------------------------------------------------------------- 1 | require "redtape/version" 2 | require "redtape/attribute_whitelist" 3 | require "redtape/model_factory" 4 | require "redtape/populator/abstract" 5 | require "redtape/populator/root" 6 | require "redtape/populator/has_many" 7 | require "redtape/populator/has_one" 8 | 9 | require 'active_model' 10 | require 'active_support/core_ext/class/attribute' 11 | 12 | require 'active_record' 13 | 14 | require 'forwardable' 15 | 16 | module Redtape 17 | 18 | class DuelingBanjosError < StandardError; end 19 | class WhitelistViolationError < StandardError; end 20 | 21 | class Form 22 | extend Forwardable 23 | extend ActiveModel::Naming 24 | include ActiveModel::Callbacks 25 | include ActiveModel::Conversion 26 | include ActiveModel::Validations 27 | 28 | def_delegator :@factory, :model 29 | 30 | def initialize(controller, args = {}) 31 | if controller.respond_to?(:populate_individual_record) && args[:whitelisted_attrs] 32 | fail DuelingBanjosError, "Redtape::Form does not accept both #{controller.class}#populate_individual_record and the 'whitelisted_attrs' argument" 33 | end 34 | 35 | @factory = ModelFactory.new(factory_args_for(controller, args)) 36 | end 37 | 38 | # Forms are never themselves persisted 39 | def persisted? 40 | false 41 | end 42 | 43 | def valid? 44 | model = @factory.populate_model 45 | valid = model.valid? 46 | 47 | # @errors comes from ActiveModel::Validations. This may not 48 | # be a legit hook. 49 | @errors = model.errors 50 | 51 | valid 52 | end 53 | 54 | def save 55 | if valid? 56 | begin 57 | ActiveRecord::Base.transaction do 58 | @factory.save! 59 | end 60 | rescue ActiveRecord::RecordInvalid 61 | # This shouldn't even happen with the #valid? above. 62 | end 63 | else 64 | false 65 | end 66 | end 67 | 68 | def method_missing(*args, &block) 69 | if @factory.model 70 | @factory.model.send(*args, &block) 71 | else 72 | super 73 | end 74 | end 75 | 76 | private 77 | 78 | def factory_args_for(controller, args) 79 | args.dup.merge( 80 | :attrs => controller.params, 81 | :controller => controller 82 | ) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/redtape/attribute_whitelist.rb: -------------------------------------------------------------------------------- 1 | module Redtape 2 | class AttributeWhitelist 3 | attr_reader :whitelisted_attrs 4 | 5 | def initialize(whitelisted_attrs) 6 | @whitelisted_attrs = whitelisted_attrs 7 | end 8 | 9 | def top_level_name 10 | whitelisted_attrs.try(:keys).try(:first) 11 | end 12 | 13 | def allows?(args = {}) 14 | allowed_attrs = whitelisted_attrs_for(args[:association_name]) || [] 15 | allowed_attrs = allowed_attrs.map(&:to_s) 16 | allowed_attrs << "id" 17 | allowed_attrs.include?(args[:attr].to_s) 18 | end 19 | 20 | private 21 | 22 | # Locate whitelisted attributes for the supplied association name 23 | def whitelisted_attrs_for(assoc_name, attr_hash = whitelisted_attrs) 24 | if assoc_name.to_s == attr_hash.keys.first.to_s 25 | return attr_hash.values.first.reject { |v| v.is_a? Hash } 26 | end 27 | 28 | scoped_whitelisted_attrs = attr_hash.values.first 29 | scoped_whitelisted_attrs.reject { |v| 30 | !v.is_a? Hash 31 | }.find { |v| 32 | whitelisted_attrs_for(assoc_name, v) 33 | }.try(:values).try(:first) 34 | end 35 | end 36 | 37 | class NullAttrWhitelist 38 | def top_level_name 39 | nil 40 | end 41 | 42 | def present? 43 | false 44 | end 45 | 46 | def nil? 47 | true 48 | end 49 | 50 | def allows?(args = {}) 51 | false 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/redtape/model_factory.rb: -------------------------------------------------------------------------------- 1 | module Redtape 2 | class ModelFactory 3 | attr_reader :top_level_name, :records_to_save, :model, :controller, :attr_whitelist, :attrs 4 | 5 | def initialize(args = {}) 6 | assert_inputs(args) 7 | 8 | @attrs = args[:attrs] 9 | @attr_whitelist = NullAttrWhitelist.new 10 | @controller = args[:controller] 11 | @records_to_save = [] 12 | @top_level_name = 13 | @attr_whitelist.top_level_name || 14 | args[:top_level_name] || 15 | default_top_level_name_from(controller) 16 | if args[:whitelisted_attrs] 17 | @attr_whitelist = AttributeWhitelist.new(args[:whitelisted_attrs]) 18 | end 19 | end 20 | 21 | def populate_model 22 | @model = find_or_create_root_model 23 | 24 | populators = [ Populator::Root.new(root_populator_args) ] 25 | populators.concat( 26 | create_populators_for(model, attrs.values.first).flatten 27 | ) 28 | 29 | populators.each do |p| 30 | p.call 31 | end 32 | 33 | violations = populators.map(&:whitelist_failures).flatten 34 | if violations.present? 35 | errors = violations.join(", ") 36 | fail WhitelistViolationError, "Form supplied non-whitelisted attrs #{errors}" 37 | end 38 | 39 | @model 40 | end 41 | 42 | def save! 43 | model.save! 44 | records_to_save.each(&:save!) 45 | end 46 | 47 | private 48 | 49 | def default_top_level_name_from(controller) 50 | if controller.class.to_s =~ /(\w+)Controller/ 51 | $1.singularize.downcase.to_sym 52 | end 53 | end 54 | 55 | def root_populator_args 56 | root_populator_args = { 57 | :model => model, 58 | :attrs => params_for_current_scope(attrs.values.first), 59 | :association_name => attrs.keys.first 60 | }.tap do |r| 61 | if attr_whitelist.present? && controller.respond_to?(:populate_individual_record) 62 | fail ArgumentError, "Expected either controller to respond_to #populate_individual_record or :whitelisted_attrs but not both" 63 | elsif controller.respond_to?(:populate_individual_record) 64 | r[:data_mapper] = controller 65 | elsif attr_whitelist 66 | r[:attr_whitelist] = attr_whitelist 67 | end 68 | end 69 | end 70 | 71 | def find_associated_model(attrs, args = {}) 72 | case args[:with_macro] 73 | when :has_many 74 | args[:on_association].find(attrs[:id]) 75 | when :has_one 76 | args[:on_model].send(args[:for_association_name]) 77 | end 78 | end 79 | 80 | def find_or_create_root_model 81 | model_class = top_level_name.to_s.camelize.constantize 82 | root_object_id = attrs.values.first[:id] 83 | if root_object_id 84 | model_class.send(:find, root_object_id) 85 | else 86 | model_class.new 87 | end 88 | end 89 | 90 | def create_populators_for(model, attributes) 91 | attributes.each_with_object([]) do |key_value, association_populators| 92 | next unless key_value[1].is_a?(Hash) 93 | 94 | key, value = key_value 95 | macro = macro_for_attribute_key(key) 96 | associated_attrs = 97 | case macro 98 | when :has_many 99 | value.values 100 | when :has_one 101 | [value] 102 | end 103 | 104 | associated_attrs.inject(association_populators) do |populators, record_attrs| 105 | assoc_name = find_association_name_in(key) 106 | current_scope_attrs = params_for_current_scope(record_attrs) 107 | 108 | associated_model = find_or_initialize_associated_model( 109 | current_scope_attrs, 110 | :for_association_name => assoc_name, 111 | :on_model => model, 112 | :with_macro => macro 113 | ) 114 | 115 | populator_class = "Redtape::Populator::#{macro.to_s.camelize}".constantize 116 | 117 | populator_args = { 118 | :model => associated_model, 119 | :association_name => assoc_name, 120 | :attrs => current_scope_attrs, 121 | :parent => model 122 | } 123 | if controller.respond_to?(:populate_individual_record) && attr_whitelist.present? 124 | fail ArgumentError, "Expected either controller to respond_to #populate_individual_record or :whitelisted_attrs but not both" 125 | elsif controller.respond_to?(:populate_individual_record) 126 | populator_args[:data_mapper] = controller 127 | elsif attr_whitelist 128 | populator_args[:attr_whitelist] = attr_whitelist 129 | end 130 | 131 | populators << populator_class.new(populator_args) 132 | populators.concat( 133 | create_populators_for(associated_model, record_attrs) 134 | ) 135 | end 136 | end 137 | end 138 | 139 | def find_or_initialize_associated_model(attrs, args = {}) 140 | association_name, macro, model = args.values_at(:for_association_name, :with_macro, :on_model) 141 | 142 | association = model.send(association_name) 143 | if attrs[:id] 144 | find_associated_model( 145 | attrs, 146 | :on_model => model, 147 | :with_macro => macro, 148 | :on_association => association 149 | ).tap do |record| 150 | records_to_save << record 151 | end 152 | else 153 | case macro 154 | when :has_many 155 | model.send(association_name).build 156 | when :has_one 157 | model.send("build_#{association_name}") 158 | end 159 | end 160 | end 161 | 162 | def macro_for_attribute_key(key) 163 | association_name = find_association_name_in(key).to_sym 164 | association_reflection = model.class.reflect_on_association(association_name) 165 | association_reflection.macro 166 | end 167 | 168 | 169 | def params_for_current_scope(attrs) 170 | attrs.dup.reject { |_, v| v.is_a? Hash } 171 | end 172 | 173 | ATTRIBUTES_KEY_REGEXP = /^(.+)_attributes$/ 174 | 175 | def has_many_association_attrs?(key) 176 | key =~ ATTRIBUTES_KEY_REGEXP 177 | end 178 | 179 | def find_association_name_in(key) 180 | ATTRIBUTES_KEY_REGEXP.match(key)[1] 181 | end 182 | 183 | def assert_inputs(args) 184 | if args[:top_level_name] && args[:whitelisted_attrs].present? 185 | fail ArgumentError, ":top_level_name is redundant as it is already present as the key in :whitelisted_attrs" 186 | end 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /lib/redtape/populator/abstract.rb: -------------------------------------------------------------------------------- 1 | module Redtape 2 | module Populator 3 | class Abstract 4 | attr_reader :association_name, :model, :pending_attributes, :parent, :data_mapper, :attr_whitelist, :whitelist_failures 5 | 6 | def initialize(args = {}) 7 | @model = args[:model] 8 | @association_name = args[:association_name] 9 | @pending_attributes = args[:attrs] 10 | @parent = args[:parent] 11 | @data_mapper = args[:data_mapper] 12 | @attr_whitelist = args[:attr_whitelist] 13 | @whitelist_failures = [] 14 | end 15 | 16 | def call 17 | populate_model_attributes(model, pending_attributes) 18 | 19 | if model.new_record? 20 | assign_to_parent 21 | end 22 | end 23 | 24 | protected 25 | 26 | def assign_to_parent 27 | fail NotImplementedError, "You have to implement this in your subclass" 28 | end 29 | 30 | private 31 | 32 | def populate_model_attributes(model, attributes) 33 | msg_target = 34 | if data_mapper && data_mapper.respond_to?(:populate_individual_record) 35 | data_mapper 36 | else 37 | self 38 | end 39 | msg_target.send( 40 | :populate_individual_record, 41 | model, 42 | attributes 43 | ) 44 | end 45 | 46 | def populate_individual_record(record, attrs) 47 | assert_against_whitelisted(attrs.keys) 48 | 49 | # #merge! didn't work here.... 50 | record.attributes = record.attributes.merge(attrs) 51 | end 52 | 53 | def assert_against_whitelisted(attrs) 54 | return unless attr_whitelist.present? 55 | return if model.new_record? 56 | 57 | attrs.each do |a| 58 | unless attr_whitelist.allows?(:association_name => association_name, :attr => a) 59 | whitelist_failures << %{"#{association_name}##{a}"} 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/redtape/populator/has_many.rb: -------------------------------------------------------------------------------- 1 | module Redtape 2 | module Populator 3 | class HasMany < Abstract 4 | def assign_to_parent 5 | parent.send(association_name).send("<<", model) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/redtape/populator/has_one.rb: -------------------------------------------------------------------------------- 1 | module Redtape 2 | module Populator 3 | class HasOne < Abstract 4 | def assign_to_parent 5 | parent.send("#{association_name}=", model) 6 | end 7 | end 8 | end 9 | end 10 | 11 | -------------------------------------------------------------------------------- /lib/redtape/populator/root.rb: -------------------------------------------------------------------------------- 1 | module Redtape 2 | module Populator 3 | class Root < Abstract 4 | def assign_to_parent 5 | # no-op 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/redtape/version.rb: -------------------------------------------------------------------------------- 1 | module Redtape 2 | VERSION = "1.0.2" 3 | end 4 | -------------------------------------------------------------------------------- /redtape.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/redtape/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ["Evan Light"] 6 | gem.email = ["evan.light@tripledogdare.net"] 7 | gem.description = %q{A handy dandy way to avoid using #accepts_nested_attributes_for} 8 | gem.summary = %q{ Redtape provides an alternative to [ActiveRecord::NestedAttributes#accepts\_nested\_attributes\_for](http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html#method-i-accepts_nested_attributes_for) in the form of, well, a Form! The initial implementation was heavily inspired by ["7 Ways to Decompose Fat Activerecord Models"](http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/) by [Bryan Helmkamp](https://github.com/brynary).} 9 | gem.homepage = "http://github.com/ClearFit/redtape" 10 | 11 | gem.files = Array(Dir.glob("lib/**/*.rb")) 12 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 13 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 14 | gem.name = "redtape" 15 | gem.require_paths = ["lib"] 16 | gem.version = Redtape::VERSION 17 | 18 | # See Gemfile for development dependencies. 19 | 20 | gem.add_runtime_dependency "activemodel" 21 | gem.add_runtime_dependency "activesupport" 22 | end 23 | -------------------------------------------------------------------------------- /spec/attribute_whitelist_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Redtape::AttributeWhitelist do 4 | subject { Redtape::AttributeWhitelist.new(:user => [:name, {:addresses => [:address1]}]) } 5 | 6 | context "#allows?" do 7 | specify { subject.allows?(:association_name => :user, :attr => :name).should be_true } 8 | specify { subject.allows?(:association_name => :user, :attr => :social_security_number).should be_false } 9 | specify { subject.allows?(:association_name => :addresses, :attr => :address1).should be_true } 10 | specify { subject.allows?(:association_name => :addresses, :attr => :alarm_code).should be_false } 11 | end 12 | 13 | context "#scoped_whitelisted_attrs_for" do 14 | specify { subject.send(:whitelisted_attrs_for, :user).should == [:name] } 15 | specify { subject.send(:whitelisted_attrs_for, :addresses).should == [:address1] } 16 | specify { subject.send(:whitelisted_attrs_for, :addresses, {:addresses => [:address1]}).should == [:address1] } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/automation_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class UsersController; end 4 | 5 | describe "Using the default ModelFactory" do 6 | let(:create_params) { 7 | HashWithIndifferentAccess.new( 8 | :user => { 9 | :name => "Evan Light", 10 | :social_security_number => "123-456-7890", 11 | :addresses_attributes => { 12 | "0" => { 13 | :address1 => "123 Foobar way", 14 | :city => "Foobar", 15 | :state => "MN", 16 | :zipcode => "12345", 17 | :alarm_code => "12345" 18 | }, 19 | "1" => { 20 | :address1 => "124 Foobar way", 21 | :city => "Foobar", 22 | :state => "MN", 23 | :zipcode => "12345", 24 | :alarm_code => "12345" 25 | } 26 | } 27 | } 28 | ) 29 | } 30 | 31 | let(:update_params) { 32 | HashWithIndifferentAccess.new( 33 | :user => { 34 | :id => User.last.id, 35 | :name => "Evan Not-so-bright-light", 36 | :social_security_number => "000-000-0000", 37 | :addresses_attributes => { 38 | "0" => { 39 | :id => Address.first.id, 40 | :address1 => "456 Foobar way", 41 | :city => "Foobar", 42 | :state => "MN", 43 | :zipcode => "12345", 44 | :alarm_code => "00000" 45 | }, 46 | "1" => { 47 | :id => Address.last.id, 48 | :address1 => "124 Foobar way", 49 | :city => "Foobar", 50 | :state => "MN", 51 | :zipcode => "12345", 52 | :alarm_code => "12345" 53 | } 54 | } 55 | } 56 | ) 57 | } 58 | 59 | context "when creating records" do 60 | let(:controller_stub) { 61 | UsersController.new.tap { |c| c.stub(:params => create_params) } 62 | } 63 | 64 | before do 65 | Redtape::Form.new(controller_stub).save 66 | end 67 | 68 | it "saves the root model" do 69 | User.count.should == 1 70 | end 71 | 72 | it "saves the nested models" do 73 | User.first.addresses.count.should == 2 74 | end 75 | end 76 | 77 | context "when updating records" do 78 | let(:controller_stub) { 79 | UsersController.new.tap { |c| c.stub(:params => update_params) } 80 | } 81 | 82 | context "with attributes that were not whitelisted" do 83 | 84 | subject { 85 | Redtape::Form.new(controller_stub, :whitelisted_attrs => { 86 | :user => [ 87 | :name, 88 | { 89 | :addresses => [ 90 | :address1, 91 | :address2, 92 | :city, 93 | :state, 94 | :zipcode, 95 | ] 96 | } 97 | ] 98 | }) 99 | } 100 | 101 | before do 102 | params = create_params[:user] 103 | u = User.create!( 104 | :name => params[:name], 105 | :social_security_number => params[:social_security_number] 106 | ) 107 | Address.create!( 108 | params[:addresses_attributes]["0"].merge(:user_id => u.id) 109 | ) 110 | Address.create!( 111 | params[:addresses_attributes]["1"].merge(:user_id => u.id) 112 | ) 113 | end 114 | 115 | specify do 116 | lambda { subject.save }.should raise_error(Redtape::WhitelistViolationError) 117 | end 118 | 119 | specify do 120 | begin 121 | subject.save 122 | rescue 123 | expect($!.to_s).to match(/social_security_number/) 124 | end 125 | end 126 | 127 | specify do 128 | begin 129 | subject.save 130 | rescue 131 | expect($!.to_s).to match(/alarm_code/) 132 | end 133 | end 134 | end 135 | end 136 | 137 | context "User has_one PhoneNumber" do 138 | let(:create_params) { 139 | HashWithIndifferentAccess.new( 140 | :user => { 141 | :name => "Evan Light", 142 | :phone_number_attributes => { 143 | :country_code => "1", 144 | :area_code => "123", 145 | :number => "456-7890" 146 | } 147 | } 148 | ) 149 | } 150 | 151 | let(:controller_stub) { 152 | UsersController.new.tap { |c| c.stub(:params => create_params) } 153 | } 154 | 155 | subject { Redtape::Form.new(controller_stub) } 156 | 157 | specify do 158 | count = User.count 159 | subject.save 160 | User.count.should == count + 1 161 | end 162 | 163 | specify do 164 | count = PhoneNumber.count 165 | subject.save 166 | PhoneNumber.count.should == count + 1 167 | end 168 | 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /spec/fixtures/db/migrate/20120912184745_everything.rb: -------------------------------------------------------------------------------- 1 | class Everything < ActiveRecord::Migration 2 | def change 3 | create_table :users do |u| 4 | u.string :name 5 | u.string :social_security_number 6 | u.timestamps 7 | end 8 | 9 | create_table :addresses do |a| 10 | a.string :address1 11 | a.string :address2 12 | a.string :city 13 | a.string :state 14 | a.string :zipcode 15 | a.string :alarm_code 16 | a.integer :user_id 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/fixtures/db/migrate/20120912184746_create_phone_number.rb: -------------------------------------------------------------------------------- 1 | class CreatePhoneNumber < ActiveRecord::Migration 2 | def change 3 | create_table :phone_numbers do |p| 4 | p.string :country_code 5 | p.string :area_code 6 | p.string :number 7 | p.integer :user_id 8 | p.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/fixtures/models/address.rb: -------------------------------------------------------------------------------- 1 | class Address < ActiveRecord::Base 2 | belongs_to :user 3 | 4 | attr_accessible :address1, :address2, :city, :state, :zipcode, :alarm_code, :user_id 5 | 6 | validates_presence_of :address1, :city, :state, :zipcode 7 | end 8 | -------------------------------------------------------------------------------- /spec/fixtures/models/phone_number.rb: -------------------------------------------------------------------------------- 1 | class PhoneNumber < ActiveRecord::Base 2 | validates_presence_of :country_code, :area_code, :number 3 | 4 | attr_accessible :country_code, :area_code, :number 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | has_many :addresses 3 | has_one :phone_number 4 | 5 | attr_accessible :name, :social_security_number 6 | 7 | validates_presence_of :name 8 | validate :name_contains_at_least_two_parts 9 | 10 | def name_contains_at_least_two_parts 11 | unless name =~ /.+ .+/ 12 | errors.add(:name, "should contain at least two parts") 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/fixtures/redtape/nested_form_redtape.rb: -------------------------------------------------------------------------------- 1 | module NestedFormRedtape 2 | def populate_individual_record(record, attrs) 3 | if record.is_a?(User) 4 | record.name = "#{attrs[:first_name]} #{attrs[:last_name]}" 5 | elsif record.is_a?(Address) 6 | record.attributes = record.attributes.merge(attrs) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/redtape/registration_redtape.rb: -------------------------------------------------------------------------------- 1 | module RegistrationRedtape 2 | def populate_individual_record(record, attrs) 3 | if record.is_a?(User) 4 | record.name = "#{attrs[:first_name]} #{attrs[:last_name]}" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/form_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class RegistrationController 4 | include RegistrationRedtape 5 | end 6 | 7 | describe Redtape::Form do 8 | subject { Redtape::Form.new(controller_stub, :top_level_name => :user) } 9 | 10 | context "given a Form accepting a first and last name that creates a User" do 11 | context "with valid data" do 12 | let (:controller_stub) { 13 | RegistrationController.new.tap do |c| 14 | c.stub(:params).and_return({ 15 | :user => { 16 | :first_name => "Evan", 17 | :last_name => "Light" 18 | } 19 | }) 20 | end 21 | } 22 | 23 | context "after saving the form" do 24 | before do 25 | subject.save 26 | end 27 | 28 | specify { subject.should be_valid } 29 | specify { subject.model.should be_valid } 30 | specify { subject.model.should be_persisted } 31 | end 32 | 33 | context "after validating the form" do 34 | before do 35 | subject.valid? 36 | end 37 | 38 | specify { subject.model.should be_valid } 39 | end 40 | end 41 | 42 | context "with invalid data" do 43 | let (:controller_stub) { 44 | RegistrationController.new.tap do |c| 45 | c.stub(:params).and_return({ 46 | :user => { 47 | :first_name => "Evan" 48 | } 49 | }) 50 | end 51 | } 52 | 53 | context "after saving the form" do 54 | before do 55 | subject.save 56 | end 57 | 58 | specify { subject.should_not be_valid } 59 | specify { subject.should_not be_persisted } 60 | specify { subject.errors.should have_key(:name) } 61 | specify { subject.model.should_not be_valid } 62 | end 63 | end 64 | end 65 | 66 | context "Creating a Redtape::Form that provides whitelisted attrs and a #populate_individual_record impl" do 67 | let(:controller_stub) { 68 | RegistrationController.new.tap { |c| 69 | c.stub(:params => { 70 | :user => { :first_name => "Evan " } 71 | }) 72 | } 73 | } 74 | 75 | it "should raise a DuelingBanjosError" do 76 | expect { 77 | Redtape::Form.new(controller_stub, :whitelisted_attrs => { :user => [:name] }) 78 | }.to raise_error(Redtape::DuelingBanjosError) 79 | end 80 | end 81 | end 82 | 83 | -------------------------------------------------------------------------------- /spec/lint_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class RegistrationController 4 | def params 5 | { 6 | :user => { 7 | :name => "Ohai ohai" 8 | } 9 | } 10 | end 11 | end 12 | 13 | require 'minitest/unit' 14 | 15 | describe "my looks like model class" do 16 | include ActiveModel::Lint::Tests 17 | include MiniTest::Assertions 18 | 19 | subject { Redtape::Form.new(RegistrationController.new, :top_level_name => :user) } 20 | 21 | def model 22 | subject 23 | end 24 | 25 | # to_s is to support ruby-1.9 26 | ActiveModel::Lint::Tests.public_instance_methods.map{|m| m.to_s}.grep(/^test/).each do |m| 27 | example m.gsub('_',' ') do 28 | send m 29 | end 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /spec/nested_form_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class NestedFormController 4 | include NestedFormRedtape 5 | end 6 | 7 | describe Redtape::Form do 8 | subject { Redtape::Form.new(controller_stub, :top_level_name => :user) } 9 | 10 | let(:create_params) { 11 | HashWithIndifferentAccess.new( 12 | :user => { 13 | :first_name => "Evan", 14 | :last_name => "Light", 15 | :addresses_attributes => { 16 | "0" => { 17 | :address1 => "123 Foobar way", 18 | :city => "Foobar", 19 | :state => "MN", 20 | :zipcode => "12345" 21 | }, 22 | "1" => { 23 | :address1 => "124 Foobar way", 24 | :city => "Foobar", 25 | :state => "MN", 26 | :zipcode => "12345" 27 | } 28 | } 29 | } 30 | ) 31 | } 32 | 33 | context "Creating a user" do 34 | context "where simulating a nested form from the view for a User with many Addresses" do 35 | context "where the Address form fields adhere to Address column names" do 36 | let(:controller_stub) { 37 | NestedFormController.new.tap do |c| 38 | c.stub(:params).and_return(create_params) 39 | end 40 | } 41 | 42 | before do 43 | subject.save 44 | end 45 | 46 | specify { subject.model.addresses.count.should == 2 } 47 | end 48 | end 49 | end 50 | 51 | context "Updating a user who has addresses" do 52 | context "where simulating a nested form from the view for a User with many Addresses" do 53 | context "where the Address form fields adhere to Address column names" do 54 | before do 55 | params = create_params 56 | u = User.create!(:name => "#{params[:user][:first_name]} #{params[:user][:last_name]}") 57 | @address1 = Address.create!( 58 | params[:user][:addresses_attributes]["0"].merge(:user_id => u.id) 59 | ) 60 | @address2 = Address.create!( 61 | params[:user][:addresses_attributes]["1"].merge(:user_id => u.id) 62 | ) 63 | end 64 | 65 | let(:update_params) { 66 | HashWithIndifferentAccess.new( 67 | :user => { 68 | :id => User.last.id, 69 | :first_name => "Evan", 70 | :last_name => "Not-so-bright-light", 71 | :addresses_attributes => { 72 | "0" => { 73 | :id => @address1.id, 74 | :address1 => "456 Foobar way", 75 | :city => "Foobar", 76 | :state => "MN", 77 | :zipcode => "12345" 78 | }, 79 | "1" => { 80 | :id => @address2.id, 81 | :address1 => "124 Foobar way", 82 | :city => "Foobar", 83 | :state => "MN", 84 | :zipcode => "12345" 85 | } 86 | } 87 | } 88 | ) 89 | } 90 | 91 | let(:controller_stub) { 92 | NestedFormController.new.tap do |c| 93 | c.stub(:params).and_return(update_params) 94 | end 95 | } 96 | 97 | 98 | 99 | specify { 100 | lambda { subject.save }.should_not change(User, :count) 101 | } 102 | 103 | specify { 104 | lambda { subject.save }.should_not change(Address, :count) 105 | } 106 | 107 | specify { 108 | subject.save 109 | User.last.name.should == 110 | "#{update_params[:user][:first_name]} #{update_params[:user][:last_name]}" 111 | } 112 | 113 | specify { 114 | subject.save 115 | User.last.addresses.first.address1.should == 116 | update_params[:user][:addresses_attributes]["0"][:address1] 117 | } 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/hash/indifferent_access' 2 | 3 | require 'redtape' 4 | require 'active_record' 5 | require 'pry' 6 | 7 | ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => 'development.db') 8 | 9 | %w[models redtape].each do |path| 10 | Dir.glob("./spec/fixtures/#{path}/*").each do |r| 11 | require r[0...-3] 12 | end 13 | end 14 | 15 | RSpec.configure do |config| 16 | config.after(:each) do 17 | User.destroy_all 18 | Address.destroy_all 19 | PhoneNumber.destroy_all 20 | end 21 | end 22 | 23 | ActiveRecord::Migrator.migrate("./spec/fixtures/db/migrate/") 24 | --------------------------------------------------------------------------------