Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

misc(filters-contracts): Define contracts to validate query objects filters #2650

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ gem 'puma', '~> 6.4'
gem 'rails', '~> 7.1.3.4'
gem 'redis'
gem 'sidekiq'
gem 'dry-validation'

# Security
gem 'bcrypt'
Expand Down
34 changes: 34 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,39 @@ GEM
docile (1.4.1)
dotenv (3.1.2)
drb (2.2.1)
dry-configurable (1.2.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-core (1.0.1)
concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6)
dry-inflector (1.1.0)
dry-initializer (3.1.1)
dry-logic (1.5.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-schema (1.13.4)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0, >= 1.0.1)
dry-core (~> 1.0, < 2)
dry-initializer (~> 3.0)
dry-logic (>= 1.4, < 2)
dry-types (>= 1.7, < 2)
zeitwerk (~> 2.6)
dry-types (1.7.2)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
dry-validation (1.10.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
dry-initializer (~> 3.0)
dry-schema (>= 1.12, < 2)
zeitwerk (~> 2.6)
erubi (1.13.0)
execjs (2.9.1)
factory_bot (6.4.6)
Expand Down Expand Up @@ -892,6 +925,7 @@ DEPENDENCIES
debug
discard (~> 1.2)
dotenv
dry-validation
factory_bot_rails
faker
gocardless_pro (~> 2.34)
Expand Down
12 changes: 12 additions & 0 deletions app/contracts/billable_metrics_query_filters_contract.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

class BillableMetricsQueryFiltersContract < Dry::Validation::Contract
params do
required(:filters).hash do
optional(:recurring).filled(:bool)
optional(:aggregation_types).array(:string, included_in?: %w[max_agg count_agg])
end

optional(:search_term).maybe(:string)
end
end
13 changes: 13 additions & 0 deletions app/queries/base_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ def initialize(organization:, pagination: DEFAULT_PAGINATION_PARAMS, filters: {}

attr_reader :organization, :pagination_params, :filters, :search_term, :order

def validate_filters
contract_class_name = "#{self.class.name}FiltersContract"
contract = Object.const_get(contract_class_name).new
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a thought, do you think we could find a way to pass or assign the Contract class to the query class, to avoid doing some magic to load it?

validation_result = contract.call(filters: filters.to_h, search_term:)

unless validation_result.success?
errors = validation_result.errors.to_h
result.validation_failure!(errors:)
end

result
end

def pagination
return if pagination_params.blank?

Expand Down
2 changes: 2 additions & 0 deletions app/queries/billable_metrics_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

class BillableMetricsQuery < BaseQuery
def call
return result unless validate_filters.success?

metrics = base_scope.result
metrics = paginate(metrics)
metrics = metrics.order(created_at: :desc)
Expand Down
59 changes: 59 additions & 0 deletions spec/contracts/billable_metrics_query_filters_contract_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe BillableMetricsQueryFiltersContract, type: :contract do
subject(:result) { described_class.new.call(filters:, search_term:) }

let(:filters) { {} }
let(:search_term) { nil }

context "when filters are valid" do
let(:filters) do
{
recurring: true,
aggregation_types: ["max_agg", "count_agg"]
}
end

it "is valid" do
expect(result.success?).to be(true)
end
end

context "when search_term is provided and valid" do
let(:search_term) { "valid_search_term" }

it "is valid" do
expect(result.success?).to be(true)
end
end

context "when search_term is invalid" do
let(:search_term) { 12345 }

it "is invalid" do
expect(result.success?).to be(false)
expect(result.errors.to_h).to include(search_term: ["must be a string"])
end
end

context "when filters are invalid" do
shared_examples "an invalid filter" do |filter, value, error_message|
let(:filters) { {filter => value} }

it "is invalid when #{filter} is set to #{value.inspect}" do
expect(result.success?).to be(false)
expect(result.errors.to_h).to include(filters: {filter => error_message})
end
end

it_behaves_like "an invalid filter", :recurring, nil, ["must be filled"]
it_behaves_like "an invalid filter", :recurring, "not_a_bool", ["must be boolean"]

it_behaves_like "an invalid filter", :aggregation_types, nil, ["must be an array"]
it_behaves_like "an invalid filter", :aggregation_types, "not_an_array", ["must be an array"]
it_behaves_like "an invalid filter", :aggregation_types, [1], {0 => ["must be a string"]}
it_behaves_like "an invalid filter", :aggregation_types, ["invalid_type"], {0 => ["must be one of: max_agg, count_agg"]}
end
end
15 changes: 15 additions & 0 deletions spec/queries/billable_metrics_query_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@
end
end

context "when filters validation fails" do
let(:filters) do
{
recurring: "unexpected_value",
aggregation_types: ["unexpected_value"]
}
end

it "captures all validation errors" do
expect(result).not_to be_success
expect(result.error.messages[:filters][:recurring]).to include("must be boolean")
expect(result.error.messages[:filters][:aggregation_types][0]).to include("must be one of: max_agg, count_agg")
end
end

context 'with pagination' do
let(:pagination) { {page: 2, limit: 3} }

Expand Down