7 |
--------------------------------------------------------------------------------
/lib/generators/arturo/routes_generator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'rails/generators'
3 |
4 | module Arturo
5 | class RoutesGenerator < Rails::Generators::Base
6 |
7 | def add_mount
8 | if Arturo::Engine.respond_to?(:routes)
9 | route "mount Arturo::Engine => ''"
10 | else
11 | puts "This version of Rails doesn't support Engine-specific routing. Nothing to do."
12 | end
13 | end
14 |
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/generators/arturo/templates/migration.erb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateFeatures < ActiveRecord::Migration<%= migration_version %>
4 | def self.up
5 | create_table :features do |t|
6 | t.string :symbol, null: false
7 | t.integer :deployment_percentage, null: false
8 | # Any additional fields here
9 |
10 | t.timestamps
11 | end
12 | end
13 |
14 | def self.down
15 | drop_table :features
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/spec/dummy_app/app/models/user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class User
3 |
4 | attr_reader :name, :id
5 |
6 | def initialize(options = {})
7 | @name = options[:name]
8 | @admin = options[:admin]
9 | @id = options[:id]
10 | end
11 |
12 | def admin?
13 | !!@admin
14 | end
15 |
16 | def to_s
17 | name
18 | end
19 |
20 | def inspect
21 | type = @admin ? 'Admin' : 'User'
22 | ""
23 | end
24 |
25 | end
26 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yaml:
--------------------------------------------------------------------------------
1 | name: "CodeQL public repository scanning"
2 |
3 | on:
4 | push:
5 | schedule:
6 | - cron: "0 0 * * *"
7 | pull_request_target:
8 | types: [opened, synchronize, reopened]
9 | workflow_dispatch:
10 |
11 | permissions:
12 | contents: read
13 | security-events: write
14 | actions: read
15 | packages: read
16 |
17 | jobs:
18 | trigger-codeql:
19 | uses: zendesk/prodsec-code-scanning/.github/workflows/codeql_advanced_shared.yml@production
20 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'bundler/setup'
2 | require 'bundler/gem_tasks'
3 |
4 | require 'rspec/core/rake_task'
5 | RSpec::Core::RakeTask.new(:spec)
6 | task default: :spec
7 |
8 | require 'rdoc/task'
9 | Rake::RDocTask.new do |rdoc|
10 | version = File.exist?('VERSION') ? File.read('VERSION') : ""
11 |
12 | rdoc.rdoc_dir = 'rdoc'
13 | rdoc.title = "Betsy #{version}"
14 | rdoc.rdoc_files.include('README*')
15 | rdoc.rdoc_files.include('Gemfile')
16 | rdoc.rdoc_files.include('lib/**/*.rb')
17 | end
18 |
--------------------------------------------------------------------------------
/spec/dummy_app/config/initializers/secret_token.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | if DummyApp::Application.config.respond_to?(:secret_key_base=)
4 | DummyApp::Application.config.secret_key_base = '2d93f8060fff84c29e7d212af5f6400626f47ebc1e16b2a2bc4d7562cfbe72d149cc8b8ce73b54f9b79c202cd2eb887000e761e3e7eb387a63fe11a4c557d253'
5 | else
6 | DummyApp::Application.config.secret_token = '2d93f8060fff84c29e7d212af5f6400626f47ebc1e16b2a2bc4d7562cfbe72d149cc8b8ce73b54f9b79c202cd2eb887000e761e3e7eb387a63fe11a4c557d253'
7 | end
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2012-2013 James A. Rosen
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
--------------------------------------------------------------------------------
/arturo.gemspec:
--------------------------------------------------------------------------------
1 | require_relative 'lib/arturo/version'
2 |
3 | Gem::Specification.new do |s|
4 | s.name = 'arturo'
5 | s.version = Arturo::VERSION
6 | s.authors = ['James A. Rosen']
7 | s.email = 'james.a.rosen@gmail.com'
8 |
9 | s.summary = 'Feature sliders, wrapped up in an engine'
10 | s.homepage = 'http://github.com/zendesk/arturo'
11 | s.files = Dir['lib/**/*', 'app/**/*', 'config/**/*'] + %w(README.md CHANGELOG.md LICENSE)
12 | s.description = 'Deploy features incrementally to your users'
13 |
14 | s.license = 'APLv2'
15 | s.required_ruby_version = '>= 3.2'
16 |
17 | s.add_runtime_dependency 'activerecord', '>= 7.2'
18 | end
19 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish to RubyGems.org
2 |
3 | on:
4 | push:
5 | branches: main
6 | paths: lib/arturo/version.rb
7 | workflow_dispatch:
8 |
9 | jobs:
10 | publish:
11 | runs-on: ubuntu-latest
12 | environment: rubygems-publish
13 | if: github.repository_owner == 'zendesk'
14 | permissions:
15 | id-token: write
16 | contents: write
17 | steps:
18 | - uses: actions/checkout@v4
19 | - name: Set up Ruby
20 | uses: ruby/setup-ruby@v1
21 | with:
22 | bundler-cache: false
23 |
24 | - name: Install dependencies
25 | run: bundle install
26 | - uses: rubygems/release-gem@v1
27 |
--------------------------------------------------------------------------------
/lib/arturo/no_such_feature.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module Arturo
3 |
4 | # A Null-Object stand-in for a Feature.
5 | class NoSuchFeature
6 |
7 | attr_reader :symbol
8 |
9 | def initialize(symbol)
10 | raise ArgumentError.new(I18n.t('arturo.no_such_feature.symbol_required')) if symbol.nil?
11 | @symbol = symbol
12 | end
13 |
14 | def enabled_for?(feature_recipient)
15 | false
16 | end
17 |
18 | def name
19 | I18n.t('arturo.no_such_feature.name', :symbol => symbol)
20 | end
21 |
22 | alias_method :to_s, :name
23 |
24 | def inspect
25 | ""
26 | end
27 |
28 | end
29 |
30 | end
31 |
--------------------------------------------------------------------------------
/spec/models/no_such_feature_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 |
4 | describe Arturo::NoSuchFeature do
5 | before do
6 | reset_translations!
7 | end
8 |
9 | let(:feature) { Arturo::NoSuchFeature.new(:an_unknown_feature) }
10 |
11 | it 'is not enabled' do
12 | expect(feature.enabled_for?(nil)).to be(false)
13 | expect(feature.enabled_for?(double('User', to_s: 'Saorse', id: 12))).to be(false)
14 | end
15 |
16 | it 'requires a symbol' do
17 | expect {
18 | Arturo::NoSuchFeature.new(nil)
19 | }.to raise_error(ArgumentError)
20 | end
21 |
22 | it 'responds to to_s' do
23 | expect(feature.to_s).to include(feature.name)
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/assets/javascripts/arturo.js:
--------------------------------------------------------------------------------
1 | if (typeof(jQuery) === 'function') {
2 | jQuery.arturo = {
3 | agentSupportsHTML5Output: ('for' in jQuery('')),
4 |
5 | linkAndShowOutputs: function() {
6 | if (jQuery.arturo.agentSupportsHTML5Output) {
7 | jQuery('.features output,.feature_new output,.feature_edit output').each(function(i, output) {
8 | var output = jQuery(output);
9 | var input = jQuery('#' + output.attr('for'));
10 | input.change(function() {
11 | output.val(input.val());
12 | });
13 | output.removeClass('no_js');
14 | });
15 | }
16 | }
17 | };
18 |
19 | jQuery(function() {
20 | jQuery.arturo.linkAndShowOutputs();
21 | });
22 | }
23 |
--------------------------------------------------------------------------------
/spec/dummy_app/config/application.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require File.expand_path('../boot', __FILE__)
3 |
4 | require 'bundler/setup'
5 | require 'logger'
6 | require 'rails/all'
7 | require 'arturo/engine'
8 |
9 | Bundler.require(:default, Rails.env) if defined?(Bundler)
10 |
11 | module DummyApp
12 | class Application < Rails::Application
13 | config.encoding = "utf-8"
14 | config.filter_parameters += [:password]
15 | config.assets.precompile += %w( arturo.js ) if config.respond_to?(:assets)
16 | config.action_controller.action_on_unpermitted_parameters = :raise
17 | config.active_support.deprecation = :raise
18 | config.secret_key_base = 'dsdsdshjdshdshdshdshjdhjshjsdhjdsjhdshjds'
19 | config.i18n.enforce_available_locales = true
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/generators/arturo/assets_generator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'rails/generators'
3 |
4 | module Arturo
5 | class AssetsGenerator < Rails::Generators::Base
6 |
7 | def self.source_root
8 | File.join(File.dirname(__FILE__), 'templates')
9 | end
10 |
11 | def copy_assets
12 | copy_file 'arturo_customizations.css', 'public/stylesheets/arturo_customizations.css', :skip => true
13 |
14 | unless defined?(Sprockets)
15 | copy_file 'app/assets/stylesheets/arturo.css', 'public/stylesheets/arturo.css', :force => true
16 | copy_file 'app/assets/javascripts/arturo.js', 'public/javascripts/arturo.js'
17 | copy_file 'app/assets/images/colon.png', 'public/images/colon.png'
18 | end
19 | end
20 |
21 | end
22 | end
23 |
24 |
--------------------------------------------------------------------------------
/spec/dummy_app/app/controllers/books_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class BooksController < ApplicationController
3 |
4 | require_feature :books
5 | require_feature :book_holds, :only => :holds
6 |
7 | # instead of a model:
8 | BOOKS = {}
9 |
10 | def show
11 | if (book = requested_book)
12 | render :plain => book
13 | else
14 | render :plain => 'Not Found', :status => 404
15 | end
16 | end
17 |
18 | def holds
19 | if (book = requested_book)
20 | render :plain => "Added hold on #{book}"
21 | else
22 | render :plain => 'Not Found', :status => 404
23 | end
24 | end
25 |
26 | protected
27 |
28 | def requested_book
29 | BOOKS[params[:id].to_s]
30 | end
31 |
32 | def current_user
33 | User.new(:name => "Fred")
34 | end
35 |
36 | end
37 |
--------------------------------------------------------------------------------
/spec/dummy_app/public/javascripts/arturo.js:
--------------------------------------------------------------------------------
1 | if (typeof(jQuery) === 'function') {
2 | jQuery.arturo = {
3 | agentSupportsHTML5Output: ('for' in jQuery('')),
4 |
5 | linkAndShowOutputs: function() {
6 | if (jQuery.arturo.agentSupportsHTML5Output) {
7 | jQuery('.features output,.feature_new output,.feature_edit output').each(function(i, output) {
8 | var output = jQuery(output);
9 | var input = jQuery('#' + output.attr('for'));
10 | input.change(function() {
11 | console.log('input value changed to ' + input.val());
12 | output.val(input.val());
13 | });
14 | output.removeClass('no_js');
15 | });
16 | }
17 | }
18 | };
19 |
20 | jQuery(function() {
21 | jQuery.arturo.linkAndShowOutputs();
22 | });
23 | }
24 |
--------------------------------------------------------------------------------
/lib/generators/arturo/migration_generator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rails/generators'
4 | require 'rails/generators/migration'
5 | require 'rails/generators/active_record'
6 |
7 | module Arturo
8 | class MigrationGenerator < Rails::Generators::Base
9 | include Rails::Generators::Migration
10 |
11 | def self.source_root
12 | File.join(File.dirname(__FILE__), 'templates')
13 | end
14 |
15 | def self.next_migration_number(dirname)
16 | ::ActiveRecord::Generators::Base.next_migration_number(dirname)
17 | end
18 |
19 | def create_migration_file
20 | migration_template 'migration.erb', 'db/migrate/create_features.rb', { migration_version: migration_version }
21 | end
22 |
23 | def migration_version
24 | "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | ENV['RAILS_ENV'] = 'test'
3 | require 'debug/prelude'
4 | require 'debug/config'
5 | require 'dummy_app/config/environment'
6 | require 'rspec/rails'
7 | require 'factory_bot'
8 | require 'timecop'
9 | require 'support/prepare_database'
10 | require 'arturo'
11 | require 'arturo/feature'
12 | require 'arturo/feature_factories'
13 | require 'arturo/test_support'
14 |
15 | RSpec.configure do |config|
16 | config.include ::FactoryBot::Syntax::Methods
17 | config.use_transactional_fixtures = true
18 |
19 | def reset_translations!
20 | I18n.reload!
21 | end
22 |
23 | def define_translation(key, value)
24 | hash = key.to_s.split('.').reverse.inject(value) do |value, key_part|
25 | { key_part.to_sym => value }
26 | end
27 | I18n.backend.store_translations I18n.locale, hash
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/arturo/feature_management.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module Arturo
3 |
4 | # A mixin that is included by Arturo::FeaturesController and is declared
5 | # as a helper for all views. It provides a single method,
6 | # may_manage_features?, that returns whether or not the current user
7 | # may manage features. By default, it is implemented as follows:
8 | #
9 | # def may_manage_features?
10 | # current_user.present? && current_user.admin?
11 | # end
12 | #
13 | # If you would like to change this implementation, it is recommended
14 | # you do so in config/initializers/arturo_initializer.rb
15 | module FeatureManagement
16 |
17 | # @return [true,false] whether the current user may manage features
18 | def may_manage_features?
19 | current_user.present? && current_user.admin?
20 | end
21 |
22 | end
23 |
24 | end
25 |
--------------------------------------------------------------------------------
/lib/generators/arturo/templates/initializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'arturo'
3 | require 'arturo/feature'
4 |
5 | # Configure who may manage features here.
6 | # The following is the default implementation.
7 | # Arturo::FeatureManagement.class_eval do
8 | # def may_manage_features?
9 | # current_user.present? && current_user.admin?
10 | # end
11 | # end
12 |
13 | # Configure what receives features here.
14 | # The following is the default implementation.
15 | # Arturo::FeatureAvailability.class_eval do
16 | # def feature_recipient
17 | # current_user
18 | # end
19 | # end
20 |
21 | # Whitelists and Blacklists:
22 | #
23 | # Enable feature one for all admins:
24 | # Arturo::Feature.whitelist(:feature_one) do |user|
25 | # user.admin?
26 | # end
27 | #
28 | # Disable feature two for all small accounts:
29 | # Arturo::Feature.blacklist(:feature_two) do |user|
30 | # user.account.small?
31 | # end
32 |
--------------------------------------------------------------------------------
/app/views/arturo/features/_form.html.erb:
--------------------------------------------------------------------------------
1 | <%# frozen_string_literal: true %>
2 | <%= form_for(feature, :as => 'feature', :url => (feature.new_record? ? arturo_engine.features_path : arturo_engine.feature_path(feature))) do |form| %>
3 |
17 | <% end %>
18 |
--------------------------------------------------------------------------------
/spec/controllers/controller_filters_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 |
4 | describe BooksController, type: :controller do
5 | before do
6 | BooksController::BOOKS.merge!(
7 | '1' => 'The Varieties of Religious Experience',
8 | '2' => 'Jane Eyre',
9 | '3' => 'Robison Crusoe'
10 | )
11 | create(:feature, symbol: :books, deployment_percentage: 100)
12 | create(:feature, symbol: :book_holds, deployment_percentage: 0)
13 | end
14 |
15 | it 'does not consider on_feature_disabled as an action' do
16 | expect(controller.action_methods).to_not include(:on_feature_disabled)
17 | end
18 |
19 | it 'works with a get on show' do
20 | get :show, params: { id: '2' }
21 |
22 | expect(response).to be_successful
23 | end
24 |
25 | it 'works with a post on holds' do
26 | post :holds, params: { id: '3' }
27 |
28 | expect(response).to have_http_status(:forbidden)
29 | end
30 |
31 | end
32 |
--------------------------------------------------------------------------------
/lib/arturo/controller_filters.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module Arturo
3 |
4 | # Adds before filters to controllers for specifying that actions
5 | # require features to be enabled for the requester.
6 | #
7 | # To configure how the controller responds when the feature is
8 | # *not* enabled, redefine #on_feature_disabled(feature_name).
9 | # It must render or raise an exception.
10 | module ControllerFilters
11 |
12 | def self.included(base)
13 | base.extend Arturo::ControllerFilters::ClassMethods
14 | end
15 |
16 | def on_feature_disabled(feature_name)
17 | render :plain => 'Forbidden', :status => 403
18 | end
19 |
20 | module ClassMethods
21 |
22 | def require_feature(name, options = {})
23 | send(:before_action, options) do |controller|
24 | unless controller.feature_enabled?(name)
25 | controller.on_feature_disabled(name)
26 | end
27 | end
28 | end
29 |
30 | end
31 |
32 | end
33 |
34 | end
35 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | For bug reports, open an [issue](https://github.com/zendesk/arturo/issues)
2 | on GitHub.
3 |
4 | ## Getting Started
5 |
6 | 1. Install dependencies with `bundle install`
7 | 2. Run tests with `rake test`
8 |
9 | ### Releasing a new version
10 | A new version is published to RubyGems.org every time a change to `version.rb` is pushed to the `main` branch.
11 | In short, follow these steps:
12 | 1. Update `version.rb`,
13 | 2. update version in all `Gemfile.lock` files,
14 | 3. merge this change into `main`, and
15 | 4. look at [the action](https://github.com/zendesk/arturo/actions/workflows/publish.yml) for output.
16 |
17 | To create a pre-release from a non-main branch:
18 | 1. change the version in `version.rb` to something like `1.2.0.pre.1` or `2.0.0.beta.2`,
19 | 2. push this change to your branch,
20 | 3. go to [Actions → “Publish to RubyGems.org” on GitHub](https://github.com/zendesk/arturo/actions/workflows/publish.yml),
21 | 4. click the “Run workflow” button,
22 | 5. pick your branch from a dropdown.
23 |
--------------------------------------------------------------------------------
/spec/dummy_app/db/schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # encoding: UTF-8
3 | # This file is auto-generated from the current state of the database. Instead
4 | # of editing this file, please use the migrations feature of Active Record to
5 | # incrementally modify your database, and then regenerate this schema definition.
6 | #
7 | # Note that this schema.rb definition is the authoritative source for your
8 | # database schema. If you need to create the application database on another
9 | # system, you should be using db:schema:load, not running all the migrations
10 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations
11 | # you'll amass, the slower it'll run and the greater likelihood for issues).
12 | #
13 | # It's strongly recommended to check this file into your version control system.
14 |
15 | ActiveRecord::Schema.define(:version => 20101017195547) do
16 |
17 | create_table "features", :force => true do |t|
18 | t.string "symbol", :null => false
19 | t.integer "deployment_percentage", :null => false
20 | t.datetime "created_at"
21 | t.datetime "updated_at"
22 | end
23 |
24 | end
25 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: push
4 |
5 | jobs:
6 | specs:
7 | runs-on: ubuntu-latest
8 |
9 | strategy:
10 | fail-fast: false
11 | matrix:
12 | ruby-version:
13 | - '3.2'
14 | - "3.3"
15 | - "3.4"
16 | gemfile:
17 | - rails7.2
18 | - rails8.0
19 | - rails8.1
20 | name: Ruby ${{ matrix.ruby-version }}, ${{ matrix.gemfile }}
21 | env:
22 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
23 | steps:
24 | - uses: actions/checkout@v4
25 | - name: Set up Ruby
26 | uses: ruby/setup-ruby@v1
27 | with:
28 | ruby-version: ${{ matrix.ruby-version }}
29 | bundler-cache: true
30 | - name: RSpec
31 | run: bundle exec rspec
32 |
33 | specs_successful:
34 | name: Specs passing?
35 | needs: specs
36 | if: always()
37 | runs-on: ubuntu-latest
38 | steps:
39 | - run: |
40 | if ${{ needs.specs.result == 'success' }}
41 | then
42 | echo "All specs pass"
43 | else
44 | echo "Some specs failed"
45 | false
46 | fi
47 |
--------------------------------------------------------------------------------
/lib/arturo/feature_params_support.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module Arturo
3 |
4 | # Mix in to FeaturesController. Provides the logic for getting parameters
5 | # for creating/updated features out of the request.
6 | module FeatureParamsSupport
7 |
8 | module WithoutStrongParams
9 | def feature_params
10 | params[:feature] || {}
11 | end
12 |
13 | def features_params
14 | params[:features] || {}
15 | end
16 | end
17 |
18 | module WithStrongParams
19 | PERMITTED_ATTRIBUTES = [ :symbol, :deployment_percentage ]
20 |
21 | def feature_params
22 | if feature = params[:feature]
23 | feature.permit(PERMITTED_ATTRIBUTES)
24 | end
25 | end
26 |
27 | def features_params
28 | features = params[:features]
29 | features.each do |id, attributes|
30 | attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
31 | features[id] = ActionController::Parameters.new(attributes).permit(*PERMITTED_ATTRIBUTES)
32 | end
33 | end
34 | end
35 |
36 | include defined?(ActionController::Parameters) ? WithStrongParams : WithoutStrongParams
37 |
38 | end
39 |
40 | end
41 |
--------------------------------------------------------------------------------
/.github/workflows/rails_main_testing.yml:
--------------------------------------------------------------------------------
1 | name: Test against Rails main
2 |
3 | on:
4 | schedule:
5 | - cron: "0 0 * * *" # Run every day at 00:00 UTC
6 | workflow_dispatch:
7 | push:
8 |
9 | jobs:
10 | specs:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | fail-fast: false
14 | matrix:
15 | ruby-version:
16 | - "3.4"
17 | gemfile:
18 | - rails_main
19 | name: Ruby ${{ matrix.ruby-version }}, ${{ matrix.gemfile }}
20 | env:
21 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
22 | steps:
23 | - uses: actions/checkout@v4
24 | - name: Set up Ruby
25 | uses: ruby/setup-ruby@v1
26 | with:
27 | ruby-version: ${{ matrix.ruby-version }}
28 | bundler-cache: true
29 | - name: RSpec
30 | run: bundle exec rspec
31 |
32 | specs_successful:
33 | name: Rails Main Specs passing?
34 | needs: specs
35 | if: always()
36 | runs-on: ubuntu-latest
37 | steps:
38 | - run: |
39 | if ${{ needs.specs.result == 'success' }}
40 | then
41 | echo "All specs pass"
42 | else
43 | echo "Some specs failed"
44 | false
45 | fi
46 |
--------------------------------------------------------------------------------
/lib/arturo/feature_availability.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module Arturo
3 |
4 | # A mixin that provides #feature_enabled? and #if_feature_enabled
5 | # methods; to be mixed in by Controllers and Helpers. The including
6 | # class must return some "thing that has features" (e.g. a User, Person,
7 | # or Account) when Arturo.feature_recipient is bound to an instance
8 | # and called.
9 | #
10 | # @see Arturo.feature_recipient
11 | module FeatureAvailability
12 |
13 | def feature_enabled?(symbol_or_feature)
14 | feature = ::Arturo::Feature.to_feature(symbol_or_feature)
15 | return false if feature.blank?
16 | feature.enabled_for?(feature_recipient)
17 | end
18 |
19 | def if_feature_enabled(symbol_or_feature, &block)
20 | if feature_enabled?(symbol_or_feature)
21 | block.call
22 | else
23 | nil
24 | end
25 | end
26 |
27 | # By default, returns current_user.
28 | #
29 | # If you would like to change this implementation, it is recommended
30 | # you do so in config/initializers/arturo_initializer.rb
31 | # @return [Object] the recipient of features.
32 | def feature_recipient
33 | current_user
34 | end
35 |
36 | end
37 |
38 | end
39 |
--------------------------------------------------------------------------------
/gem-public_cert.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIDPDCCAiSgAwIBAgIBADANBgkqhkiG9w0BAQUFADBEMRYwFAYDVQQDDA1qYW1l
3 | cy5hLnJvc2VuMRUwEwYKCZImiZPyLGQBGRYFZ21haWwxEzARBgoJkiaJk/IsZAEZ
4 | FgNjb20wHhcNMTMwNTAxMjIxMzMxWhcNMTQwNTAxMjIxMzMxWjBEMRYwFAYDVQQD
5 | DA1qYW1lcy5hLnJvc2VuMRUwEwYKCZImiZPyLGQBGRYFZ21haWwxEzARBgoJkiaJ
6 | k/IsZAEZFgNjb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDRQLSM
7 | iwNHiF7XcbFTuLjucBG9FRxZxM3/btJuq3k3al2mPxC0Hy1GGKCZiCQBQxHzS0BT
8 | 7NmS/BWG657xCsX5PdkxOMn15LKkkRHOFDohPirUbftkSN3HQLqNORjscJ/elbB7
9 | Y22PhJmkZGbFBrOMw16CXWb6k7dYX/5D2i5CU2SNssBMALFQ4jiKZtwJwauHozSn
10 | 366rEXUc3bWvq/mzTnm34jU0cbZ9GM7QZ0rQUWHLf8hOy5UGkvkATz+JOF7Eyhi5
11 | 7NniKuw7I9uxSGtFtBHy8CoIEkHRijdIUf83yxJa7KuKAeiBRz7rrIJGSb7jSdoL
12 | v7328eQ6Hr1Zp8BtAgMBAAGjOTA3MAkGA1UdEwQCMAAwHQYDVR0OBBYEFMATrfFP
13 | 8jtg3vGVodLesPtYWn7bMAsGA1UdDwQEAwIEsDANBgkqhkiG9w0BAQUFAAOCAQEA
14 | E+LeMyTXq0vA0yY+hyAnJ8twRpsvKIMCumSWzBphjzzMsFyFe1BuoYrIkj1DgyD0
15 | VLp6XCJcsdhiVZPL+wz0iIRPAc2mBbA4QmJR6T6vcPD6XjNye/z+dFGIKscNHtyJ
16 | ocDm2dxySF61lhvEUyvF9rX6k7amDKhJ93V0EOWfACGuHISflgGi9AiY+9+0kXVk
17 | gk0uK6HyG77PA2MG7+s4wLfjVb9vP69ypabAj5TKJ02aavV35EjYVRjM6UbTA/P7
18 | /WrHsXKhYfN8xLplXXGkSt3NS8RFhQFCOIdgsXEljEvZpVxuri5ObH0zqxUNXOlq
19 | 5lEi56n3KkkKRhu1cE61gA==
20 | -----END CERTIFICATE-----
21 |
--------------------------------------------------------------------------------
/app/views/arturo/features/index.html.erb:
--------------------------------------------------------------------------------
1 | <%# frozen_string_literal: true %>
2 |
<%= t('.title') %>
3 |
4 | <%= arturo_flash_messages %>
5 |
6 | <%= form_tag(arturo_engine.features_path, :method => 'put', 'data-update-path' => arturo_engine.feature_path(:id => ':id'), :remote => true) do %>
7 |
33 | <% end %>
34 |
--------------------------------------------------------------------------------
/lib/arturo.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative 'arturo/null_logger'
4 | require_relative 'arturo/special_handling'
5 | require_relative 'arturo/feature_methods'
6 | require_relative 'arturo/feature_availability'
7 | require_relative 'arturo/feature_management'
8 | require_relative 'arturo/feature_caching'
9 | require_relative 'arturo/controller_filters'
10 |
11 | module Arturo
12 | class << self
13 | # Quick check for whether a feature is enabled for a recipient.
14 | # @param [String, Symbol] feature_name
15 | # @param [#id] recipient
16 | # @return [true,false] whether the feature exists and is enabled for the recipient
17 | def feature_enabled_for?(feature_name, recipient)
18 | return false if recipient.nil?
19 |
20 | f = self::Feature.to_feature(feature_name)
21 | f && f.enabled_for?(recipient)
22 | end
23 |
24 | def logger=(logger)
25 | @logger = logger
26 | end
27 |
28 | def logger
29 | @logger || NullLogger.new
30 | end
31 | end
32 | end
33 |
34 | ActiveSupport.on_load(:action_controller) do
35 | include Arturo::FeatureAvailability
36 | include Arturo::ControllerFilters
37 | if respond_to?(:helper)
38 | helper Arturo::FeatureAvailability
39 | helper Arturo::FeatureManagement
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/spec/models/features_helper_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 | require 'arturo/features_helper'
4 |
5 | describe Arturo::FeaturesHelper do
6 | include ActionView::Helpers::TagHelper
7 | include Arturo::FeaturesHelper
8 |
9 | attr_accessor :output_buffer # Used by the features helper
10 |
11 | let(:bad_feature) do
12 | create(:feature).tap do |f|
13 | f.deployment_percentage = 101
14 | f.valid?
15 | end
16 | end
17 |
18 | it 'generates an error message for bad features' do
19 | expected = "
must be less than or equal to 100
"
20 | actual = error_messages_for_feature(bad_feature, :deployment_percentage)
21 |
22 | expect(actual).to eq(expected)
23 | expect(actual).to be_html_safe
24 | end
25 |
26 | it 'sets flash messages' do
27 | html = arturo_flash_messages(
28 | :notice => 'foo',
29 | :error => [ 'bar', 'baz' ]
30 | )
31 | html = Nokogiri::HTML::Document.parse(html)
32 |
33 | expect(html.css('.alert.alert-arturo .close[data-dismiss="alert"]').count).to eq(3)
34 | expect(html.css('.alert-notice').text).to match(/^foo/)
35 | expect(html.css('.alert-error')[0].text).to match( /^bar/)
36 | expect(html.css('.alert-error')[1].text).to match( /^baz/)
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/spec/models/engine_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 |
4 | describe Arturo::Engine do
5 |
6 | it 'includes feature availability' do
7 | expect(ActionController::Base).to be < Arturo::FeatureAvailability
8 | end
9 |
10 | it 'does not define availability methods as actions' do
11 | expect(BooksController.action_methods).to_not include('feature_enabled?')
12 | expect(BooksController.action_methods).to_not include('if_feature_enabled')
13 | expect(BooksController.action_methods).to_not include('feature_recipient')
14 | end
15 |
16 | it 'defines availability as a helper' do
17 | expect(Arturo::FeaturesController._helpers).to be < Arturo::FeatureAvailability
18 | end
19 |
20 | it 'includes filters in controllers' do
21 | expect(ActionController::Base).to be < Arturo::ControllerFilters
22 | end
23 |
24 | it 'does not define filter methods as actions' do
25 | expect(BooksController.action_methods).to_not include('on_feature_disabled')
26 | end
27 |
28 | it 'defines feature management as a helper' do
29 | expect(BooksController._helpers).to be < Arturo::FeatureManagement
30 | end
31 |
32 | it 'does not define feature management methods as actions' do
33 | expect(BooksController.action_methods).to_not include('may_manage_features?')
34 | end
35 |
36 | end
37 |
--------------------------------------------------------------------------------
/spec/models/feature_availability_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 | require 'arturo/features_helper'
4 |
5 | describe Arturo::FeatureAvailability do
6 | let!(:current_user) { double('CurrentUser') }
7 | let!(:helper) { double('Helper', current_user: current_user).tap { |h| h.extend described_class } }
8 |
9 | let(:feature) { create(:feature) }
10 | let(:block) { -> { 'Content that requires a feature' } }
11 |
12 | describe 'if_feature_enabled' do
13 | it 'does not call the block with non existent feature' do
14 | expect(block).to_not receive(:call)
15 | expect(helper.if_feature_enabled(:nonexistent, &block)).to be_nil
16 | end
17 |
18 | it 'uses feature recipient' do
19 | expect(feature).to receive(:enabled_for?).with(current_user)
20 | helper.if_feature_enabled(feature, &block)
21 | end
22 |
23 | it 'does not call the block with disabled feature' do
24 | allow(feature).to receive(:enabled_for?).and_return(false)
25 | expect(block).to_not receive(:call)
26 | expect(helper.if_feature_enabled(feature, &block)).to be_nil
27 | end
28 |
29 | it 'calls the block with enabled feature' do
30 | allow(feature).to receive(:enabled_for?).and_return(true)
31 | expect(block).to receive(:call).and_return('result')
32 | expect(helper.if_feature_enabled(feature, &block)).to eq('result')
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | activerecord:
3 | models:
4 | "arturo/feature": "Feature"
5 | attributes:
6 | "arturo/feature":
7 | symbol: "Symbol"
8 | name: "Name"
9 | deployment_percentage: "Deployment Percentage"
10 | arturo:
11 | feature:
12 | nameless: "(no name)"
13 | features:
14 | index:
15 | title: 'Features'
16 | new: 'New'
17 | none_yet: No features yet.
18 | new:
19 | title: New Feature
20 | legend: "New Feature"
21 | edit:
22 | title: "Edit Feature %{name}"
23 | legend: "Edit Feature %{name}"
24 | feature:
25 | edit: 'Edit'
26 | show:
27 | title: "Feature %{name}"
28 | forbidden:
29 | title: Forbidden
30 | text: You do not have permission to access that resource.
31 | flash:
32 | no_such_feature: "No such feature: %{id}"
33 | error_updating: "Error updating feature %{id}"
34 | updated_many: "Updated %{count} feature(s)"
35 | created: "Created %{name}"
36 | error_creating: "Sorry, there was an error creating the feature."
37 | updated: "Updated %{name}"
38 | error_updating: "Could not update %{name}: %{errors}."
39 | removed: "Removed %{name}"
40 | error_removing: "Sorry, there was an error removing %{name}"
41 | no_such_feature:
42 | name: "NoSuchFeature: %{symbol}"
43 | symbol_required: "NoSuchFeature marker objects must have a symbol."
44 |
--------------------------------------------------------------------------------
/spec/controllers/features_controller_non_admin_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 | require 'arturo/features_controller'
4 |
5 | describe Arturo::FeaturesController, type: :request do
6 |
7 | before do
8 | allow_any_instance_of(Arturo::FeaturesController)
9 | .to receive(:current_user).and_return(nil)
10 | end
11 |
12 | it 'returns forbidden with get on index' do
13 | get '/arturo/features'
14 | expect(response).to have_http_status(:forbidden)
15 | end
16 |
17 | it 'returns forbidden with get on new' do
18 | get '/arturo/features/new'
19 | expect(response).to have_http_status(:forbidden)
20 | end
21 |
22 | it 'returns forbidden with post on create' do
23 | post '/arturo/features', params: { feature: { deployment_percentage: '38' } }
24 |
25 | expect(response).to have_http_status(:forbidden)
26 | end
27 |
28 | it 'returns forbidden with get on show' do
29 | get '/arturo/features/1'
30 | expect(response).to have_http_status(:forbidden)
31 | end
32 |
33 | it 'returns forbidden with get on edit' do
34 | get '/arturo/features/1'
35 | expect(response).to have_http_status(:forbidden)
36 | end
37 |
38 | it 'returns forbidden with put on update' do
39 | put '/arturo/features/1', params: { feature: { deployment_percentage: '81' } }
40 |
41 | expect(response).to have_http_status(:forbidden)
42 | end
43 |
44 | it 'returns forbidden with delete on destroy' do
45 | delete '/arturo/features/1'
46 | expect(response).to have_http_status(:forbidden)
47 | end
48 |
49 | end
50 |
--------------------------------------------------------------------------------
/lib/arturo/test_support.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | Arturo.instance_eval do
3 |
4 | # Enable a feature; create it if necessary.
5 | # For use in testing. Not auto-required on load. To load,
6 | #
7 | # require 'arturo/test_support'
8 | #
9 | # @param [Symbol, String] name the feature name
10 | def enable_feature!(name)
11 | if feature = Arturo::Feature.find_feature(name)
12 | feature = feature.class.find(feature.id) if feature.frozen?
13 | feature.update(:deployment_percentage => 100)
14 | else
15 | Arturo::Feature.create!(:symbol => name, :deployment_percentage => 100)
16 | end
17 | end
18 |
19 | # Disable a feature if it exists.
20 | # For use in testing. Not auto-required on load. To load,
21 | #
22 | # require 'arturo/test_support'
23 | #
24 | # @param [Symbol, String] name the feature name
25 | def disable_feature!(name)
26 | if feature = Arturo::Feature.find_feature(name)
27 | feature = feature.class.find(feature.id) if feature.frozen?
28 | feature.update(:deployment_percentage => 0)
29 | end
30 | end
31 |
32 | # Enable or disable a feature. If enabling, create it if necessary.
33 | # For use in testing. Not auto-required on load. To load,
34 | #
35 | # require 'arturo/test_support'
36 | #
37 | # @param [Symbol, String] name the feature name
38 | # @param Boolean enabled should the feature be enabled?
39 | def set_feature!(name, enabled)
40 | if enabled
41 | enable_feature!(name)
42 | else
43 | disable_feature!(name)
44 | end
45 | end
46 |
47 | end
48 |
--------------------------------------------------------------------------------
/spec/dummy_app/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | DummyApp::Application.configure do
3 | # Settings specified here will take precedence over those in config/environment.rb
4 |
5 | # The test environment is used exclusively to run your application's
6 | # test suite. You never need to work with it otherwise. Remember that
7 | # your test database is "scratch space" for the test suite and is wiped
8 | # and recreated between test runs. Don't rely on the data there!
9 | config.cache_classes = true
10 |
11 | config.eager_load = false
12 |
13 | # Show full error reports and disable caching
14 | config.consider_all_requests_local = true
15 | config.action_controller.perform_caching = false
16 |
17 | # Raise exceptions instead of rendering exception templates
18 | config.action_dispatch.show_exceptions = false
19 |
20 | # Disable request forgery protection in test environment
21 | config.action_controller.allow_forgery_protection = false
22 |
23 | # Tell Action Mailer not to deliver emails to the real world.
24 | # The :test delivery method accumulates sent emails in the
25 | # ActionMailer::Base.deliveries array.
26 | config.action_mailer.delivery_method = :test
27 |
28 | # Use SQL instead of Active Record's schema dumper when creating the test database.
29 | # This is necessary if your schema can't be completely dumped by the schema dumper,
30 | # like if you have constraints or database-specific column types
31 | # config.active_record.schema_format = :sql
32 |
33 | # Print deprecation notices to the stderr
34 | config.active_support.deprecation = :stderr
35 |
36 | config.active_support.test_order = :random
37 | end
38 |
--------------------------------------------------------------------------------
/spec/models/middleware_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 |
4 | describe Arturo::Middleware do
5 | let(:user) { User.new(name: 'Thor', id: 5791) }
6 | let(:feature) { create(:feature) }
7 |
8 | let(:underlying_app) { ->(_env) { [ 200, {}, ['Success']] } }
9 |
10 | def arturo_app(options = {})
11 | options[:feature] ||= feature
12 | Arturo::Middleware.new(underlying_app, options)
13 | end
14 |
15 | it 'returns 404 with no recipient' do
16 | Arturo.enable_feature! feature
17 | status, headers, body = arturo_app.call({})
18 | expect(status).to eq(404)
19 | end
20 |
21 | it 'retursn 404 if feature is disabled' do
22 | Arturo.disable_feature! feature
23 | status, headers, body = arturo_app.call({ 'arturo.recipient' => user })
24 | expect(status).to eq(404)
25 | end
26 |
27 | it 'passes through if feature is enabled' do
28 | Arturo.enable_feature! feature
29 | status, headers, body = arturo_app.call({ 'arturo.recipient' => user })
30 | expect(status).to eq(200)
31 | expect(body).to eq(['Success'])
32 | end
33 |
34 | it 'uses custom on_unavailable' do
35 | fail_app = lambda { |env| [ 403, {}, [ 'Forbidden' ] ] }
36 | Arturo.disable_feature! feature
37 | status, headers, body = arturo_app(on_unavailable: fail_app).call({})
38 | expect(status).to eq(403)
39 | end
40 |
41 | it 'works with feature recipient' do
42 | expect(feature).to receive(:enabled_for?).with(user).and_return(false)
43 | arturo_app.call({ 'arturo.recipient' => user })
44 | end
45 |
46 | it 'works with custom feature recipient key' do
47 | expect(feature).to receive(:enabled_for?).with(user).and_return(false)
48 | arturo_app(recipient: 'warden.user').call({ 'warden.user' => user })
49 | end
50 |
51 | end
52 |
--------------------------------------------------------------------------------
/app/helpers/arturo/features_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module Arturo
3 | module FeaturesHelper
4 | include ActionView::Helpers::TagHelper
5 |
6 | def arturo_flash_messages(flash = self.flash)
7 | [ :success, :notice, :error ].inject(''.html_safe) do |output, status|
8 | [* flash[status] ].each do |messages|
9 | output += arturo_flash_message(status, messages)
10 | end
11 | output
12 | end
13 | end
14 |
15 | def arturo_flash_message(status, message)
16 | content_tag(:div, :class => "alert alert-#{status} alert-arturo") do
17 | close = content_tag(:a, '×'.html_safe, :href => '#', :class => 'close', 'data-dismiss' => 'alert')
18 | content_tag(:span, message) + close
19 | end
20 | end
21 |
22 | def deployment_percentage_range_and_output_tags(name, value, options = {})
23 | id = sanitize_to_id(name)
24 | options = {
25 | 'type' => 'range',
26 | 'name' => name,
27 | 'id' => id,
28 | 'value' => value,
29 | 'min' => '0',
30 | 'max' => '100',
31 | 'step' => '1',
32 | 'class' => 'deployment_percentage'
33 | }.update(options.stringify_keys)
34 | tag(:input, options) + deployment_percentage_output_tag(id, value)
35 | end
36 |
37 | def deployment_percentage_output_tag(id, value)
38 | content_tag(:output, value, { 'for' => id, 'class' => 'deployment_percentage no_js' })
39 | end
40 |
41 | def error_messages_for_feature(feature, attribute)
42 | if feature.errors[attribute].any?
43 | content_tag(:ul, :class => 'errors') do
44 | feature.errors[attribute].map { |msg| content_tag(:li, msg, :class => 'error') }.join('').html_safe
45 | end
46 | else
47 | ''
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/arturo/middleware.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module Arturo
3 | # A Rack middleware that requires a feature to be present. By default,
4 | # checks feature availability against an `arturo.recipient` object
5 | # in the `env`. If that object is missing, this middleware always fails,
6 | # even if the feature is available for everyone.
7 | #
8 | # ## Usage
9 | #
10 | # use Arturo::Middleware, :feature => :foo
11 | #
12 | # ## Options
13 | #
14 | # * feature -- the name of the feature to require, as a Symbol; required
15 | #
16 | # * recipient -- the key in the `env` hash under which the feature
17 | # recipient can be found; defaults to "arturo.recipient".
18 | # * on_unavailable -- a Rack-like object
19 | # (has `#call(Hash) -> [status, headers, body]`) that
20 | # is called when the feature is unavailable; defaults
21 | # to returning `[ 404, {}, ['Not Found'] ]`.
22 | class Middleware
23 |
24 | MISSING_FEATURE_ERROR = "Cannot create an Arturo::Middleware without a :feature"
25 |
26 | DEFAULT_RECIPIENT_KEY = 'arturo.recipient'
27 |
28 | DEFAULT_ON_UNAVAILABLE = lambda { |env| [ 404, {}, ['Not Found'] ] }
29 |
30 | def initialize(app, options = {})
31 | @app = app
32 | @feature = options[:feature]
33 | raise ArgumentError.new(MISSING_FEATURE_ERROR) unless @feature
34 | @recipient_key = options[:recipient] || DEFAULT_RECIPIENT_KEY
35 | @on_unavailable = options[:on_unavailable] || DEFAULT_ON_UNAVAILABLE
36 | end
37 |
38 | def call(env)
39 | if enabled_for_recipient?(env)
40 | @app.call(env)
41 | else
42 | fail(env)
43 | end
44 | end
45 |
46 | private
47 |
48 | def enabled_for_recipient?(env)
49 | ::Arturo.feature_enabled_for?(@feature, recipient(env))
50 | end
51 |
52 | def recipient(env)
53 | env[@recipient_key]
54 | end
55 |
56 | def fail(env)
57 | @on_unavailable.call(env)
58 | end
59 |
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/arturo.css:
--------------------------------------------------------------------------------
1 | /*
2 | WARNING:
3 |
4 | Do not edit this file. Any changes you make to this file will be overwritten
5 | when you regenerate the arturo assets (which happens when you upgrade the gem).
6 | Instead, make customizations to arturo_customizations.css.
7 | */
8 |
9 | .features code.symbol:before { content: ":"; }
10 |
11 | .features { border-collapse: collapse; }
12 |
13 | .features thead tr:last-child th { border-bottom: 1px solid; }
14 | .features tfoot tr:first-child th { border-top: 1px solid; }
15 |
16 | .features th, .features td {
17 | margin: 0;
18 | padding: 0.5em 1.5em;
19 | text-align: left;
20 | }
21 |
22 | input.deployment_percentage[type=range] { width: 200px; }
23 |
24 | output.deployment_percentage.no_js { display: none; }
25 | output.deployment_percentage { margin-left: 1em; }
26 | output.deployment_percentage:after { content: "%"; }
27 |
28 | .features a[rel=edit] { visibility: hidden; }
29 | .features tr:hover a[rel=edit] { visibility: inherit; }
30 |
31 | .features tfoot th {
32 | text-align: right;
33 | }
34 |
35 | .features tfoot th * + * {
36 | margin-left: 2em;
37 | }
38 |
39 | .feature_new label, .feature_edit label { font-weight: bold; }
40 |
41 | .feature_new label, .feature_new .errors,
42 | .feature_edit label, .feature_edit .errors {
43 | display: block;
44 | }
45 |
46 | .feature_new label + input, .feature_new label + textarea, .feature_new label + select,
47 | .feature_edit label + input, .feature_edit label + textarea, .feature_edit label + select {
48 | margin-top: 0.5em;
49 | }
50 |
51 | .feature_new input + label, .feature_new textarea + label, .feature_new select + label,
52 | .feature_edit input + label, .feature_edit textarea + label, .feature_edit select + label {
53 | margin-top: 1.5em;
54 | }
55 |
56 | .feature_new input[type=text], .feature_edit input[type=text] { padding: 0.5em; }
57 |
58 | .feature_new input.symbol, .feature_edit input.symbol {
59 | background: transparent url('/images/colon.png') no-repeat 3px 4px;
60 | font-family: "DejaVu Sans Mono", "Droid Sans Mono", "Mondale", monospace;
61 | padding-left: 9px;
62 | }
63 |
64 | .feature_new .errors, .feature_edit .errors { color: red; }
65 | .feature_new :invalid { border-color: red; }
66 |
67 | .feature_new footer, .feature_edit footer { margin-top: 2em; }
68 |
--------------------------------------------------------------------------------
/spec/dummy_app/public/stylesheets/arturo.css:
--------------------------------------------------------------------------------
1 | /*
2 | WARNING:
3 |
4 | Do not edit this file. Any changes you make to this file will be overwritten
5 | when you regenerate the arturo assets (which happens when you upgrade the gem).
6 | Instead, make customizations to arturo_customizations.css.
7 | */
8 |
9 | .features code.symbol:before { content: ":"; }
10 |
11 | .features { border-collapse: collapse; }
12 |
13 | .features thead tr:last-child th { border-bottom: 1px solid; }
14 | .features tfoot tr:first-child th { border-top: 1px solid; }
15 |
16 | .features th, .features td {
17 | margin: 0;
18 | padding: 0.5em 1.5em;
19 | text-align: left;
20 | }
21 |
22 | input.deployment_percentage[type=range] { width: 200px; }
23 |
24 | output.deployment_percentage.no_js { display: none; }
25 | output.deployment_percentage { margin-left: 1em; }
26 | output.deployment_percentage:after { content: "%"; }
27 |
28 | .features a[rel=edit] { visibility: hidden; }
29 | .features tr:hover a[rel=edit] { visibility: inherit; }
30 |
31 | .features tfoot th {
32 | text-align: right;
33 | }
34 |
35 | .features tfoot th * + * {
36 | margin-left: 2em;
37 | }
38 |
39 | .feature_new label, .feature_edit label { font-weight: bold; }
40 |
41 | .feature_new label, .feature_new .errors,
42 | .feature_edit label, .feature_edit .errors {
43 | display: block;
44 | }
45 |
46 | .feature_new label + input, .feature_new label + textarea, .feature_new label + select,
47 | .feature_edit label + input, .feature_edit label + textarea, .feature_edit label + select {
48 | margin-top: 0.5em;
49 | }
50 |
51 | .feature_new input + label, .feature_new textarea + label, .feature_new select + label,
52 | .feature_edit input + label, .feature_edit textarea + label, .feature_edit select + label {
53 | margin-top: 1.5em;
54 | }
55 |
56 | .feature_new input[type=text], .feature_edit input[type=text] { padding: 0.5em; }
57 |
58 | .feature_new input.symbol, .feature_edit input.symbol {
59 | background: transparent url('/images/colon.png') no-repeat 3px 4px;
60 | font-family: "DejaVu Sans Mono", "Droid Sans Mono", "Mondale", monospace;
61 | padding-left: 9px;
62 | }
63 |
64 | .feature_new .errors, .feature_edit .errors { color: red; }
65 | .feature_new :invalid { border-color: red; }
66 |
67 | .feature_new footer, .feature_edit footer { margin-top: 2em; }
68 |
--------------------------------------------------------------------------------
/spec/models/whitelist_and_blacklist_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 |
4 | describe 'Whilelist and Blacklist' do
5 |
6 | let(:feature) { create(:feature) }
7 |
8 | before do
9 | Arturo::Feature.whitelists.clear
10 | Arturo::Feature.blacklists.clear
11 | end
12 |
13 | after do
14 | Arturo::Feature.whitelists.clear
15 | Arturo::Feature.blacklists.clear
16 | end
17 |
18 | it 'overrides percent calculation with whitelist' do
19 | feature.deployment_percentage = 0
20 | Arturo::Feature.whitelist(feature.symbol) { |thing| true }
21 | expect(feature.enabled_for?(:a_thing)).to be(true)
22 | end
23 |
24 | it 'overrides percent calculation with blacklist' do
25 | feature.deployment_percentage = 100
26 | Arturo::Feature.blacklist(feature.symbol) { |thing| true }
27 | expect(feature.enabled_for?(:a_thing)).to be(false)
28 | end
29 |
30 | it 'prefers blacklist over whitelist' do
31 | Arturo::Feature.whitelist(feature.symbol) { |thing| true }
32 | Arturo::Feature.blacklist(feature.symbol) { |thing| true }
33 | expect(feature.enabled_for?(:a_thing)).to be(false)
34 | end
35 |
36 | it 'allow a whitelist or blacklist before the feature is created' do
37 | Arturo::Feature.whitelist(:does_not_exist) { |thing| thing == 'whitelisted' }
38 | Arturo::Feature.blacklist(:does_not_exist) { |thing| thing == 'blacklisted' }
39 | feature = create(:feature, symbol: :does_not_exist)
40 | expect(feature.enabled_for?('whitelisted')).to be(true)
41 | expect(feature.enabled_for?('blacklisted')).to be(false)
42 | end
43 |
44 | it 'works with global whitelisting' do
45 | feature.deployment_percentage = 0
46 | other_feature = create(:feature, deployment_percentage: 0)
47 | Arturo::Feature.whitelist { |feature, recipient| feature == other_feature }
48 | expect(feature.enabled_for?(:a_thing)).to be(false)
49 | expect(other_feature.enabled_for?(:a_thing)).to be(true)
50 | end
51 |
52 | it 'works with global blacklisting' do
53 | feature.deployment_percentage = 100
54 | other_feature = create(:feature, deployment_percentage: 100)
55 | Arturo::Feature.blacklist { |feature, recipient| feature == other_feature }
56 | expect(feature.enabled_for?(:a_thing)).to be(true)
57 | expect(other_feature.enabled_for?(:a_thing)).to be(false)
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/arturo/special_handling.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'active_support'
4 |
5 | module Arturo
6 |
7 | # Adds whitelist and blacklist support to individual features by name
8 | # or for all features. Blacklists override whitelists. (In the world of
9 | # Apache, Features are "(deny,allow)".)
10 | # @example
11 | # # allow admins for some_feature:
12 | # Arturo::Feature.whitelist(:some_feature) do |user|
13 | # user.is_admin?
14 | # end
15 | #
16 | # # disallow for small accounts for another_feature:
17 | # Arturo::Feature.blacklist(:another_feature) do |user|
18 | # user.account.small?
19 | # end
20 | #
21 | # # allow large accounts access to large features:
22 | # Arturo::Feature.whitelist do |feature, user|
23 | # feature.symbol.to_s =~ /^large/ && user.account.large?
24 | # end
25 | #
26 | # Blacklists and whitelists can be defined before the feature exists
27 | # and are not persisted, so they are best defined in initializers.
28 | # This is particularly important if your application runs in several
29 | # different processes or on several servers.
30 | module SpecialHandling
31 | extend ActiveSupport::Concern
32 |
33 | class_methods do
34 | def whitelists
35 | @whitelists ||= []
36 | end
37 |
38 | def blacklists
39 | @blacklists ||= []
40 | end
41 |
42 | def whitelist(feature_symbol = nil, &block)
43 | whitelists << two_arg_block(feature_symbol, block)
44 | end
45 |
46 | def blacklist(feature_symbol = nil, &block)
47 | blacklists << two_arg_block(feature_symbol, block)
48 | end
49 |
50 | private
51 |
52 | def two_arg_block(symbol, block)
53 | return block if symbol.nil?
54 | lambda do |feature, recipient|
55 | feature.symbol.to_s == symbol.to_s && block.call(recipient)
56 | end
57 | end
58 |
59 | end
60 |
61 | protected
62 |
63 | def whitelisted?(feature_recipient)
64 | x_listed?(self.class.whitelists, feature_recipient)
65 | end
66 |
67 | def blacklisted?(feature_recipient)
68 | x_listed?(self.class.blacklists, feature_recipient)
69 | end
70 |
71 | def x_listed?(lists, feature_recipient)
72 | lists.any? { |block| block.call(self, feature_recipient) }
73 | end
74 |
75 | end
76 |
77 | end
78 |
--------------------------------------------------------------------------------
/spec/controllers/features_controller_admin_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 | require 'arturo/features_controller'
4 |
5 | describe Arturo::FeaturesController, type: :request do
6 | let!(:current_user) { double('Admin', admin?: true) }
7 | let!(:features) {
8 | [
9 | create(:feature),
10 | create(:feature),
11 | create(:feature)
12 | ]
13 | }
14 |
15 | let(:document_root_element) { Nokogiri::HTML::Document.parse(response.body) }
16 |
17 | before do
18 | allow_any_instance_of(Arturo::FeaturesController)
19 | .to receive(:current_user)
20 | .and_return(current_user)
21 | end
22 |
23 | it 'responds to a get on index' do
24 | get '/arturo/features'
25 | expect(response).to be_successful
26 |
27 | assert_select('table tbody tr input[type=range]')
28 | assert_select("table tfoot a[href='/arturo/features/new']")
29 | assert_select('table tfoot input[type=submit]')
30 | end
31 |
32 | it 'responds to a put on update_all' do
33 | params = {
34 | features: {
35 | features.first.id => { deployment_percentage: '14' },
36 | features.last.id => { deployment_percentage: '98' }
37 | }
38 | }
39 |
40 | put '/arturo/features', params: params
41 |
42 | expect(features.first.reload.deployment_percentage.to_s).to eq('14')
43 | expect(features.last.reload.deployment_percentage.to_s).to eq('98')
44 | expect(response).to redirect_to('/arturo/features')
45 | end
46 |
47 | it 'responds to a get on new' do
48 | get '/arturo/features/new'
49 | expect(response).to be_successful
50 | end
51 |
52 | it 'responds to a post on create' do
53 | post '/arturo/features', params: { feature: { symbol: 'anything' } }
54 |
55 | expect(Arturo::Feature.find_by_symbol('anything')).to be_present
56 | expect(response).to redirect_to('/arturo/features')
57 | end
58 |
59 | def test_get_show
60 | get "/arturo/features/#{@features.first.id}"
61 | expect(response).to be_success
62 | end
63 |
64 | def test_get_edit
65 | get "/arturo/features/#{@features.first.id}/edit"
66 | expect(response).to be_success
67 | end
68 |
69 | def test_put_update
70 | put "/arturo/features/#{@features.first.id}", params: { feature: { deployment_percentage: '2' } }
71 |
72 | expect(response).to redirect_to("/arturo/features/#{@features.first.to_param}")
73 | end
74 |
75 | def test_put_invalid_update
76 | put '/arturo/features/#{@features.first.id}', params: { feature: { deployment_percentage: '-10' } }
77 |
78 | expect(response).to be_success
79 | expect(controller.flash[:alert])
80 | .to eq("Could not update #{@features.first.name}: Deployment Percentage must be greater than or equal to 0.")
81 | end
82 |
83 | def test_delete_destroy
84 | delete "/arturo/features/#{@features.first.id}"
85 | expect(response).to redirect_to('/arturo/features')
86 | end
87 |
88 | end
89 |
--------------------------------------------------------------------------------
/app/controllers/arturo/features_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'action_controller'
3 | require 'arturo/feature_params_support'
4 |
5 | # TODO: this doesn't do anything radically out of the ordinary.
6 | # Are there Rails 3 patterns/mixins/methods I can use
7 | # to clean it up a bit?
8 | module Arturo
9 |
10 | # Handles all Feature actions. Clients of the Arturo engine
11 | # should redefine Arturo::FeaturesController#may_manage_features? to
12 | # return true only for users who are permitted to manage features.
13 | class FeaturesController < ApplicationController
14 | include Arturo::FeatureManagement
15 | include Arturo::FeatureParamsSupport
16 |
17 | respond_to :html, :json, :xml
18 |
19 | before_action :require_permission
20 | before_action :load_feature, :only => [ :show, :edit, :update, :destroy ]
21 |
22 | def index
23 | @features = Arturo::Feature.all
24 | respond_with @features
25 | end
26 |
27 | def update_all
28 | updated_count = 0
29 | errors = []
30 | features_params.each do |id, attributes|
31 | feature = Arturo::Feature.find_by_id(id)
32 | if feature.blank?
33 | errors << t('arturo.features.flash.no_such_feature', :id => id)
34 | elsif feature.update(attributes)
35 | updated_count += 1
36 | else
37 | errors << t('arturo.features.flash.error_updating', :id => id, :errors => feature.errors.full_messages.to_sentence)
38 | end
39 | end
40 | if errors.any?
41 | flash[:error] = errors
42 | else
43 | flash[:success] = t('arturo.features.flash.updated_many', :count => updated_count)
44 | end
45 | redirect_to arturo_engine.features_path
46 | end
47 |
48 | def show
49 | respond_with @feature
50 | end
51 |
52 | def new
53 | @feature = Arturo::Feature.new(feature_params)
54 | respond_with @feature
55 | end
56 |
57 | def create
58 | @feature = Arturo::Feature.new(feature_params)
59 | if @feature.save
60 | flash[:notice] = t('arturo.features.flash.created', :name => @feature.to_s)
61 | redirect_to arturo_engine.features_path
62 | else
63 | flash[:alert] = t('arturo.features.flash.error_creating', :name => @feature.to_s)
64 | render :action => 'new'
65 | end
66 | end
67 |
68 | def edit
69 | respond_with @feature
70 | end
71 |
72 | def update
73 | if @feature.update(feature_params)
74 | flash[:notice] = t('arturo.features.flash.updated', :name => @feature.to_s)
75 | redirect_to arturo_engine.feature_path(@feature)
76 | else
77 | flash[:alert] = t('arturo.features.flash.error_updating', :name => @feature.name, :errors => @feature.errors.full_messages.join("\n"))
78 | render :action => 'edit'
79 | end
80 | end
81 |
82 | def destroy
83 | if @feature.destroy
84 | flash[:notice] = t('arturo.features.flash.removed', :name => @feature.to_s)
85 | else
86 | flash[:alert] = t('arturo.features.flash.error_removing', :name => @feature.to_s)
87 | end
88 | redirect_to arturo_engine.features_path
89 | end
90 |
91 | protected
92 |
93 | def require_permission
94 | unless may_manage_features?
95 | render :action => 'forbidden', :status => 403
96 | return false
97 | end
98 | end
99 |
100 | def load_feature
101 | @feature ||= Arturo::Feature.find(params[:id])
102 | end
103 |
104 | end
105 |
106 | end
107 |
--------------------------------------------------------------------------------
/lib/arturo/feature_methods.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'active_record'
4 | require 'active_support'
5 | require 'active_support/core_ext/hash/indifferent_access'
6 |
7 | module Arturo
8 | module FeatureMethods
9 | extend ActiveSupport::Concern
10 | include Arturo::SpecialHandling
11 |
12 | SYMBOL_REGEX = /^[a-zA-z][a-zA-Z0-9_]*$/
13 | DEFAULT_ATTRIBUTES = { :deployment_percentage => 0 }.with_indifferent_access
14 |
15 | included do
16 | attr_readonly :symbol
17 |
18 | validates_presence_of :symbol, :deployment_percentage
19 | validates_uniqueness_of :symbol, :allow_blank => true, :case_sensitive => false
20 | validates_numericality_of :deployment_percentage,
21 | :only_integer => true,
22 | :allow_blank => true,
23 | :greater_than_or_equal_to => 0,
24 | :less_than_or_equal_to => 100
25 | end
26 |
27 | class_methods do
28 | # Looks up a feature by symbol. Also accepts a Feature as input.
29 | # @param [Symbol, Arturo::Feature] feature_or_symbol a Feature or the Symbol of a Feature
30 | # @return [Arturo::Feature, Arturo::NoSuchFeature] the Feature if found, else Arturo::NoSuchFeature
31 | def to_feature(feature_or_symbol)
32 | return feature_or_symbol if feature_or_symbol.kind_of?(self)
33 |
34 | symbol = feature_or_symbol.to_sym.to_s
35 | self.where(:symbol => symbol).first || Arturo::NoSuchFeature.new(symbol)
36 | end
37 |
38 | # Looks up a feature by symbol. Also accepts a Feature as input.
39 | # @param [Symbol, Arturo::Feature] feature_or_symbol a Feature or the Symbol of a Feature
40 | # @return [Arturo::Feature, nil] the Feature if found, else nil
41 | def find_feature(feature_or_symbol)
42 | feature = to_feature(feature_or_symbol)
43 | feature.is_a?(Arturo::NoSuchFeature) ? nil : feature
44 | end
45 |
46 | def last_updated_at
47 | maximum(:updated_at)
48 | end
49 | end
50 |
51 | # Create a new Feature
52 | def initialize(*args, &block)
53 | args[0] = DEFAULT_ATTRIBUTES.merge(args[0].try(:to_h) || {})
54 | super(*args, &block)
55 | end
56 |
57 | # @param [Object] feature_recipient a User, Account,
58 | # or other model with an #id method
59 | # @return [true,false] whether or not this feature is enabled
60 | # for feature_recipient
61 | # @see Arturo::SpecialHandling#whitelisted?
62 | # @see Arturo::SpecialHandling#blacklisted?
63 | def enabled_for?(feature_recipient)
64 | return false if feature_recipient.nil?
65 | return false if blacklisted?(feature_recipient)
66 | return true if whitelisted?(feature_recipient)
67 | passes_threshold?(feature_recipient, deployment_percentage || 0)
68 | end
69 |
70 | def name
71 | return I18n.translate("arturo.feature.nameless") if symbol.blank?
72 |
73 | I18n.translate("arturo.feature.#{symbol}", :default => symbol.to_s.titleize)
74 | end
75 |
76 | def to_s
77 | "Feature #{name}"
78 | end
79 |
80 | def to_param
81 | persisted? ? "#{id}-#{symbol.to_s.parameterize}" : nil
82 | end
83 |
84 | def inspect
85 | ""
86 | end
87 |
88 | # made public so as to allow for thresholds stored outside of the model
89 | def passes_threshold?(feature_recipient, threshold)
90 | return true if threshold == 100
91 | return false if threshold == 0 || !feature_recipient.id
92 | (((feature_recipient.id + (self.id || 1) + 17) * 13) % 100) < threshold
93 | end
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/spec/models/feature_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 |
4 | describe Arturo::Feature do
5 |
6 | before do
7 | reset_translations!
8 | end
9 |
10 | let(:feature) { create(:feature) }
11 | let(:bunch_of_things) do
12 | (1..2000).to_a.map do |i|
13 | double('Thing', id: i)
14 | end
15 | end
16 |
17 | it 'responds to to_feature' do
18 | expect(::Arturo::Feature.to_feature(feature)).to eq(feature)
19 | expect(::Arturo::Feature.to_feature(feature.symbol)).to eq(feature)
20 | expect(::Arturo::Feature.to_feature(:does_not_exist)).to be_a(Arturo::NoSuchFeature)
21 | end
22 |
23 | it 'responds to find_feature' do
24 | expect(::Arturo::Feature.find_feature(feature)).to eq(feature)
25 | expect(::Arturo::Feature.find_feature(feature.symbol)).to eq(feature)
26 | expect(::Arturo::Feature.find_feature(:does_not_exist)).to be_nil
27 | end
28 |
29 | it 'finds existent features with feature_enabled_for' do
30 | feature.update(:deployment_percentage => 100)
31 | recipient = double('User', to_s: 'Paula', id: 12)
32 | expect(::Arturo.feature_enabled_for?(feature.symbol, recipient)).to be(true), "#{feature} should be enabled for #{recipient}"
33 | end
34 |
35 | it 'does not finds non existent features with feature_enabled_for' do
36 | expect(::Arturo.feature_enabled_for?(:does_not_exist, 'Paula')).to be(false)
37 | end
38 |
39 | it 'does not find feature for nil recipients' do
40 | expect(::Arturo.feature_enabled_for?(feature.symbol, nil)).to be(false)
41 | expect(::Arturo.feature_enabled_for?(:does_not_exist, nil)).to be(false)
42 | end
43 |
44 | it 'requires a symbol' do
45 | feature.symbol = nil
46 | expect(feature).to_not be_valid
47 | expect(feature.errors[:symbol]).to be_present
48 | end
49 |
50 | it 'responds to last_updated_at' do
51 | Arturo::Feature.delete_all
52 |
53 | Timecop.freeze(Time.local(2008, 9, 1, 12, 0, 0)) { create(:feature) }
54 | updated_at = Time.local(2011, 9, 1, 12, 0, 0)
55 |
56 | Timecop.freeze(updated_at) { create(:feature) }
57 | expect(Arturo::Feature.last_updated_at).to eq(updated_at)
58 | end
59 |
60 | it 'responds to last_updated_at with no features' do
61 | Arturo::Feature.delete_all
62 | expect(Arturo::Feature.last_updated_at).to be_nil
63 | end
64 |
65 | # regression
66 | # @see https://github.com/zendesk/arturo/issues/7
67 | it 'does not overwrite deployment_percentage on create' do
68 | new_feature = ::Arturo::Feature.create('symbol' => :foo, 'deployment_percentage' => 37)
69 | expect(new_feature.deployment_percentage.to_s).to eq('37')
70 | end
71 |
72 | it 'requires a deployment percentage' do
73 | feature.deployment_percentage = nil
74 | expect(feature).to_not be_valid
75 | expect(feature.errors[:deployment_percentage]).to be_present
76 | end
77 |
78 | it 'has a readonly symbol' do
79 | original_symbol = feature.symbol
80 | feature.symbol = :foo_bar
81 | feature.save
82 | expect(feature.reload.symbol.to_sym).to eq(original_symbol.to_sym)
83 | end
84 |
85 | it 'has a sane default for name' do
86 | feature.symbol = :foo_bar
87 | expect(feature.name).to eq('Foo Bar')
88 | end
89 |
90 | it 'uses names with internationalization when available' do
91 | define_translation("arturo.feature.#{feature.symbol}", 'Happy Feature')
92 | expect(feature.name).to eq('Happy Feature')
93 | end
94 |
95 | it 'sets deployment percentagle to 0 by default' do
96 | expect(::Arturo::Feature.new.deployment_percentage).to eq(0)
97 | end
98 |
99 | describe 'enabled_for?' do
100 | it 'returns false if thing is nil' do
101 | feature.deployment_percentage = 100
102 | expect(feature.enabled_for?(nil)).to be(false)
103 | end
104 |
105 | it 'returns false for all things when deployment percentage is nil' do
106 | feature.deployment_percentage = 0
107 | bunch_of_things.each do |t|
108 | expect(feature.enabled_for?(t)).to be(false)
109 | end
110 | end
111 |
112 | it 'returns true for all non nil things when deployment percentage is 100' do
113 | feature.deployment_percentage = 100
114 | bunch_of_things.each do |t|
115 | expect(feature.enabled_for?(t)).to be(true)
116 | end
117 | end
118 |
119 | it 'returns true for certain accounts when deployment percentage is 50' do
120 | feature.deployment_percentage = 50
121 | { 58 => false, 61 => true, 112 => false, 116 => true }.each do |id, expected|
122 | expect(feature.enabled_for?(double('Thing', id: id))).to be(expected)
123 | end
124 | end
125 |
126 | it 'returns true for about deployment percentage percent of things' do
127 | feature.deployment_percentage = 37
128 | yes = 0
129 | bunch_of_things.each { |t| yes += 1 if feature.enabled_for?(t) }
130 | expect(yes).to be_within(0.02 * bunch_of_things.length).of(0.37 * bunch_of_things.length)
131 | end
132 |
133 | it 'returns false for things with nil id and not 100' do
134 | feature.deployment_percentage = 99
135 | expect(feature.enabled_for?(double('ThingWithNilId', id: nil))).to be(false)
136 | end
137 |
138 | it 'returns false for things with nil id and 100' do
139 | feature.deployment_percentage = 100
140 | expect(feature.enabled_for?(double('ThingWithNilId', id: nil))).to be(true)
141 | end
142 |
143 | it 'is not identical across features' do
144 | foo = create(:feature, symbol: :foo, deployment_percentage: 55)
145 | bar = create(:feature, symbol: :bar, deployment_percentage: 55)
146 | has_foo = bunch_of_things.map { |t| foo.enabled_for?(t) }
147 | has_bar = bunch_of_things.map { |t| bar.enabled_for?(t) }
148 | expect(has_bar).to_not eq(has_foo)
149 | end
150 | end
151 |
152 | it 'responds do to_s' do
153 | expect(feature.to_s).to include(feature.name)
154 | end
155 |
156 | it 'responds to to_param' do
157 | expect(feature.to_param).to match(%r{^#{feature.id}-})
158 | end
159 | end
160 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## Unreleased
2 |
3 | Drops support for Ruby 3.0 and 3.1.
4 |
5 | Drops support for Rails 6.0 and 6.1.
6 |
7 | Adds tests against Ruby 3.4.
8 |
9 | Adds tests against Rails 7.2 and 8.0.
10 |
11 | ## v4.1.1
12 |
13 | Fixes missing indifferent_access import
14 |
15 | ## v4.1.0
16 |
17 | Removes upper boundary on ActiveRecord.
18 |
19 | Drops support for Ruby < 3.0.
20 |
21 | Drops support for Rails < 6.0.
22 |
23 | ## v4.0.1
24 |
25 | Fixes loading issues for apps not using the Rails engine.
26 |
27 | ## v4.0.0
28 |
29 | Stops loading the Rails engine automatically. If you are using the engine, you need to require it explicitly by adding `require 'arturo/engine'` to `application.rb`.
30 |
31 | Adds support for Ruby 3.3.
32 |
33 | Returns false immediately for `feature_enabled_for?` calls with `nil` recipients.
34 |
35 | ## v3.0.0
36 |
37 | Converts the Feature model into a mixin that should be used by services via a model generator.
38 |
39 | Brings back the `warm_cache!` method.
40 |
41 | Adds support for Rails 7.1.
42 |
43 | ## v2.8.0
44 |
45 | Drop support for Ruby 2.6
46 |
47 | Drop Support for Rails 5.0 & 5.1
48 |
49 | Add support for Ruby 3.2
50 |
51 | ## v2.7.0
52 |
53 | Adds ability to register cache update listeners with Arturo::FeatureCaching::AllStrategy that are called when the cache is updated
54 |
55 | ## v2.6.0
56 |
57 | Add support for Rails 7.0
58 |
59 | Add support for Ruby 3.0 & 3.1
60 |
61 | Drop support for Rails 4.2
62 |
63 | Drop support for Ruby 2.4 & 2.5
64 |
65 | ## v2.5.4
66 |
67 | Bug fix: Explicitly require rails engine to avoid errors that ::Rails::Engine cannot be found.
68 |
69 | ## v2.5.3
70 |
71 | Bug fix: Allow using Arturo with ActiveRecord, but without all of Rails.
72 |
73 | ## v2.5.2
74 |
75 | Drop support for Rails 3.2.
76 |
77 | Add support for Rails 6.1.
78 |
79 | Switch CI from Travis to GitHub Actions.
80 |
81 | ## v2.2.0
82 |
83 | Bug fix: making a feature-update request that fails strict params checks now returns a sensible error instead of throwing an exception
84 |
85 | Improvement: better failed-to-update error messages
86 |
87 | Support Matrix changes: add Rails 5.0, drop Rails 3.2, add Ruby 2.1.7, add Ruby 2.2.3, drop Ruby 1.9.3
88 |
89 | ## v2.1.0
90 |
91 | Bug fix: `Arturo::SpecialHandling` always compares symbols as strings
92 |
93 | Improvmement: Rails 4.2 compatibility
94 |
95 | Improvement: relax minitest version constraints
96 |
97 | Improvement: add `set_feature!` method to complement `enable_feature!`and `disable_feature!`
98 |
99 | ## v2.0.0
100 |
101 | Bug fix: add missing require to initializer.
102 |
103 | Improvement: Remove support for `[feature]_enabled_for?` methods.
104 |
105 | Improvement: Use more specific gem versions for development dependencies.
106 |
107 | ## v1.11.0
108 |
109 | Depreaction: `[feature]_enabled_for?` methods
110 |
111 | Bug fix: `Arturo.respond_to?` takes an optional second argument, per
112 | `Object.respond_to?`'s signature.
113 |
114 | Improvement: support Rails 4.1.
115 |
116 | Improvement: use Travis's multiple builds instead of Appraisal.
117 |
118 | ## v1.10.0
119 |
120 | Improvement: Arturo no longer declares a hard runtime dependency on Rails, but
121 | instead only on ActiveRecord. This makes it possible to use `Arturo::Feature`
122 | in non-Rails settings. Feature *management* is still expressed as a Rails engine
123 | and requires `actionpack` and other parts of Rails.
124 |
125 | ## v1.9.0
126 |
127 | Improvement: `Arturo::Feature` is defined in `lib/arturo/feature.rb` instead of
128 | `app/models/arturo/feature.rb`, which means consuming applications can load it
129 | without loading the whole engine.
130 |
131 | Improvement: `Arturo::Engine` no longer eagerly loads all engine files; instead,
132 | it uses Rails's `autoload_paths` to ensure classes are loaded as necessary.
133 |
134 | Bug fix: the route to `arturo/features_controller#update_all` is now called
135 | `features_update_all`; it had been called simply `features`, which caused
136 | conflict problems in Rails 4.0.
137 |
138 | ## v1.8.0
139 |
140 | Improvement: "All" caching strategy is now smarter about its use of the
141 | `update_at` attribute. It handles the case when a Feature's `updated_at` is
142 | `nil` and queries the database less often to figure out whether any features
143 | have changed.
144 |
145 | Bug fix: the engine's `source_root` relied on its `root_path`, which is not
146 | available on all versions of Rails.
147 |
148 | ## v1.7.0
149 |
150 | `Arturo::FeaturesHelper#error_messages_for` has been removed. This only affects
151 | people who have written their own feature-management pages that use this helper.
152 |
153 | ## v1.6.1
154 |
155 | `Arturo::FeaturesHelper#error_messages_for` has been deprecated in favor of
156 | `error_messages_for_feature` because it conflicts with a Rails and DynamicForm
157 | method. It will be removed in v1.7.0. This only affects people who have written
158 | their own feature-management pages that use this helper.
159 |
160 | ## v1.6.0
161 |
162 | Formerly, whitelists and blacklists had to be *feature-specific*:
163 |
164 | Arturo::Feature.whitelist(:foo) do |recipient|
165 | recipient.plan.has_foo?
166 | end
167 |
168 | Now whitelists and blacklists can be global. The block takes the feature
169 | as the first argument:
170 |
171 | Arturo::Feature.whitelist do |feature, recipient|
172 | recipient.plan.has?(feature.to_sym)
173 | end
174 |
175 | ## v1.5.3
176 |
177 | Set `signing_key` in gemspec only if the file exists.
178 |
179 | The `FeaturesController` docs erroneously said to override `#permitted?`.
180 | The correct method name is `may_manage_features?`.
181 |
182 | ## v1.5.2
183 |
184 | The gem is now signed. The public key is
185 | [gem-public_cert.pem](./gem-public_cert.pem).
186 |
187 | ## v1.5.1
188 |
189 | Use just ActiveRecord, not all of Rails, when defining different behavior
190 | for different versions.
191 |
192 | Unify interface of `Feature` and `NoSuchFeature` so the latter fulfills the
193 | null-object pattern.
194 |
195 | ## v1.5.0
196 |
197 | This project is now licensed under the
198 | [APLv2](https://www.apache.org/licenses/LICENSE-2.0.html).
199 |
200 | Arturo now works on Rails 3.0 and Rails 4.0. Helpers are no longer mixed into
201 | the global view, but are under the `arturo_engine` namespace, as is the
202 | convention in Rails 3.1+.
203 |
204 | The feature cache will return `NoSuchFeature` for cache misses instead of `nil`,
205 | which clients can treat like a `Feature` that is always off.
206 |
207 | Better error messages when managing features, and the addition of the
208 | `arturo_flash_messages` helper method.
209 |
210 | Add `Feature.last_updated_at` to get the most recent `updated_at` among all
211 | `Feature`s.
212 |
213 | ## v1.3.0
214 |
215 | Add `Arturo::Middleware`, which passes requests down the stack if an only if
216 | a particular feature is available.
217 |
218 | `TestSupport` methods use `Feature.to_feature`.
219 |
220 | ## v 1.1.0 - cleanup
221 |
222 | * changed `require_feature!` to `require_feature`
223 | * replaced `Arturo.permit_management` and `Arturo.feature_recipient`
224 | blocks with instance methods
225 | `Arturo::FeatureManagement.may_manage_features?` and
226 | `Arturo::FeatureAvailability.feature_recipient`
227 |
228 | ## v 1.0.0 - Initial Release
229 |
230 | * `require_feature!` controller filter
231 | * `if_feature_enabled` controller and view method
232 | * `feature_enabled?` controller and view method
233 | * CRUD for features
234 | * `Arturo.permit_management` to configure management permission
235 | * `Arturo.feature_recipient` to configure on what basis features are deployed
236 | * whitelists and blacklists
237 |
--------------------------------------------------------------------------------
/lib/arturo/feature_caching.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'arturo/no_such_feature'
3 |
4 | module Arturo
5 |
6 | # To be extended by Arturo::Feature if you want to enable
7 | # in-memory caching.
8 | # NB: Arturo's feature caching only works when using
9 | # Arturo::Feature.to_feature or when using the helper methods
10 | # in Arturo and Arturo::FeatureAvailability.
11 | # NB: if you have multiple application servers, you almost certainly
12 | # want to clear this cache after each request:
13 | #
14 | # class ApplicationController < ActionController::Base
15 | # after_filter { Arturo::Feature.clear_feature_cache }
16 | # end
17 | #
18 | # Alternatively, you could redefine Arturo::Feature.feature_cache
19 | # to use a shared cache like Memcached.
20 | module FeatureCaching
21 |
22 | module PrependMethods
23 | # Wraps Arturo::Feature.to_feature with in-memory caching.
24 | def to_feature(feature_or_symbol)
25 | if !caches_features?
26 | super
27 | elsif feature_or_symbol.kind_of?(Arturo::Feature)
28 | feature_or_symbol
29 | else
30 | symbol = feature_or_symbol.to_sym
31 | feature_caching_strategy.fetch(feature_cache, symbol) { super(symbol) }
32 | end
33 | end
34 | end
35 |
36 | def self.extended(base)
37 | class << base
38 | prepend PrependMethods
39 | attr_accessor :cache_ttl, :feature_cache, :feature_caching_strategy
40 | attr_writer :extend_cache_on_failure
41 | end
42 | base.send(:after_save) do |f|
43 | f.class.feature_caching_strategy.expire(f.class.feature_cache, f.symbol.to_sym) if f.class.caches_features?
44 | end
45 | base.cache_ttl = 0
46 | base.extend_cache_on_failure = false
47 | base.feature_cache = Arturo::FeatureCaching::Cache.new
48 | base.feature_caching_strategy = AllStrategy
49 | end
50 |
51 | def extend_cache_on_failure?
52 | !!@extend_cache_on_failure
53 | end
54 |
55 | def caches_features?
56 | self.cache_ttl.to_i > 0
57 | end
58 |
59 | def warm_cache!
60 | to_feature(:fake_feature_to_force_cache_warming)
61 | end
62 |
63 | class AllStrategy
64 | class << self
65 | ##
66 | # @param cache [Arturo::Cache] cache backend
67 | # @param symbol [Symbol] arturo identifier
68 | # @return [Arturo::Feature, Arturo::NoSuchFeature]
69 | #
70 | def fetch(cache, symbol, &block)
71 | existing_features = cache.read("arturo.all")
72 |
73 | features = if cache_is_current?(cache, existing_features)
74 | existing_features
75 | else
76 | arturos_from_origin(fallback: existing_features).tap do |updated_features|
77 | update_and_extend_cache!(cache, updated_features)
78 | end
79 | end
80 |
81 | features[symbol] || Arturo::NoSuchFeature.new(symbol)
82 | end
83 |
84 | def expire(cache, symbol)
85 | cache.delete("arturo.all")
86 | end
87 |
88 | def register_cache_update_listener(&block)
89 | cache_update_listeners << block
90 | end
91 |
92 | private
93 |
94 | def cache_update_listeners
95 | @cache_update_listeners ||= []
96 | end
97 |
98 | ##
99 | # @param fallback [Hash] features to use on database failure
100 | # @return [Hash] updated features from origin or fallback
101 | # @raise [ActiveRecord::ActiveRecordError] on database failure
102 | # without cache extension option
103 | #
104 | def arturos_from_origin(fallback:)
105 | Arturo::Feature.all.to_h { |f| [f.symbol.to_sym, f] }
106 | rescue ActiveRecord::ActiveRecordError
107 | raise unless Arturo::Feature.extend_cache_on_failure?
108 |
109 | if fallback.blank?
110 | log_empty_cache
111 | raise
112 | else
113 | log_stale_cache
114 | fallback
115 | end
116 | end
117 |
118 | ##
119 | # @return [Boolean] whether the current cache has to be updated from origin
120 | # @raise [ActiveRecord::ActiveRecordError] on database failure
121 | # without cache extension option
122 | #
123 | def cache_is_current?(cache, features)
124 | return unless features
125 | return true if cache.read("arturo.current")
126 |
127 | begin
128 | return false if origin_changed?(features)
129 | rescue ActiveRecord::ActiveRecordError
130 | raise unless Arturo::Feature.extend_cache_on_failure?
131 |
132 | if features.blank?
133 | log_empty_cache
134 | raise
135 | else
136 | log_stale_cache
137 | update_and_extend_cache!(cache, features)
138 | end
139 |
140 | return true
141 | end
142 | mark_as_current!(cache)
143 | end
144 |
145 | def formatted_log(namespace, msg)
146 | "[Arturo][#{namespace}] #{msg}"
147 | end
148 |
149 | def log_empty_cache
150 | Arturo.logger.error(formatted_log('extend_cache_on_failure', 'Fallback cache is empty'))
151 | end
152 |
153 | def log_stale_cache
154 | Arturo.logger.warn(formatted_log('extend_cache_on_failure', 'Falling back to stale cache'))
155 | end
156 |
157 | ##
158 | # @return [True]
159 | #
160 | def mark_as_current!(cache)
161 | cache.write("arturo.current", true, expires_in: Arturo::Feature.cache_ttl)
162 | end
163 |
164 | ##
165 | # The Arturo origin might return a big payload, so checking for the latest
166 | # update is a cheaper operation.
167 | #
168 | # @return [Boolean] if origin has been updated since the last cache update.
169 | #
170 | def origin_changed?(features)
171 | features.values.map(&:updated_at).compact.max != Arturo::Feature.maximum(:updated_at)
172 | end
173 |
174 | def update_and_extend_cache!(cache, features)
175 | mark_as_current!(cache)
176 | cache.write("arturo.all", features, expires_in: Arturo::Feature.cache_ttl * 10)
177 | cache_update_listeners.each(&:call)
178 | end
179 | end
180 | end
181 |
182 | class OneStrategy
183 | def self.fetch(cache, symbol, &block)
184 | if feature = cache.read("arturo.#{symbol}")
185 | feature
186 | else
187 | cache.write("arturo.#{symbol}", yield || Arturo::NoSuchFeature.new(symbol), expires_in: Arturo::Feature.cache_ttl)
188 | end
189 | end
190 |
191 | def self.expire(cache, symbol)
192 | cache.delete("arturo.#{symbol}")
193 | end
194 | end
195 |
196 | # Quack like a Rails cache.
197 | class Cache
198 | def initialize
199 | @data = {} # of the form {key => [value, expires_at or nil]}
200 | end
201 |
202 | def read(name, options = nil)
203 | value, expires_at = *@data[name]
204 | if value && (expires_at.blank? || expires_at > Time.now)
205 | value
206 | else
207 | nil
208 | end
209 | end
210 |
211 | def delete(name)
212 | @data.delete(name)
213 | end
214 |
215 | def write(name, value, options = nil)
216 | expires_at = if options && options.respond_to?(:[]) && options[:expires_in]
217 | Time.now + options.delete(:expires_in)
218 | else
219 | nil
220 | end
221 | value.freeze.tap do |val|
222 | @data[name] = [value, expires_at]
223 | end
224 | end
225 |
226 | def clear
227 | @data.clear
228 | end
229 | end
230 |
231 | end
232 |
233 | end
234 |
--------------------------------------------------------------------------------
/spec/models/cache_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'spec_helper'
3 | require 'arturo/features_helper'
4 |
5 | describe Arturo::FeatureCaching do
6 | Arturo::Feature.extend(Arturo::FeatureCaching)
7 |
8 | class StupidCache
9 | def initialize(enabled=true)
10 | @enabled = enabled
11 | @data = {}
12 | end
13 |
14 | def read(key)
15 | @data[key] if @enabled
16 | end
17 |
18 | def delete(key)
19 | @data.delete(key)
20 | end
21 |
22 | def write(key, value, options={})
23 | @data[key] = value
24 | end
25 |
26 | def clear
27 | @data.clear
28 | end
29 | end
30 |
31 | before do
32 | @feature = create(:feature)
33 | Arturo::Feature.cache_ttl = 30.minutes
34 | Arturo::Feature.feature_cache = Arturo::FeatureCaching::Cache.new
35 | end
36 |
37 | after do
38 | Arturo::Feature.cache_ttl = 0 # turn off for other tests
39 | Timecop.return
40 | end
41 |
42 | # Rails 4 calls all when calling maximum :/
43 | def lock_down_maximum
44 | m = Arturo::Feature.maximum(:updated_at)
45 | allow(Arturo::Feature).to receive(:maximum).and_return(m)
46 | end
47 |
48 | [Arturo::FeatureCaching::OneStrategy, Arturo::FeatureCaching::AllStrategy].each do |strategy|
49 | describe strategy do
50 | let(:feature_method) { strategy == Arturo::FeatureCaching::OneStrategy ? :where : :all }
51 |
52 | before do
53 | Arturo::Feature.feature_caching_strategy = strategy
54 | end
55 |
56 | it 'hits db on first load' do
57 | expect(Arturo::Feature).to receive(feature_method).and_return([@feature])
58 |
59 | Arturo::Feature.to_feature(@feature.symbol)
60 | end
61 |
62 | it 'caches missing features' do
63 | expect(Arturo::Feature).to receive(feature_method).and_return([])
64 |
65 | expect(Arturo::Feature.to_feature(:ramen)).to be_kind_of(Arturo::NoSuchFeature)
66 | expect(Arturo::Feature.to_feature(:ramen)).to be_kind_of(Arturo::NoSuchFeature)
67 | expect(Arturo::Feature.to_feature(:ramen)).to be_kind_of(Arturo::NoSuchFeature)
68 | end
69 |
70 | it 'works with other cache backends' do
71 | Arturo::Feature.feature_cache = StupidCache.new
72 | expect(Arturo::Feature).to receive(feature_method).and_return([@feature])
73 |
74 | Arturo::Feature.to_feature(@feature.symbol.to_sym)
75 | Arturo::Feature.to_feature(@feature.symbol)
76 | Arturo::Feature.to_feature(@feature.symbol.to_sym)
77 | Arturo::Feature.to_feature(@feature.symbol)
78 | end
79 |
80 | it 'works with inconsistent cache backend' do
81 | Arturo::Feature.feature_cache = StupidCache.new(false)
82 | expect(Arturo::Feature).to receive(feature_method).and_return([@feature]).twice
83 |
84 | Arturo::Feature.to_feature(@feature.symbol.to_sym)
85 | Arturo::Feature.to_feature(@feature.symbol.to_sym)
86 | end
87 |
88 | it 'can clear the cache' do
89 | Arturo::Feature.to_feature(@feature.symbol)
90 | Arturo::Feature.feature_cache.clear
91 | expect(Arturo::Feature).to receive(feature_method).and_return([@feature])
92 |
93 | Arturo::Feature.to_feature(@feature.symbol)
94 | end
95 |
96 | it 'can turn off caching' do
97 | Arturo::Feature.cache_ttl = 0
98 | expect(Arturo::Feature).to receive(:where).and_return([@feature]).twice
99 |
100 | Arturo::Feature.to_feature(@feature.symbol)
101 | Arturo::Feature.to_feature(@feature.symbol)
102 | end
103 |
104 | it 'does not expire when inside cache ttl' do
105 | Arturo::Feature.to_feature(@feature.symbol)
106 | expect(Arturo::Feature).to_not receive(feature_method)
107 |
108 | Timecop.travel(Time.now + Arturo::Feature.cache_ttl - 5.seconds)
109 | Arturo::Feature.to_feature(@feature.symbol)
110 | end
111 |
112 | it 'expires when outside of cache ttl' do
113 | Arturo::Feature.to_feature(@feature.symbol)
114 | expect(Arturo::Feature).to receive(feature_method).and_return([@feature])
115 |
116 | Timecop.travel(Time.now + Arturo::Feature.cache_ttl * 12)
117 | Arturo::Feature.to_feature(@feature.symbol)
118 | end
119 |
120 | it 'expires cache on enable or disable' do
121 | Arturo.enable_feature!(@feature.symbol)
122 | expect(Arturo::Feature.to_feature(@feature.symbol).deployment_percentage).to eq(100)
123 |
124 | Arturo.disable_feature!(@feature.symbol)
125 | expect(Arturo::Feature.to_feature(@feature.symbol).deployment_percentage).to eq(0)
126 | end
127 | end
128 | end
129 |
130 | describe Arturo::FeatureCaching::AllStrategy do
131 | before do
132 | Arturo::Feature.feature_caching_strategy = Arturo::FeatureCaching::AllStrategy
133 | end
134 |
135 | it 'caches all features in one cache' do
136 | expect(Arturo::Feature).to_not receive(:maximum)
137 | expect(Arturo::Feature).to receive(:all).and_return([])
138 |
139 | expect(Arturo::Feature.to_feature(:ramen)) .to be_kind_of(Arturo::NoSuchFeature)
140 | expect(Arturo::Feature.to_feature(:amen)) .to be_kind_of(Arturo::NoSuchFeature)
141 | expect(Arturo::Feature.to_feature(:laymen)).to be_kind_of(Arturo::NoSuchFeature)
142 | end
143 |
144 | it 'does not expire when inside cache ttl' do
145 | Arturo::Feature.to_feature(@feature.symbol)
146 | expect(Arturo::Feature).to_not receive(:maximum)
147 | expect(Arturo::Feature).to_not receive(:all)
148 |
149 | Timecop.travel(Time.now + Arturo::Feature.cache_ttl - 5.seconds)
150 | Arturo::Feature.to_feature(@feature.symbol)
151 | end
152 |
153 | it 'expires when only feature-cache is empty' do
154 | Arturo::Feature.to_feature(@feature.symbol)
155 | expect(Arturo::Feature).to_not receive(:maximum)
156 | expect(Arturo::Feature).to receive(:all).and_return([])
157 |
158 | Arturo::Feature.feature_cache.delete('arturo.all')
159 | Arturo::Feature.to_feature(@feature.symbol)
160 | end
161 |
162 | describe 'when outside of cache ttl and fresh' do
163 | before do
164 | Arturo::Feature.to_feature(@feature.symbol)
165 | lock_down_maximum
166 | expect(Arturo::Feature).to_not receive(:all)
167 |
168 | Timecop.travel(Time.now + Arturo::Feature.cache_ttl + 5.seconds)
169 | Arturo::Feature.to_feature(@feature.symbol)
170 | end
171 |
172 | skip('does not expire')
173 |
174 | it "does not ask for updated_at after finding out it's fresh" do
175 | expect(Arturo::Feature).to_not receive(:maximum)
176 | Arturo::Feature.to_feature(@feature.symbol)
177 | end
178 | end
179 |
180 | describe 'when outside of cache ttl and stale' do
181 | let(:listener) { proc {} }
182 |
183 | before do
184 | Arturo::Feature.to_feature(@feature.symbol)
185 | @feature.touch
186 | lock_down_maximum
187 | Arturo::Feature.feature_caching_strategy.register_cache_update_listener(&listener)
188 | Timecop.travel(Time.now + Arturo::Feature.cache_ttl + 5.seconds)
189 | end
190 |
191 | after do
192 | Timecop.return
193 | Arturo::Feature.feature_caching_strategy.send(:cache_update_listeners).clear
194 | end
195 |
196 | it 'expires' do
197 | expect(Arturo::Feature).to receive(:all).and_return([@feature])
198 | Arturo::Feature.to_feature(@feature.symbol)
199 | end
200 |
201 | it 'triggers cache update listeners' do
202 | expect(listener).to receive(:call)
203 | Arturo::Feature.to_feature(@feature.symbol)
204 | end
205 | end
206 |
207 | it 'does not crash on nil updated_at' do
208 | @feature.class.where(id: @feature.id).update_all(updated_at: nil)
209 | create(:feature)
210 | expect {
211 | Arturo::Feature.to_feature(@feature.symbol)
212 | Timecop.travel(Time.now + Arturo::Feature.cache_ttl + 5.seconds)
213 | Arturo::Feature.to_feature(@feature.symbol)
214 | }.to_not raise_error
215 | end
216 |
217 | describe 'database errors' do
218 | before do
219 | Arturo::Feature.to_feature(@feature.symbol)
220 | @feature.touch
221 | Timecop.travel(Time.now + Arturo::Feature.cache_ttl + 5.seconds)
222 |
223 | if ActiveRecord.version < Gem::Version.new("7.2")
224 | allow(ActiveRecord::Base).
225 | to receive(:connection).
226 | and_raise(ActiveRecord::ActiveRecordError)
227 | else
228 | allow(Arturo::Feature).
229 | to receive(:with_connection).
230 | and_raise(ActiveRecord::ActiveRecordError)
231 | end
232 | end
233 |
234 | context 'with extend_cache_on_failure enabled' do
235 | before { Arturo::Feature.extend_cache_on_failure = true }
236 |
237 | context 'with error checking origin changes' do
238 | it 'does not raise error' do
239 | expect { Arturo::Feature.to_feature(@feature.symbol) }.
240 | not_to raise_error
241 | end
242 |
243 | it 'extends the cache' do
244 | expect(Arturo::Feature.feature_caching_strategy).
245 | to receive(:mark_as_current!)
246 | Arturo::Feature.to_feature(@feature.symbol)
247 | end
248 |
249 | it 'returns the cached result' do
250 | expect(Arturo::Feature.to_feature(@feature.symbol)).to eq(@feature)
251 | end
252 |
253 | context 'with a cold cache' do
254 | before do
255 | Arturo::Feature.feature_caching_strategy.expire(Arturo::Feature.feature_cache, 'all')
256 | end
257 |
258 | it 'raises error' do
259 | expect { Arturo::Feature.to_feature(@feature.symbol) }.
260 | to raise_error(ActiveRecord::ActiveRecordError)
261 | end
262 | end
263 | end
264 |
265 | context 'with error while refetching origin' do
266 | before do
267 | allow(Arturo::Feature).to receive(:origin_changed?).and_return(true)
268 | end
269 |
270 | it 'does not raise error' do
271 | expect { Arturo::Feature.to_feature(@feature.symbol) }.
272 | not_to raise_error
273 | end
274 |
275 | it 'extends the cache' do
276 | expect(Arturo::Feature.feature_caching_strategy).
277 | to receive(:mark_as_current!)
278 | Arturo::Feature.to_feature(@feature.symbol)
279 | end
280 |
281 | it 'returns the cached result' do
282 | expect(Arturo::Feature.to_feature(@feature.symbol)).to eq(@feature)
283 | end
284 |
285 | context 'with a cold cache' do
286 | before do
287 | Arturo::Feature.feature_caching_strategy.expire(Arturo::Feature.feature_cache, 'all')
288 | end
289 |
290 | it 'raises error' do
291 | expect { Arturo::Feature.to_feature(@feature.symbol) }.
292 | to raise_error(ActiveRecord::ActiveRecordError)
293 | end
294 | end
295 | end
296 | end
297 |
298 | context 'with extend_cache_on_failure disabled' do
299 | before { Arturo::Feature.extend_cache_on_failure = false }
300 |
301 | context 'with error checking origin changes' do
302 | it 'reraises the error' do
303 | expect { Arturo::Feature.to_feature(@feature.symbol) }.
304 | to raise_error(ActiveRecord::ActiveRecordError)
305 | end
306 | end
307 |
308 | context 'with error while refetching origin' do
309 | before do
310 | allow(Arturo::Feature).to receive(:origin_changed?).and_return(true)
311 | end
312 |
313 | it 'reraises the error' do
314 | expect { Arturo::Feature.to_feature(@feature.symbol) }.
315 | to raise_error(ActiveRecord::ActiveRecordError)
316 | end
317 | end
318 | end
319 | end
320 | end
321 | end
322 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## What
2 |
3 | Arturo provides feature sliders for Rails. It lets you turn features on and off
4 | just like
5 | [feature flippers](https://code.flickr.net/2009/12/02/flipping-out/),
6 | but offers more fine-grained control. It supports deploying features only for
7 | a given percentage of your users and whitelisting and blacklisting users based
8 | on any criteria you can express in Ruby.
9 |
10 | The selection is deterministic. So if a user has a feature on Monday, the
11 | user will still have it on Tuesday (unless you *decrease* the feature's
12 | deployment percentage or change its white- or blacklist settings).
13 |
14 | ### A quick example
15 |
16 | Trish, a developer is working on a new feature: a live feed of recent postings
17 | in the user's city that shows up in the user's sidebar. First, she uses Arturo's
18 | view helpers to control who sees the sidebar widget:
19 |
20 | ```ERB
21 | <%# in app/views/layout/_sidebar.html.erb: %>
22 | <% if_feature_enabled(:live_postings) do %>
23 |
24 |
Recent Postings
25 |
26 |
27 |
28 | <% end %>
29 | ```
30 |
31 | Then Trish writes some Javascript that will poll the server for recent
32 | postings and put them in the sidebar widget:
33 |
34 | ```js
35 | // in public/javascript/live_postings.js:
36 | $(function() {
37 | var livePostingsList = $('#live_postings');
38 | if (livePostingsList.length > 0) {
39 | var updatePostingsList = function() {
40 | livePostingsList.load('/listings/recent');
41 | setTimeout(updatePostingsList, 30);
42 | }
43 | updatePostingsList();
44 | }
45 | });
46 | ```
47 |
48 | Trish uses Arturo's Controller filters to control who has access to
49 | the feature:
50 |
51 | ```Ruby
52 | # in app/controllers/postings_controller:
53 | class PostingsController < ApplicationController
54 | require_feature :live_postings, only: :recent
55 | # ...
56 | end
57 | ```
58 |
59 | Trish then deploys this code to production. Nobody will see the feature yet,
60 | since it's not on for anyone. (In fact, the feature doesn't yet exist
61 | in the database, which is the same as being deployed to 0% of users.) A week
62 | later, when the company is ready to start deploying the feature to a few
63 | people, the product manager, Vijay, signs in to their site and navigates
64 | to `/features`, adds a new feature called "live_postings" and sets its
65 | deployment percentage to 3%. After a few days, the operations team decides
66 | that the increase in traffic is not going to overwhelm their servers, and
67 | Vijay can bump the deployment percentage up to 50%. A few more days go by
68 | and they clean up the last few bugs they found with the "live_postings"
69 | feature and deploy it to all users.
70 |
71 | ## Installation
72 |
73 | ```Ruby
74 | gem 'arturo'
75 | ```
76 |
77 | ## Configuration
78 |
79 | ### In Rails
80 |
81 | #### Run the generators:
82 |
83 | ```
84 | rails g arturo:migration
85 | rails g arturo:initializer
86 | rails g arturo:routes
87 | rails g arturo:assets
88 | rails g arturo:feature_model
89 | ```
90 |
91 | #### Run the migration:
92 |
93 | ```
94 | rake db:migrate
95 | ```
96 |
97 | #### Edit the generated migration as necessary
98 |
99 | #### Edit the configuration
100 |
101 | #### Edit the Feature model
102 |
103 | By default, the generated model `Arturo::Feature` inherits from `ActiveRecord::Base`. However, if you’re using multiple databases your models should inherit from an abstract class that specifies a database connection, not directly from `ActiveRecord::Base`. Update the generated model in `app/models/arturo/feature.rb` to make it use a correct database.
104 |
105 | ##### Initializer
106 |
107 | Open up the newly-generated `config/initializers/arturo_initializer.rb`.
108 | There are configuration options for the following:
109 |
110 | * logging capabilities (see [logging](#logging))
111 | * the method that determines whether a user has permission to manage features
112 | (see [admin permissions](#adminpermissions))
113 | * the method that returns the object that has features
114 | (e.g. User, Person, or Account; see
115 | [feature recipients](#featurerecipients))
116 | * whitelists and blacklists for features
117 | (see [white- and blacklisting](#wblisting))
118 |
119 | ##### CSS
120 |
121 | Open up the newly-generated `public/stylesheets/arturo_customizations.css`.
122 | You can add any overrides you like to the feature configuration page styles
123 | here. **Do not** edit `public/stylesheets/arturo.css` as that file may be
124 | overwritten in future updates to Arturo.
125 |
126 | ### In other frameworks
127 |
128 | Arturo is a Rails engine. I want to promote reuse on other frameworks by
129 | extracting key pieces into mixins, though this isn't done yet. Open an
130 | [issue](http://github.com/zendesk/arturo/issues) and I'll be happy to
131 | work with you on support for your favorite framework.
132 |
133 | ## Deep-Dive
134 |
135 | ### Logging
136 |
137 | You can provide a logger in order to inspect Arturo usage.
138 | A potential implementation for Rails would be:
139 |
140 | ```Ruby
141 | Arturo.logger = Rails.logger
142 | ```
143 |
144 | ### Admin Permissions
145 |
146 | `Arturo::FeatureManagement#may_manage_features?` is a method that is run in
147 | the context of a Controller or View instance. It should return `true` if
148 | and only if the current user may manage permissions. The default implementation
149 | is as follows:
150 |
151 | ```Ruby
152 | current_user.present? && current_user.admin?
153 | ```
154 |
155 | You can change the implementation in
156 | `config/initializers/arturo_initializer.rb`. A reasonable implementation
157 | might be
158 |
159 | ```Ruby
160 | Arturo.permit_management do
161 | signed_in? && current_user.can?(:manage_features)
162 | end
163 | ```
164 |
165 | ### Feature Recipients
166 |
167 | Clients of Arturo may want to deploy new features on a per-user, per-project,
168 | per-account, or other basis. For example, it is likely Twitter deployed
169 | "#newtwitter" on a per-user basis. Conversely, Facebook -- at least in its
170 | early days -- may have deployed features on a per-university basis. It wouldn't
171 | make much sense to deploy a feature to one user of a Basecamp project but not
172 | to others, so 37Signals would probably want a per-project or per-account basis.
173 |
174 | `Arturo::FeatureAvailability#feature_recipient` is intended to support these
175 | many use cases. It is a method that returns the current "thing" (a user, account,
176 | project, university, ...) that is a member of the category that is the basis for
177 | deploying new features. It should return an `Object` that responds to `#id`.
178 |
179 | The default implementation simply returns `current_user`. Like
180 | `Arturo::FeatureManagement#may_manage_features?`, this method can be configured
181 | in `config/initializers/arturo_initializer.rb`. If you want to deploy features
182 | on a per-account basis, a reasonable implementation might be
183 |
184 | ```Ruby
185 | Arturo.feature_recipient do
186 | current_account
187 | end
188 | ```
189 |
190 | or
191 |
192 | ```Ruby
193 | Arturo.feature_recipient do
194 | current_user.account
195 | end
196 | ```
197 |
198 | If the block returns `nil`, the feature will be disabled.
199 |
200 | ### Whitelists & Blacklists
201 |
202 | Whitelists and blacklists allow you to control exactly which users or accounts
203 | will have a feature. For example, if all premium users should have the
204 | `:awesome` feature, place the following in
205 | `config/initializers/arturo_initializer.rb`:
206 |
207 | ```Ruby
208 | Arturo::Feature.whitelist(:awesome) do |user|
209 | user.account.premium?
210 | end
211 | ```
212 |
213 | If, on the other hand, no users on the free plan should have the
214 | `:awesome` feature, place the following in
215 | `config/initializers/arturo_initializer.rb`:
216 |
217 | ```Ruby
218 | Arturo::Feature.blacklist(:awesome) do |user|
219 | user.account.free?
220 | end
221 | ```
222 |
223 | If you want to whitelist or blacklist large groups of features at once, you
224 | can move the feature argument into the block:
225 |
226 | ```Ruby
227 | Arturo::Feature.whitelist do |feature, user|
228 | user.account.has?(feature.to_sym)
229 | end
230 | ```
231 |
232 | ### Feature Conditionals
233 |
234 | All that configuration is just a waste of time if Arturo didn't modify the
235 | behavior of your application based on feature availability. There are a few
236 | ways to do so.
237 |
238 | #### Controller Filters
239 |
240 | If an action should only be available to those with a feature enabled,
241 | use a before filter. The following will raise a 403 Forbidden error for
242 | every action within `BookHoldsController` that is invoked by a user who
243 | does not have the `:hold_book` feature.
244 |
245 | ```Ruby
246 | class BookHoldsController < ApplicationController
247 | require_feature :hold_book
248 | end
249 | ```
250 |
251 | `require_feature` accepts as a second argument a `Hash` that it passes on
252 | to `before_action`, so you can use `:only` and `:except` to specify exactly
253 | which actions are filtered.
254 |
255 | If you want to customize the page that is rendered on 403 Forbidden
256 | responses, put the view in
257 | `RAILS_ROOT/app/views/arturo/features/forbidden.html.erb`. Rails will
258 | check there before falling back on Arturo's forbidden page.
259 |
260 | #### Conditional Evaluation
261 |
262 | Both controllers and views have access to the `if_feature_enabled?` and
263 | `feature_enabled?` methods. The former is used like so:
264 |
265 | ```ERB
266 | <% if_feature_enabled?(:reserve_table) %>
267 | <%= link_to 'Reserve a table', new_restaurant_reservation_path(:restaurant_id => @restaurant) %>
268 | <% end %>
269 | ```
270 |
271 | The latter can be used like so:
272 |
273 | ```Ruby
274 | def widgets_for_sidebar
275 | widgets = []
276 | widgets << twitter_widget if feature_enabled?(:twitter_integration)
277 | ...
278 | widgets
279 | end
280 | ```
281 |
282 | #### Rack Middleware
283 |
284 | ```Ruby
285 | require 'arturo'
286 | use Arturo::Middleware, feature: :my_feature
287 | ```
288 |
289 | #### Outside a Controller
290 |
291 | If you want to check availability outside of a controller or view (really
292 | outside of something that has `Arturo::FeatureAvailability` mixed in), you
293 | can ask either
294 |
295 | ```Ruby
296 | Arturo.feature_enabled_for?(:foo, recipient)
297 | ```
298 |
299 | or the slightly fancier
300 |
301 | ```Ruby
302 | Arturo.foo_enabled_for?(recipient)
303 | ```
304 |
305 | Both check whether the `foo` feature exists and is enabled for `recipient`.
306 |
307 | #### Caching
308 |
309 | **Note**: Arturo has support for caching `Feature` lookups, but doesn't yet
310 | integrate with Rails's caching. This means you should be very careful when
311 | caching actions or pages that involve feature detection as you will get
312 | strange behavior when a user who has access to a feature requests a page
313 | just after one who does not (and vice versa).
314 |
315 | To enable caching `Feature` lookups, mix `Arturo::FeatureCaching` into
316 | `Arturo::Feature` and set the `cache_ttl`. This is best done in an
317 | initializer:
318 |
319 | ```Ruby
320 | Arturo::Feature.extend(Arturo::FeatureCaching)
321 | Arturo::Feature.cache_ttl = 10.minutes
322 | ```
323 |
324 | You can also warm the cache on startup:
325 |
326 | ```Ruby
327 | Arturo::Feature.warm_cache!
328 | ```
329 |
330 | This will pre-fetch all `Feature`s and put them in the cache.
331 |
332 | To use the current cache state when you can't fetch updates from origin:
333 |
334 | ```Ruby
335 | Arturo::Feature.extend_cache_on_failure = true
336 | ```
337 |
338 | The following is the **intended** support for integration with view caching:
339 |
340 | Both the `require_feature` before filter and the `if_feature_enabled` block
341 | evaluation automatically append a string based on the feature's
342 | `last_modified` timestamp to cache keys that Rails generates. Thus, you don't
343 | have to worry about expiring caches when you increase a feature's deployment
344 | percentage. See `Arturo::CacheSupport` for more information.
345 |
346 | ## The Name
347 |
348 | Arturo gets its name from
349 | [Professor Maximillian Arturo](http://en.wikipedia.org/wiki/Maximillian_Arturo)
350 | on [Sliders](http://en.wikipedia.org/wiki/Sliders).
351 |
352 | ## Quality Metrics
353 |
354 | [](https://github.com/zendesk/arturo/actions?query=workflow%3ACI)
355 |
356 | [](https://codeclimate.com/github/zendesk/arturo)
357 |
--------------------------------------------------------------------------------
/spec/dummy_app/public/javascripts/jquery-1.4.3.min.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * jQuery JavaScript Library v1.4.3
3 | * http://jquery.com/
4 | *
5 | * Copyright 2010, John Resig
6 | * Dual licensed under the MIT or GPL Version 2 licenses.
7 | * http://jquery.org/license
8 | *
9 | * Includes Sizzle.js
10 | * http://sizzlejs.com/
11 | * Copyright 2010, The Dojo Foundation
12 | * Released under the MIT, BSD, and GPL Licenses.
13 | *
14 | * Date: Thu Oct 14 23:10:06 2010 -0400
15 | */
16 | (function(E,A){function U(){return false}function ba(){return true}function ja(a,b,d){d[0].type=a;return c.event.handle.apply(b,d)}function Ga(a){var b,d,e=[],f=[],h,k,l,n,s,v,B,D;k=c.data(this,this.nodeType?"events":"__events__");if(typeof k==="function")k=k.events;if(!(a.liveFired===this||!k||!k.live||a.button&&a.type==="click")){if(a.namespace)D=RegExp("(^|\\.)"+a.namespace.split(".").join("\\.(?:.*\\.)?")+"(\\.|$)");a.liveFired=this;var H=k.live.slice(0);for(n=0;nd)break;a.currentTarget=f.elem;a.data=f.handleObj.data;
18 | a.handleObj=f.handleObj;D=f.handleObj.origHandler.apply(f.elem,arguments);if(D===false||a.isPropagationStopped()){d=f.level;if(D===false)b=false}}return b}}function Y(a,b){return(a&&a!=="*"?a+".":"")+b.replace(Ha,"`").replace(Ia,"&")}function ka(a,b,d){if(c.isFunction(b))return c.grep(a,function(f,h){return!!b.call(f,h,f)===d});else if(b.nodeType)return c.grep(a,function(f){return f===b===d});else if(typeof b==="string"){var e=c.grep(a,function(f){return f.nodeType===1});if(Ja.test(b))return c.filter(b,
19 | e,!d);else b=c.filter(b,e)}return c.grep(a,function(f){return c.inArray(f,b)>=0===d})}function la(a,b){var d=0;b.each(function(){if(this.nodeName===(a[d]&&a[d].nodeName)){var e=c.data(a[d++]),f=c.data(this,e);if(e=e&&e.events){delete f.handle;f.events={};for(var h in e)for(var k in e[h])c.event.add(this,h,e[h][k],e[h][k].data)}}})}function Ka(a,b){b.src?c.ajax({url:b.src,async:false,dataType:"script"}):c.globalEval(b.text||b.textContent||b.innerHTML||"");b.parentNode&&b.parentNode.removeChild(b)}
20 | function ma(a,b,d){var e=b==="width"?a.offsetWidth:a.offsetHeight;if(d==="border")return e;c.each(b==="width"?La:Ma,function(){d||(e-=parseFloat(c.css(a,"padding"+this))||0);if(d==="margin")e+=parseFloat(c.css(a,"margin"+this))||0;else e-=parseFloat(c.css(a,"border"+this+"Width"))||0});return e}function ca(a,b,d,e){if(c.isArray(b)&&b.length)c.each(b,function(f,h){d||Na.test(a)?e(a,h):ca(a+"["+(typeof h==="object"||c.isArray(h)?f:"")+"]",h,d,e)});else if(!d&&b!=null&&typeof b==="object")c.isEmptyObject(b)?
21 | e(a,""):c.each(b,function(f,h){ca(a+"["+f+"]",h,d,e)});else e(a,b)}function S(a,b){var d={};c.each(na.concat.apply([],na.slice(0,b)),function(){d[this]=a});return d}function oa(a){if(!da[a]){var b=c("<"+a+">").appendTo("body"),d=b.css("display");b.remove();if(d==="none"||d==="")d="block";da[a]=d}return da[a]}function ea(a){return c.isWindow(a)?a:a.nodeType===9?a.defaultView||a.parentWindow:false}var u=E.document,c=function(){function a(){if(!b.isReady){try{u.documentElement.doScroll("left")}catch(i){setTimeout(a,
22 | 1);return}b.ready()}}var b=function(i,r){return new b.fn.init(i,r)},d=E.jQuery,e=E.$,f,h=/^(?:[^<]*(<[\w\W]+>)[^>]*$|#([\w\-]+)$)/,k=/\S/,l=/^\s+/,n=/\s+$/,s=/\W/,v=/\d/,B=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,D=/^[\],:{}\s]*$/,H=/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,w=/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,G=/(?:^|:|,)(?:\s*\[)+/g,M=/(webkit)[ \/]([\w.]+)/,g=/(opera)(?:.*version)?[ \/]([\w.]+)/,j=/(msie) ([\w.]+)/,o=/(mozilla)(?:.*? rv:([\w.]+))?/,m=navigator.userAgent,p=false,
23 | q=[],t,x=Object.prototype.toString,C=Object.prototype.hasOwnProperty,P=Array.prototype.push,N=Array.prototype.slice,R=String.prototype.trim,Q=Array.prototype.indexOf,L={};b.fn=b.prototype={init:function(i,r){var y,z,F;if(!i)return this;if(i.nodeType){this.context=this[0]=i;this.length=1;return this}if(i==="body"&&!r&&u.body){this.context=u;this[0]=u.body;this.selector="body";this.length=1;return this}if(typeof i==="string")if((y=h.exec(i))&&(y[1]||!r))if(y[1]){F=r?r.ownerDocument||r:u;if(z=B.exec(i))if(b.isPlainObject(r)){i=
24 | [u.createElement(z[1])];b.fn.attr.call(i,r,true)}else i=[F.createElement(z[1])];else{z=b.buildFragment([y[1]],[F]);i=(z.cacheable?z.fragment.cloneNode(true):z.fragment).childNodes}return b.merge(this,i)}else{if((z=u.getElementById(y[2]))&&z.parentNode){if(z.id!==y[2])return f.find(i);this.length=1;this[0]=z}this.context=u;this.selector=i;return this}else if(!r&&!s.test(i)){this.selector=i;this.context=u;i=u.getElementsByTagName(i);return b.merge(this,i)}else return!r||r.jquery?(r||f).find(i):b(r).find(i);
25 | else if(b.isFunction(i))return f.ready(i);if(i.selector!==A){this.selector=i.selector;this.context=i.context}return b.makeArray(i,this)},selector:"",jquery:"1.4.3",length:0,size:function(){return this.length},toArray:function(){return N.call(this,0)},get:function(i){return i==null?this.toArray():i<0?this.slice(i)[0]:this[i]},pushStack:function(i,r,y){var z=b();b.isArray(i)?P.apply(z,i):b.merge(z,i);z.prevObject=this;z.context=this.context;if(r==="find")z.selector=this.selector+(this.selector?" ":
26 | "")+y;else if(r)z.selector=this.selector+"."+r+"("+y+")";return z},each:function(i,r){return b.each(this,i,r)},ready:function(i){b.bindReady();if(b.isReady)i.call(u,b);else q&&q.push(i);return this},eq:function(i){return i===-1?this.slice(i):this.slice(i,+i+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(N.apply(this,arguments),"slice",N.call(arguments).join(","))},map:function(i){return this.pushStack(b.map(this,function(r,y){return i.call(r,
27 | y,r)}))},end:function(){return this.prevObject||b(null)},push:P,sort:[].sort,splice:[].splice};b.fn.init.prototype=b.fn;b.extend=b.fn.extend=function(){var i=arguments[0]||{},r=1,y=arguments.length,z=false,F,I,K,J,fa;if(typeof i==="boolean"){z=i;i=arguments[1]||{};r=2}if(typeof i!=="object"&&!b.isFunction(i))i={};if(y===r){i=this;--r}for(;r0)){if(q){for(var r=0;i=q[r++];)i.call(u,b);q=null}b.fn.triggerHandler&&b(u).triggerHandler("ready")}}},bindReady:function(){if(!p){p=true;if(u.readyState==="complete")return setTimeout(b.ready,
29 | 1);if(u.addEventListener){u.addEventListener("DOMContentLoaded",t,false);E.addEventListener("load",b.ready,false)}else if(u.attachEvent){u.attachEvent("onreadystatechange",t);E.attachEvent("onload",b.ready);var i=false;try{i=E.frameElement==null}catch(r){}u.documentElement.doScroll&&i&&a()}}},isFunction:function(i){return b.type(i)==="function"},isArray:Array.isArray||function(i){return b.type(i)==="array"},isWindow:function(i){return i&&typeof i==="object"&&"setInterval"in i},isNaN:function(i){return i==
30 | null||!v.test(i)||isNaN(i)},type:function(i){return i==null?String(i):L[x.call(i)]||"object"},isPlainObject:function(i){if(!i||b.type(i)!=="object"||i.nodeType||b.isWindow(i))return false;if(i.constructor&&!C.call(i,"constructor")&&!C.call(i.constructor.prototype,"isPrototypeOf"))return false;for(var r in i);return r===A||C.call(i,r)},isEmptyObject:function(i){for(var r in i)return false;return true},error:function(i){throw i;},parseJSON:function(i){if(typeof i!=="string"||!i)return null;i=b.trim(i);
31 | if(D.test(i.replace(H,"@").replace(w,"]").replace(G,"")))return E.JSON&&E.JSON.parse?E.JSON.parse(i):(new Function("return "+i))();else b.error("Invalid JSON: "+i)},noop:function(){},globalEval:function(i){if(i&&k.test(i)){var r=u.getElementsByTagName("head")[0]||u.documentElement,y=u.createElement("script");y.type="text/javascript";if(b.support.scriptEval)y.appendChild(u.createTextNode(i));else y.text=i;r.insertBefore(y,r.firstChild);r.removeChild(y)}},nodeName:function(i,r){return i.nodeName&&i.nodeName.toUpperCase()===
32 | r.toUpperCase()},each:function(i,r,y){var z,F=0,I=i.length,K=I===A||b.isFunction(i);if(y)if(K)for(z in i){if(r.apply(i[z],y)===false)break}else for(;F