Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ gem 'rails', '~> 7.1'
gem 'scout_apm'
gem 'sentry-rails'
gem 'statesman'
gem "flipper", "~> 1.3"
gem "flipper-active_record", "~> 1.3"

group :development, :test do
gem 'awesome_print'
Expand Down Expand Up @@ -76,3 +78,5 @@ group :test do
gem 'webdrivers'
gem 'webmock'
end

gem "flipper-ui", "~> 1.3"
18 changes: 18 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ GEM
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
fiber-storage (1.0.1)
flipper (1.3.6)
concurrent-ruby (< 2)
flipper-active_record (1.3.6)
activerecord (>= 4.2, < 9)
flipper (~> 1.3.6)
flipper-ui (1.3.6)
erubi (>= 1.0.0, < 2.0.0)
flipper (~> 1.3.6)
rack (>= 1.4, < 4)
rack-protection (>= 1.5.3, < 5.0.0)
rack-session (>= 1.0.2, < 3.0.0)
sanitize (< 8)
fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
Expand Down Expand Up @@ -481,6 +493,9 @@ GEM
ffi (~> 1.12)
logger
rubyzip (3.0.2)
sanitize (7.0.0)
crass (~> 1.0.2)
nokogiri (>= 1.16.8)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
Expand Down Expand Up @@ -579,6 +594,9 @@ DEPENDENCIES
factory_bot_rails
faker
faraday
flipper (~> 1.3)
flipper-active_record (~> 1.3)
flipper-ui (~> 1.3)
github_webhook (~> 1.4)
globalid
good_job (~> 4.3)
Expand Down
13 changes: 13 additions & 0 deletions app/controllers/api/features_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Api
class FeaturesController < ApiController
def index
features = Flipper.features.map do |feature|
[feature.key, Flipper.enabled?(feature.key, current_user&.schools&.first)]
end

render json: features.to_h
end
end
end
2 changes: 2 additions & 0 deletions app/helpers/features_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module FeaturesHelper
end
45 changes: 45 additions & 0 deletions config/initializers/flipper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
Rails.application.configure do
## Memoization ensures that only one adapter call is made per feature per request.
## For more info, see https://www.flippercloud.io/docs/optimization#memoization
# config.flipper.memoize = true

## Flipper preloads all features before each request, which is recommended if:
## * you have a limited number of features (< 100?)
## * most of your requests depend on most of your features
## * you have limited gate data combined across all features (< 1k enabled gates, like individual actors, across all features)
##
## For more info, see https://www.flippercloud.io/docs/optimization#preloading
# config.flipper.preload = true

## Warn or raise an error if an unknown feature is checked
## Can be set to `:warn`, `:raise`, or `false`
# config.flipper.strict = Rails.env.development? && :warn

## Show Flipper checks in logs
# config.flipper.log = true

## Reconfigure Flipper to use the Memory adapter and disable Cloud in tests
# config.flipper.test_help = true

## The path that Flipper Cloud will use to sync features
# config.flipper.cloud_path = "_flipper"

## The instrumenter that Flipper will use. Defaults to ActiveSupport::Notifications.
# config.flipper.instrumenter = ActiveSupport::Notifications
end

Flipper.configure do |config|
## Configure other adapters that you want to use here:
## See http://flippercloud.io/docs/adapters
# config.use Flipper::Adapters::ActiveSupportCacheStore, Rails.cache, expires_in: 5.minutes
end

## Register a group that can be used for enabling features.
##
## Flipper.enable_group :my_feature, :admins
##
## See https://www.flippercloud.io/docs/features#enablement-group
#
# Flipper.register(:admins) do |actor|
# actor.respond_to?(:admin?) && actor.admin?
# end
5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
mount GoodJob::Engine => 'good_job'
resources :components

mount Flipper::UI.app(Flipper) => '/flipper',
constraints: AdminSessionConstraint.new

resources :projects do
delete :images, on: :member, action: :destroy_image
end
Expand Down Expand Up @@ -87,6 +90,8 @@
resources :school_import_jobs, only: %i[show]

post '/google/auth/exchange-code', to: 'google_auth#exchange_code', defaults: { format: :json }

resources :features, only: %i[index]
end

resource :github_webhooks, only: :create, defaults: { formats: :json }
Expand Down
22 changes: 22 additions & 0 deletions db/migrate/20260126130135_create_flipper_tables.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
class CreateFlipperTables < ActiveRecord::Migration[7.2]
def up
create_table :flipper_features do |t|
t.string :key, null: false
t.timestamps null: false
end
add_index :flipper_features, :key, unique: true

create_table :flipper_gates do |t|
t.string :feature_key, null: false
t.string :key, null: false
t.text :value
t.timestamps null: false
end
add_index :flipper_gates, [:feature_key, :key, :value], unique: true, length: { value: 255 }
end

def down
drop_table :flipper_gates
drop_table :flipper_features
end
end
18 changes: 17 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions lib/helpers/admin_session_constraint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

class AdminSessionConstraint
def matches?(request)
current_user = request.session[:current_user]
return false unless current_user

User.new(current_user).admin?
end
end
121 changes: 121 additions & 0 deletions spec/requests/api/features_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe "Features", type: :request do
let(:headers) { { Authorization: UserProfileMock::TOKEN } }
let!(:school_class) { create(:school_class, teacher_ids: [teacher.id], school:) }
let(:school) { create(:school) }
let(:student) { create(:student, school:) }
let(:teacher) { create(:teacher, school:) }
let(:admin_user) { create(:admin_user) }

describe "Feature flag API" do
it "returns a globally-disabled feature as disabled" do
# Arrange
Flipper.disable :some_global_feature

# Act
get "/api/features"

# Assert
expect(response.body).not_to include('"some_global_feature":true')
end

it "returns a globally-enabled feature as enabled" do
# Arrange
Flipper.enable :some_global_feature

# Act
get "/api/features"

# Assert
expect(response.body).to include('"some_global_feature":true')
end

it "returns a school-level feature as disabled for logged-out user" do
# Arrange
Flipper.enable_actor :some_school_level_feature, school

# Act
get "/api/features"

# Assert
expect(response.body).not_to include('"some_school_level_feature":true')
end

it "returns a school-level feature as enabled for a student in that school" do
# Arrange
authenticated_in_hydra_as(student)

Flipper.enable_actor :some_school_level_feature, school

# Act
get "/api/features", headers: headers

# Assert
expect(response.body).to include('"some_school_level_feature":true')
end

it "returns both school-level and global features as enabled for a student in a school" do
# Arrange
authenticated_in_hydra_as(student)

Flipper.enable_actor :some_school_level_feature, school
Flipper.enable :some_global_feature

# Act
get "/api/features", headers: headers

# Assert
expect(response.body).to include('"some_school_level_feature":true')
expect(response.body).to include('"some_global_feature":true')
end

# todo: Don't leak the existence of disabled feature flags

end

describe "Feature flag web interface" do
it "is hidden from unauthenticated users" do
# Act
get "/admin/flipper/features"

# Assert
expect(response).to have_http_status(:not_found)
end

it "is hidden from student users" do
# Arrange
sign_in student

# Act
get "/admin/flipper/features"

# Assert
expect(response).to have_http_status(:not_found)
end

it "is hidden from teacher users" do
# Arrange
sign_in teacher

# Act
get "/admin/flipper/features"

# Assert
expect(response).to have_http_status(:not_found)
end

it "is visible to admins" do
# Arrange
sign_in admin_user

# Act
get "/admin/flipper/features"

# Assert
expect(response).to have_http_status(:success)
end
end
end