Skip to content

Commit

Permalink
Merge pull request #17 from PRX/feat/sorting_filtering
Browse files Browse the repository at this point in the history
Add sorting and filtering
  • Loading branch information
cavis authored Aug 16, 2019
2 parents 883b87b + f6537e8 commit 5d6d7fe
Show file tree
Hide file tree
Showing 10 changed files with 397 additions and 7 deletions.
4 changes: 4 additions & 0 deletions lib/hal_api/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ module HalApi::Controller
require 'hal_api/controller/cache'
require 'hal_api/controller/resources'
require 'hal_api/controller/exceptions'
require 'hal_api/controller/sorting'
require 'hal_api/controller/filtering'
require 'hal_api/responders/api_responder'

include HalApi::Controller::Actions
include HalApi::Controller::Cache
include HalApi::Controller::Resources
include HalApi::Controller::Exceptions
include HalApi::Controller::Sorting
include HalApi::Controller::Filtering

included do
include Roar::Rails::ControllerAdditions
Expand Down
107 changes: 107 additions & 0 deletions lib/hal_api/controller/filtering.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
module HalApi::Controller::Filtering
extend ActiveSupport::Concern

included do
class_eval do
class_attribute :allowed_filter_names
class_attribute :allowed_filter_types
end
end

class FilterParams < OpenStruct
def initialize(filters = {})
@filters = filters.with_indifferent_access
end

def method_missing(m, *args, &_block)
if @filters.key?(m) && args.empty?
@filters[m]
elsif m.to_s[-1] == '?' && args.empty? && @filters.key?(m.to_s.chop)
!!@filters[m.to_s.chop]
else
msg = "Unknown filter param '#{m}'"
hint = "Valid filters are: #{@filters.keys.join(' ')}"
raise HalApi::Errors::UnknownFilterError.new(msg, hint)
end
end
end

module ClassMethods
def filter_params(*args)
self.allowed_filter_names = []
self.allowed_filter_types = {}
(args || []).map do |arg|
if arg.is_a? Hash
arg.to_a.each { |key, val| add_filter_param(key.to_s, val.to_s) }
else
add_filter_param(arg.to_s)
end
end
end

private

def add_filter_param(name, type = nil)
unless allowed_filter_names.include? name
allowed_filter_names << name
allowed_filter_types[name] = type unless type.nil?
end
end
end

def filters
@filters ||= parse_filters_param
end

private

def parse_filters_param
filters_map = {}
filters = self.class.allowed_filter_names
force_types = self.class.allowed_filter_types

# set nils
filters.each do |name|
filters_map[name] = nil
end

# parse query param
(params[:filters] || '').split(',').each do |str|
name, value = str.split('=', 2)
next unless filters_map.key?(name)

# convert/guess type of known params
filters_map[name] =
if force_types[name] == 'date'
parse_date(value)
elsif force_types[name] == 'time'
parse_time(value)
elsif value.nil?
true
elsif value.blank?
''
elsif [false, 'false'].include? value
false
elsif [true, 'true'].include? value
true
elsif value =~ /\A[-+]?\d+\z/
value.to_i
else
value
end
end
FilterParams.new(filters_map)
end

def parse_date(str)
Date.parse(str)
rescue ArgumentError
raise HalApi::Errors::BadFilterValueError.new "Invalid filter date: '#{str}'"
end

def parse_time(str)
Time.find_zone('UTC').parse(str) || (raise ArgumentError.new 'Nil result!')
rescue ArgumentError
raise HalApi::Errors::BadFilterValueError.new "Invalid filter time: '#{str}'"
end
end
61 changes: 61 additions & 0 deletions lib/hal_api/controller/sorting.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
module HalApi::Controller::Sorting
extend ActiveSupport::Concern

included do
class_eval do
class_attribute :allowed_sort_names
class_attribute :default_sort
end
end

module ClassMethods
def sort_params(args)
self.allowed_sort_names = args[:allowed].map(&:to_s).uniq
self.default_sort = args[:default]
if default_sort && !default_sort.is_a?(Array)
self.default_sort = Array[default_sort]
end
end
end

def sorts
@sorts ||= parse_sorts_param
end

def sorted(arel)
apply_sorts = !sorts.blank? ? sorts : default_sort
if apply_sorts.blank?
super
else
arel.order(*apply_sorts)
end
end

private

# support ?sorts=attribute,attribute:direction params
# e.g. ?sorts=published_at,updated_at:desc
# desc is default if a direction is not specified
def parse_sorts_param
sorts_array = []
allowed_sorts = self.class.allowed_sort_names

