├── .gitignore ├── Gemfile ├── gemfiles ├── rails_4_2.gemfile └── rails_5_0.gemfile ├── test ├── support │ └── models.rb ├── schema.rb ├── test_helper.rb └── tokens │ ├── tokens_test.rb │ └── active_record_test.rb ├── lib ├── tokens │ ├── version.rb │ ├── railtie.rb │ ├── generator.rb │ ├── token.rb │ └── active_record.rb └── tokens.rb ├── Rakefile ├── .travis.yml ├── templates └── tokens.rb ├── tokens.gemspec └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | .bundle 3 | *.log 4 | *.lock 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | gemspec 3 | -------------------------------------------------------------------------------- /gemfiles/rails_4_2.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec path: ".." 3 | 4 | gem "rails", "~> 4.2.0" 5 | -------------------------------------------------------------------------------- /gemfiles/rails_5_0.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec path: ".." 3 | 4 | gem "rails", "5.0.0.beta3" 5 | -------------------------------------------------------------------------------- /test/support/models.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | tokenizable 3 | end 4 | 5 | class Post < ActiveRecord::Base 6 | tokenizable 7 | end 8 | -------------------------------------------------------------------------------- /lib/tokens/version.rb: -------------------------------------------------------------------------------- 1 | module Tokens 2 | module Version 3 | MAJOR = 2 4 | MINOR = 1 5 | PATCH = 1 6 | STRING = "#{MAJOR}.#{MINOR}.#{PATCH}" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/tokens/railtie.rb: -------------------------------------------------------------------------------- 1 | require "rails/railtie" 2 | 3 | module Tokens 4 | class Railtie < Rails::Railtie 5 | generators do 6 | require "tokens/generator" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.test_files = FileList["test/**/*_test.rb"] 7 | t.warning = false 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /lib/tokens.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | require "securerandom" 3 | 4 | require "tokens/active_record" 5 | require "tokens/token" 6 | require "tokens/version" 7 | require "tokens/railtie" if defined?(Rails) 8 | 9 | ::ActiveRecord::Base.send :include, Tokens::ActiveRecord 10 | -------------------------------------------------------------------------------- /lib/tokens/generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/base" 2 | 3 | module Tokens 4 | class InstallGenerator < ::Rails::Generators::Base 5 | source_root File.dirname(__FILE__) + "/../../templates" 6 | 7 | def copy_migrations 8 | stamp = proc {|time| time.utc.strftime("%Y%m%d%H%M%S")} 9 | copy_file "tokens.rb", "db/migrate/#{stamp[Time.now]}_create_tokens.rb" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | cache: bundler 4 | rvm: 5 | - '2.3.0' 6 | - '2.2.4' 7 | gemfile: 8 | - gemfiles/rails_4_2.gemfile 9 | - gemfiles/rails_5_0.gemfile 10 | - Gemfile 11 | notifications: 12 | email: false 13 | env: 14 | global: 15 | secure: vOCWAt9QPpbVRH8M2xowCnu31av1kqjB20nlg24aNrf7rWccNi0Il6KpiiCXkCHeJfFBNGHxBcWehZy0If2FiJEjFv/3IhkxzyMRTB+PkOK3i+PEwdVXawdDhkaBnRFqrslxsb1rcfOlIvVVnqnHEMTYEd+Twpd0d4G7idcv4NQ= 16 | -------------------------------------------------------------------------------- /lib/tokens/token.rb: -------------------------------------------------------------------------------- 1 | class Token < ActiveRecord::Base 2 | belongs_to :tokenizable, polymorphic: true 3 | serialize :data, Tokens::ActiveRecord::Serializer 4 | 5 | attr_readonly :tokenizable_id, :tokenizable_type 6 | 7 | def self.clean 8 | where("expires_at < ? AND expires_at IS NOT NULL", Time.now).delete_all 9 | end 10 | 11 | def data 12 | read_attribute(:data) || {} 13 | end 14 | 15 | def to_s 16 | token 17 | end 18 | 19 | def expired? 20 | expires_at && expires_at < Time.now 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /templates/tokens.rb: -------------------------------------------------------------------------------- 1 | class CreateTokens < ActiveRecord::Migration 2 | def change 3 | create_table :tokens do |t| 4 | t.string :name, null: false 5 | t.belongs_to :tokenizable, null: false, polymorphic: true 6 | t.string :token, null: false 7 | t.text :data, null: true 8 | t.datetime :expires_at, null: true 9 | t.datetime :created_at, null: false 10 | end 11 | 12 | add_index :tokens, [:tokenizable_type, :tokenizable_id] 13 | add_index :tokens, :token 14 | add_index :tokens, :expires_at 15 | add_index :tokens, [:tokenizable_id, :tokenizable_type, :name], unique: true 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define(:version => 0) do 2 | create_table :users do |t| 3 | t.string :name 4 | end 5 | 6 | create_table :posts do |t| 7 | t.string :title 8 | end 9 | 10 | create_table :tokens do |t| 11 | t.string :name, null: false 12 | t.belongs_to :tokenizable, null: false, polymorphic: true 13 | t.string :token, null: false 14 | t.text :data, null: true 15 | t.datetime :expires_at, null: true 16 | t.datetime :created_at, null: false 17 | end 18 | 19 | add_index :tokens, :tokenizable_type 20 | add_index :tokens, :tokenizable_id 21 | add_index :tokens, [:tokenizable_type, :tokenizable_id] 22 | add_index :tokens, :token 23 | add_index :tokens, :expires_at 24 | add_index :tokens, [:tokenizable_id, :tokenizable_type, :name], unique: true 25 | end 26 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "codeclimate-test-reporter" 2 | CodeClimate::TestReporter.start 3 | 4 | ENV["RAILS_ENV"] = "test" 5 | 6 | require "bundler/setup" 7 | require "rails" 8 | require "rails/railtie" 9 | require "action_controller/railtie" 10 | require "tokens" 11 | 12 | require "minitest/utils" 13 | require "minitest/autorun" 14 | 15 | module Tokens 16 | class Application < Rails::Application 17 | config.root = File.dirname(__FILE__) + "/.." 18 | config.active_support.deprecation = :log 19 | config.eager_load = true 20 | end 21 | end 22 | 23 | Tokens::Application.initialize! 24 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" 25 | 26 | # Load database schema 27 | begin 28 | load File.dirname(__FILE__) + "/schema.rb" 29 | rescue Exception => e 30 | p e 31 | end 32 | 33 | require "support/models" 34 | 35 | module Minitest 36 | class Test 37 | setup do 38 | User.delete_all 39 | Post.delete_all 40 | Token.delete_all 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /tokens.gemspec: -------------------------------------------------------------------------------- 1 | require "./lib/tokens/version" 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "tokens" 5 | s.version = Tokens::Version::STRING 6 | s.platform = Gem::Platform::RUBY 7 | s.authors = ["Nando Vieira"] 8 | s.email = ["fnando.vieira@gmail.com"] 9 | s.homepage = "http://rubygems.org/gems/tokens" 10 | s.summary = "Generate named tokens on your ActiveRecord models." 11 | s.description = s.summary 12 | 13 | s.files = `git ls-files`.split("\n") 14 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 15 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 16 | s.require_paths = ["lib"] 17 | 18 | s.add_development_dependency "rails" 19 | s.add_development_dependency "rake" 20 | s.add_development_dependency "minitest-utils" 21 | s.add_development_dependency "mocha" 22 | s.add_development_dependency "sqlite3" 23 | s.add_development_dependency "pry-meta" 24 | s.add_development_dependency "codeclimate-test-reporter" 25 | end 26 | -------------------------------------------------------------------------------- /test/tokens/tokens_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TokensTest < Minitest::Test 4 | setup do 5 | @user = User.create(name: "Homer") 6 | @another_user = User.create(name: "Bart") 7 | @post = Post.create(title: "How to make donuts") 8 | @expire = 3.days.from_now 9 | end 10 | 11 | test "has tokens association" do 12 | @user.tokens 13 | end 14 | 15 | test "removes all expired tokens" do 16 | %w(uid activation_code reset_password_code).each do |name| 17 | @user.add_token(name, :expires_at => 3.days.ago) 18 | end 19 | 20 | assert_equal 3, Token.clean 21 | end 22 | 23 | test "generates token without saving it" do 24 | count = Token.count 25 | User.generate_token(32) 26 | 27 | assert_equal count, Token.count 28 | end 29 | 30 | test "generates token with custom size" do 31 | assert_equal 8, User.generate_token(8).size 32 | end 33 | 34 | test "sets alias for token method" do 35 | token = @user.add_token(:uid) 36 | assert_equal token.token, token.to_s 37 | end 38 | 39 | test "finds user by token" do 40 | token = @user.add_token(:uid) 41 | assert_equal @user, User.find_by_token(:uid, token.to_s) 42 | end 43 | 44 | test "returns user by its valid token without expiration time" do 45 | token = @user.add_token(:uid) 46 | assert_equal @user, User.find_by_valid_token(:uid, token.to_s) 47 | end 48 | 49 | test "returns user by its valid token with expiration time" do 50 | token = @user.add_token(:uid, :expires_at => @expire) 51 | assert_equal @user, User.find_by_valid_token(:uid, token.to_s) 52 | end 53 | 54 | test "finds token using class method with one argument (hash only)" do 55 | token = @user.add_token(:uid) 56 | assert_equal token, User.find_token(:name => :uid, :token => token.to_s) 57 | end 58 | 59 | test "doesn't conflict with other models" do 60 | user_token = @user.add_token(:uid) 61 | post_token = @post.add_token(:uid) 62 | 63 | assert_nil User.find_token(post_token.to_s) 64 | User.find_token(name: :uid) 65 | end 66 | 67 | test "to_s should return hash" do 68 | token = @user.add_token(:uid) 69 | assert_equal token.to_s, token.to_s 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/tokens/active_record_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ActiveRecordTest < Minitest::Test 4 | setup do 5 | @user = User.create(name: "Homer") 6 | @another_user = User.create(name: "Bart") 7 | @post = Post.create(title: "How to make donuts") 8 | @expire = 3.days.from_now 9 | end 10 | 11 | test "is created" do 12 | count = Token.count 13 | @user.add_token(:uid) 14 | 15 | assert_equal count + 1, Token.count 16 | end 17 | 18 | test "is created for different users" do 19 | assert @user.add_token(:uid).valid? 20 | assert @another_user.add_token(:uid).valid? 21 | end 22 | 23 | test "is created with expiration date" do 24 | assert_equal @expire, @user.add_token(:uid, expires_at: @expire).expires_at 25 | end 26 | 27 | test "serializes data" do 28 | token = @user.add_token(:uid, data: {name: "John Doe"}) 29 | token.reload 30 | 31 | assert_equal "John Doe", token.data["name"] 32 | end 33 | 34 | test "returns empty hash as serialized data" do 35 | assert_equal Hash.new, Token.new.data 36 | end 37 | 38 | test "is created with custom size" do 39 | assert_equal 6, @user.add_token(:uid, :size => 6).to_s.size 40 | end 41 | 42 | test "finds token by its name" do 43 | token = @user.add_token(:uid) 44 | assert_equal token, @user.find_token_by_name(:uid) 45 | end 46 | 47 | test "returns nil nil when no token is found" do 48 | assert_nil @user.find_token(:uid, "abcdef") 49 | assert_nil @user.find_token_by_name(:uid) 50 | end 51 | 52 | test "is a valid token" do 53 | token = @user.add_token(:uid) 54 | assert @user.valid_token?(:uid, token.to_s) 55 | end 56 | 57 | test "isn't a valid token" do 58 | refute @user.valid_token?(:uid, "invalid") 59 | end 60 | 61 | test "finds token by its name and hash" do 62 | token = @user.add_token(:uid) 63 | assert_equal token, @user.find_token(:uid, token.to_s) 64 | end 65 | 66 | test "isn't expired when have no expiration date" do 67 | refute @user.add_token(:uid).expired? 68 | end 69 | 70 | test "isn't expired when have a future expiration date" do 71 | refute @user.add_token(:uid, expires_at: 3.days.from_now).expired? 72 | end 73 | 74 | test "is expired" do 75 | assert @user.add_token(:uid, :expires_at => 3.days.ago).expired? 76 | end 77 | 78 | test "removes token" do 79 | @user.add_token(:uid) 80 | assert @user.remove_token(:uid) 81 | end 82 | 83 | test "doesn't remove other users tokens" do 84 | @user.add_token(:uid) 85 | @another_user.add_token(:uid) 86 | 87 | @user.remove_token(:uid) 88 | 89 | assert_nil @user.find_token_by_name(:uid) 90 | assert_kind_of Token, @another_user.find_token_by_name(:uid) 91 | end 92 | 93 | test "isn't duplicated" do 94 | @user.add_token(:uid) 95 | @user.add_token(:uid) 96 | 97 | assert_equal 1, @user.tokens.where(name: "uid").count 98 | end 99 | 100 | test "returns valid token" do 101 | token = @user.add_token(:uid) 102 | assert_equal token, @user.find_valid_token(:uid, token.to_s) 103 | end 104 | 105 | test "returns nothing for invalid token" do 106 | token = @user.add_token(:uid) 107 | assert_nil @user.find_valid_token(:uid, "invalid") 108 | end 109 | 110 | test "returns nothing for missing token" do 111 | assert_nil @user.find_valid_token(:uid, "invalid") 112 | end 113 | 114 | test "returns nothing for expired token" do 115 | token = @user.add_token(:uid, expires_at: 2.weeks.ago) 116 | assert_nil @user.find_valid_token(:uid, "invalid") 117 | end 118 | 119 | test "creates token with provided value" do 120 | token = @user.add_token(:uid, token: 'abc123') 121 | assert_equal "abc123", token.to_s 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tokens 2 | 3 | [![Travis-CI](https://travis-ci.org/fnando/tokens.png)](https://travis-ci.org/fnando/tokens) 4 | [![Code Climate](https://codeclimate.com/github/fnando/tokens/badges/gpa.svg)](https://codeclimate.com/github/fnando/tokens) 5 | [![Test Coverage](https://codeclimate.com/github/fnando/tokens/badges/coverage.svg)](https://codeclimate.com/github/fnando/tokens/coverage) 6 | [![Gem](https://img.shields.io/gem/v/tokens.svg)](https://rubygems.org/gems/tokens) 7 | [![Gem](https://img.shields.io/gem/dt/tokens.svg)](https://rubygems.org/gems/tokens) 8 | 9 | ## Usage 10 | 11 | ### Installation 12 | 13 | ```bash 14 | gem install tokens 15 | ``` 16 | 17 | ### Setting up 18 | 19 | Add Tokens to your Gemfile and run `rails generate tokens:install`. 20 | This will create a new migration file. Execute it by running `rake db:migrate`. 21 | 22 | Finally, add the macro `tokenizable` to your model and be happy! 23 | 24 | ```ruby 25 | class User < ActiveRecord::Base 26 | tokenizable 27 | end 28 | 29 | # create a new user; remember that the token requires an existing record 30 | # because it depends on its id 31 | user = User.create(username: "fnando") 32 | 33 | # create token that never expires 34 | user.add_token(:activate) 35 | 36 | # uses custom expires_at 37 | user.add_token(:activate, expires_at: 10.days.from_now) 38 | 39 | # uses the default size (12 characters) 40 | user.add_token(:activate) 41 | 42 | # uses custom size (up to 122) 43 | user.add_token(:activate, size: 20) 44 | 45 | # uses custom token value 46 | user.add_token(:activate, token: 'abc123') 47 | 48 | # create token with arbitrary data. 49 | user.add_token(:activate, data: {action: "do something"}) 50 | 51 | # find token by name 52 | user.find_token_by_name(:reset_account) 53 | 54 | # find valid token per user context. 55 | user.find_valid_token(:reset_account, "ea2f14aeac40") 56 | 57 | # find token by hash 58 | user.find_token("ea2f14aeac40") 59 | 60 | # check if a token has expired 61 | user.tokens.first.expired? 62 | 63 | # find user by token 64 | User.find_by_token(:activate, "ea2f14aeac40") 65 | 66 | # remove all expired tokens except those with NULL values 67 | Token.clean 68 | 69 | # generate a token as string, without saving it 70 | User.generate_token 71 | 72 | # remove a token by its name 73 | user.remove_token(:activate) 74 | 75 | # find user by valid token (same name, same hash, not expired) 76 | User.find_by_valid_token(:activate, "ea2f14aeac40") 77 | 78 | # find a token using class scope 79 | User.find_token(:activate, "ea2f14aeac40") 80 | 81 | # Token hash 82 | token.to_s #=> ea2f14aeac40 83 | ``` 84 | 85 | ## License 86 | 87 | Copyright (c) 2008-2013 Nando Vieira, released under the MIT license 88 | 89 | Permission is hereby granted, free of charge, to any person obtaining 90 | a copy of this software and associated documentation files (the 91 | "Software"), to deal in the Software without restriction, including 92 | without limitation the rights to use, copy, modify, merge, publish, 93 | distribute, sublicense, and/or sell copies of the Software, and to 94 | permit persons to whom the Software is furnished to do so, subject to 95 | the following conditions: 96 | 97 | The above copyright notice and this permission notice shall be 98 | included in all copies or substantial portions of the Software. 99 | 100 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 101 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 102 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 103 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 104 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 105 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 106 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 107 | -------------------------------------------------------------------------------- /lib/tokens/active_record.rb: -------------------------------------------------------------------------------- 1 | module Tokens 2 | module ActiveRecord 3 | def self.included(base) 4 | base.class_eval { extend ClassMethods } 5 | end 6 | 7 | module Serializer 8 | class << self 9 | # Set the serializer adapter. Defaults to JSON. 10 | attr_accessor :adapter 11 | end 12 | 13 | require "json" 14 | self.adapter = ::JSON 15 | 16 | def self.load(data) 17 | data ? JSON.load(data) : {} 18 | end 19 | 20 | def self.dump(data) 21 | data ? JSON.dump(data) : nil 22 | end 23 | end 24 | 25 | module ClassMethods 26 | # Set up model for using tokens. 27 | # 28 | # class User < ActiveRecord::Base 29 | # tokenizable 30 | # end 31 | # 32 | def tokenizable 33 | has_many :tokens, as: "tokenizable", dependent: :destroy 34 | include InstanceMethods 35 | end 36 | 37 | # Generate token with specified length. 38 | # 39 | # User.generate_token(10) 40 | # 41 | def generate_token(size) 42 | validity = Proc.new {|token| Token.where(:token => token).first.nil?} 43 | 44 | begin 45 | token = SecureRandom.hex(size)[0, size] 46 | token = token.encode("UTF-8") 47 | end while validity[token] == false 48 | 49 | token 50 | end 51 | 52 | # Find a token 53 | # 54 | # User.find_token(:activation, "abcdefg") 55 | # User.find_token(name: activation, token: "abcdefg") 56 | # User.find_token(name: activation, token: "abcdefg", tokenizable_id: 1) 57 | # 58 | def find_token(*args) 59 | if args.first.kind_of?(Hash) 60 | options = args.first 61 | else 62 | options = { 63 | name: args.first, 64 | token: args.last 65 | } 66 | end 67 | 68 | options.merge!(name: options[:name].to_s, tokenizable_type: self.name) 69 | Token.where(options).includes(:tokenizable).first 70 | end 71 | 72 | # Find object by token. 73 | # 74 | # User.find_by_token(:activation, "abcdefg") 75 | # 76 | def find_by_token(name, hash) 77 | token = find_token(name: name.to_s, token: hash) 78 | return unless token 79 | token.tokenizable 80 | end 81 | 82 | # Find object by valid token (same name, same hash, not expired). 83 | # 84 | # User.find_by_valid_token(:activation, "abcdefg") 85 | # 86 | def find_by_valid_token(name, hash) 87 | token = find_token(name: name.to_s, token: hash) 88 | return if !token || token.expired? 89 | token.tokenizable 90 | end 91 | end 92 | 93 | module InstanceMethods 94 | # Verify if given token is valid. 95 | # 96 | # @user.valid_token?(:active, "abcdefg") 97 | # 98 | def valid_token?(name, hash) 99 | self.tokens.where(name: name.to_s, token: hash.to_s).first != nil 100 | end 101 | 102 | # Find a token. 103 | # 104 | # @user.find_token(:activation, "abcdefg") 105 | # 106 | def find_token(name, token) 107 | self.class.find_token( 108 | tokenizable_id: self.id, 109 | name: name.to_s, 110 | token: token 111 | ) 112 | end 113 | 114 | # Find token by its name. 115 | def find_token_by_name(name) 116 | self.tokens.where(name: name.to_s).first 117 | end 118 | 119 | # Return Token instance when token is valid. 120 | def find_valid_token(name, token) 121 | token = find_token(name, token) 122 | return unless token 123 | !token.expired? && token 124 | end 125 | 126 | # Remove token. 127 | # 128 | # @user.remove_token(:activate) 129 | # 130 | def remove_token(name) 131 | return if new_record? 132 | token = find_token_by_name(name) 133 | token && token.destroy 134 | end 135 | 136 | # Add a new token. 137 | # 138 | # @user.add_token(:api_key, token: 'abc123') 139 | # @user.add_token(:api_key, expires_at: nil) 140 | # @user.add_token(:api_key, size: 20) 141 | # @user.add_token(:api_key, data: {when: Time.now}) 142 | # 143 | def add_token(name, options={}) 144 | options.reverse_merge!({ 145 | expires_at: 2.days.from_now, 146 | size: 12, 147 | data: nil 148 | }) 149 | 150 | remove_token(name) 151 | attrs = { 152 | name: name.to_s, 153 | token: options[:token] || self.class.generate_token(options[:size]), 154 | expires_at: options[:expires_at], 155 | data: options.fetch(:data) || {} 156 | } 157 | 158 | self.tokens.create!(attrs) 159 | end 160 | end 161 | end 162 | end 163 | --------------------------------------------------------------------------------