# parse sort param for name of the column and direction
# default is descending, because I say so, and we have a bias towards the new
(params[:sorts] || '').split(',').each do |str|
name, direction = str.split(':', 2).map { |s| s.to_s.strip }
name = name.underscore
direction = direction.blank? ? 'desc' : direction.downcase
unless allowed_sorts.include?(name)
hint = "Valid sorts are: #{allowed_sorts.join(' ')}"
raise HalApi::Errors::BadSortError.new("Invalid sort: #{name}", hint)
end
unless ['asc', 'desc'].include?(direction)
hint = "Valid directions are: asc desc"
raise HalApi::Errors::BadSortError.new("Invalid sort direction: #{direction}", hint)
end
sorts_array << { name => direction }
end
sorts_array
end
end
30 changes: 28 additions & 2 deletions lib/hal_api/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@ module HalApi::Errors

class ApiError < StandardError
attr_accessor :status
attr_accessor :hint

def initialize(message = nil, status = 500)
def initialize(message = nil, status = nil, hint = nil)
super(message || "API Error")
self.status = status
self.status = status || 500
self.hint = hint
end
end

class Forbidden < ApiError
def initialize(message = nil, hint = nil)
super(message || 'Forbidden', 403, hint)
end
end

Expand All @@ -23,6 +31,24 @@ def initialize(type)
end
end

class BadSortError < ApiError
def initialize(msg, hint = nil)
super(msg, 400, hint)
end
end

class UnknownFilterError < ApiError
def initialize(msg, hint = nil)
super(msg, 400, hint)
end
end

class BadFilterValueError < ApiError
def initialize(msg, hint = nil)
super(msg, 400, hint)
end
end

module Representer
include Roar::JSON::HAL

Expand Down
2 changes: 1 addition & 1 deletion lib/hal_api/rails/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module HalApi
module Rails
VERSION = "0.5.0"
VERSION = "0.6.0"
end
end
2 changes: 1 addition & 1 deletion test/hal_api/controller/actions_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def self.caches_action(action, options = {})

it 'authorizes the resource' do
controller.wont_be :respond_to?, :authorize
assert_send([controller, :hal_authorize, {}])
assert controller.send(:hal_authorize, {})
controller.must_be :respond_to?, :authorize
end

Expand Down
87 changes: 87 additions & 0 deletions test/hal_api/controller/filtering_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
require 'test_helper'

describe HalApi::Controller::Filtering do

class FilteringTestController < ActionController::Base
include HalApi::Controller::Filtering

filter_params :one, :two, :three, :four, :five, six: :date, seven: :time

attr_accessor :filter_string

def params
{ filters: filter_string }
end
end

let(:controller) { FilteringTestController.new }

it 'parses query params' do
controller.filter_string = 'one,two=2,three=something,four='
controller.filters.one.must_equal true
controller.filters.two.must_equal 2
controller.filters.three.must_equal 'something'
controller.filters.four.must_equal ''
end

it 'restricts to known params' do
controller.filter_string = 'one,foo,two,bar'
controller.filters.one.must_equal true
controller.filters.two.must_equal true
err = assert_raises { controller.filters.foo }
err.must_be_instance_of(HalApi::Errors::UnknownFilterError)
err.hint.must_match /valid filters are: one two/i
err = assert_raises { controller.filters.whatever }
err.must_be_instance_of(HalApi::Errors::UnknownFilterError)
err.hint.must_match /valid filters are: one two/i
end

it 'provides boolean testers' do
controller.filter_string = 'one,two=1,three=false,four=,five=0'
controller.filters.one?.must_equal true
controller.filters.two?.must_equal true
controller.filters.three?.must_equal false
controller.filters.four?.must_equal true
controller.filters.five?.must_equal true
controller.filters.six?.must_equal false
controller.filters.seven?.must_equal false
assert_raises { controller.filters.whatever? }
end

it 'defaults to nil/false for unset filters' do
controller.filter_string = nil
controller.filters.one.must_be_nil
controller.filters.one?.must_equal false
end

it 'parses dates' do
controller.filter_string = 'six=20190203'
controller.filters.six?.must_equal true
controller.filters.six.must_equal Date.parse('2019-02-03')
end

it 'raises parse errors for dates' do
controller.filter_string = 'six=bad-string'
err = assert_raises { puts controller.filters.six }
err.must_be_instance_of(HalApi::Errors::BadFilterValueError)
end

it 'parses datetimes' do
controller.filter_string = 'seven=2019-02-03T01:02:03 -0700'
controller.filters.seven?.must_equal true
controller.filters.seven.must_equal Time.parse('2019-02-03T08:02:03Z')
end

it 'defaults datetimes to utc' do
controller.filter_string = 'seven=20190203'
controller.filters.seven?.must_equal true
controller.filters.seven.must_equal Time.parse('2019-02-03T00:00:00Z')
end

it 'raises parse errors for times' do
controller.filter_string = 'seven=bad-string'
err = assert_raises { puts controller.filters.seven }
err.must_be_instance_of(HalApi::Errors::BadFilterValueError)
end

end
Loading

0 comments on commit 5d6d7fe

Please sign in to comment.