json_apiable 0.3 → 0.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d67de4927cb04df83deea484a0890bdfdc36cbf48d6bda986000ffabcda8f3b
4
- data.tar.gz: a90a940bc16d451c698355f1e18c74a5e183135ed3315f7aa272ad9a37e345f5
3
+ metadata.gz: 87003258543205a662a1a5963258ad69746fee6b5813a52a35cb604c7481488a
4
+ data.tar.gz: 13bc1aaeb033f6200a165f3b4d2878fd01a6478b0aa8c4348c455ea912929dfc
5
5
  SHA512:
6
- metadata.gz: ee5e99fac5548996d4222e3b281ca8d3d03c78e95b57414c08b8f6f624497140c1eff43d0f5e5d54a3c2c2fec9501e92b09d37deb2e5c688c132161ca88f031c
7
- data.tar.gz: 8d36927df5f8684188cc4e6f4ef263640429ddff9a84f9824860fe1ff1519c94fdb1b90642291681d5612478e9913ca818539a1f2c8d642657dba2d24d0f6a48
6
+ metadata.gz: 6b0a0a5be9cf7c2b43cd4a8cdb3a6cb9b68064cc5c97735c620e215a15cde00655243a8442b35d11a72c63664a3dce7e44d89abf444711e5ec5adf80941eaabb
7
+ data.tar.gz: 9968c847fbabe70821f12dc4ab991ae6ff1d3f1a197a9daae016e427a624a39b5e88ff18f15e8906b78e4d408b466c703103afd7de12acb5aa8548c5f92b77a2
data/.rubocop.yml ADDED
@@ -0,0 +1,39 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.4
3
+ Exclude:
4
+ - 'db/**/*'
5
+ - 'bin/**/*'
6
+ - 'config/**/*'
7
+ - 'spec/**/*'
8
+ - 'json_apiable.gemspec'
9
+ - 'Gemfile'
10
+ - 'Rakefile'
11
+
12
+ Metrics:
13
+ Enabled: false
14
+
15
+ Naming/AccessorMethodName:
16
+ Enabled: false
17
+
18
+ Style/PercentLiteralDelimiters:
19
+ Enabled: false
20
+
21
+ Style/HashSyntax:
22
+ Enabled: false
23
+
24
+ Style/Documentation:
25
+ Enabled: false
26
+
27
+ Style/StringLiterals:
28
+ Enabled: false
29
+
30
+ Style/ClassAndModuleChildren:
31
+ Enabled: false
32
+
33
+ Style/SymbolArray:
34
+ Enabled: false
35
+
36
+ Layout/DotPosition:
37
+ EnforcedStyle: trailing
38
+
39
+
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- json_apiable (0.3)
4
+ json_apiable (0.4)
5
5
  activerecord (>= 4.2)
6
6
  activesupport (>= 4.2)
7
7
  fast_jsonapi (~> 1.5)
data/README.md CHANGED
@@ -1,4 +1,6 @@
1
1
  # JsonApiable
2
+ [![Maintainability](https://api.codeclimate.com/v1/badges/add92f51e18446e44b29/maintainability)](https://codeclimate.com/github/mikemarsian/json_apiable/maintainability)
3
+ [![Gem Version](https://badge.fury.io/rb/json_apiable.svg)](https://badge.fury.io/rb/json_apiable)
2
4
 
3
5
  JsonApiable is a Ruby module that makes it easier for Rails API controllers to handle JSON:API parameter and relationship parsing,
4
6
  strong parameter validation, returning well-structured errors and more - all in a Rails-friendly way.
@@ -54,7 +56,7 @@ class API::PostsController < API::BaseController
54
56
  # GET /v1/posts
55
57
  def index
56
58
  # pass page and include info to your logic
57
- posts = GetPostsService.call(jsonapi_page, jsonapi_include)
59
+ posts = GetPostsService.call(jsonapi_page_hash, jsonapi_include_array)
58
60
  # some other gem, such as fast_jsonapi is assumed to produce the json:api output
59
61
  render json: posts
60
62
  end
@@ -97,7 +99,7 @@ class API::PostsController < API::BaseController
97
99
  #
98
100
  # }
99
101
  @post.update_attributes!(jsonapi_assign_params)
100
- render json: @user
102
+ render json: @post
101
103
  end
102
104
 
103
105
  def create
@@ -111,7 +113,7 @@ class API::PostsController < API::BaseController
111
113
  @comments_hash = jsonapi_exclude_relationship(:comments)
112
114
  do_some_logc_with_excluded_params
113
115
  # jsonapi_assign_params wouldn't include 'author' attribute and 'comments' relationship
114
- User.create!(jsonapi_assign_params)
116
+ Post.create!(jsonapi_assign_params)
115
117
  end
116
118
 
117
119
  protected
@@ -132,6 +134,76 @@ class API::PostsController < API::BaseController
132
134
 
133
135
  end
134
136
  ````
137
+ ### Filters
138
+ JsonApiable supports parsing filter requests in the form `example.com/v1/posts?filter[status]=draft` and returning errors
139
+ in case provided filter keys or values do not adhere to what you define:
140
+
141
+ ```ruby
142
+ # Create filter class that inherits from JsonApiable::BaseFilter
143
+ class API::PostFilter < JsonApiable::BaseFilter
144
+ # Declare which filter keys are supported
145
+ def self.jsonapi_allowed_filters
146
+ {
147
+ # For each key, declare what values are allowed. The supported value matchers include:
148
+ # 1) Array of values
149
+ # example.com/v1/posts?filter[status]=draft,published
150
+ status: Post.statuses.keys,
151
+
152
+ # 2) DateTime matcher - proc that checks that the provided value is a valid DateTime
153
+ # example.com/v1/posts?filter[published_at]='2001-02-03T04:05:06+03:00'
154
+ published_at: datetime_matcher,
155
+
156
+ # 3) Boolean matcher - proc that checks that the provided value is a boolean (true/t/1 for True, false/f/0 for False)
157
+ # example.com/v1/posts?filter[subscribers_only]=true
158
+ subscribers_only: boolean_matcher,
159
+
160
+ # 4) ID matcher - proc that checks that the provided ids exist for given model
161
+ # example.com/v1/posts?filter[ids]=10893,14596
162
+ ids: ids_matcher(Post),
163
+
164
+ # Of course, you can also implement your own matchers. For example:
165
+ reviewed_at: recent_datetime_matcher
166
+ }
167
+ end
168
+
169
+ # Example of custom filter value matcher
170
+ def self.recent_datetime_matcher
171
+ proc do |value|
172
+ datetime = Time.zone.parse(value)
173
+ datetime.present? && datetime > 10.years.ago && datetime < 2.years.from_now
174
+ end
175
+ end
176
+ end
177
+ ```
178
+ Now set the filter for actions which should support filtering:
179
+ ```ruby
180
+ class API::PostsController < API::BaseController
181
+ before_action -> { set_jsonapi_filter(API::PostFilter) }, only: %i[index search]
182
+ end
183
+
184
+ ```
185
+ And you are good to go!
186
+
187
+ Incidentally, PostFilter class is also a good place to implement your filter logic:
188
+ ```ruby
189
+ class API::PostFilter < JsonApiable::BaseFilter
190
+ # The following methods are available to a filter class instance:
191
+ # jsonapi_collection - collection on which to execute filtering
192
+ # jsonapi_filter_hash - a filter query hash, e.g. { 'status' => ['draft', 'published'], 'published_at' => '2001-02-03T04:05:06+03:00' }
193
+ def call
194
+ jsonapi_collection.where(status: jsonapi_filter_hash[:status])
195
+ end
196
+ end
197
+ ```
198
+ Now you can call filter posts collection in your controller:
199
+ ```ruby
200
+ posts = GetPosts.call
201
+ # jsonapi_filter_class - API::PostFilter in our example
202
+ # jsonapi_filter_hash - a filter query hash, e.g. { 'status' => ['draft', 'published'] }
203
+ filtered_posts = jsonapi_filter_class.new(posts, jsonapi_filter_hash).call
204
+ ```
205
+
206
+
135
207
  ### Configuration
136
208
  Add an initializer to your app with the following config block:
137
209
  ```ruby
data/lib/json_apiable.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_support/all'
2
4
  require "json_apiable/version"
3
5
  require "json_apiable/core_extensions"
@@ -6,7 +8,10 @@ require 'json_apiable/renderers'
6
8
  require 'json_apiable/errors'
7
9
  require 'json_apiable/params_parser'
8
10
  require 'json_apiable/pagination_parser'
11
+ require 'json_apiable/filter_parser'
12
+ require 'json_apiable/filter_matchers'
13
+ require 'json_apiable/base_filter'
9
14
  require 'json_apiable/json_apiable'
10
15
 
11
16
  String.include CoreExtensions::String
12
- Mime::Type.register JsonApiable::JSONAPI_CONTENT_TYPE, :json_api
17
+ Mime::Type.register JsonApiable::JSONAPI_CONTENT_TYPE, :json_api
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonApiable
4
+ # Base class for Filters
5
+ class BaseFilter
6
+ extend JsonApiable::FilterMatchers
7
+
8
+ attr_reader :jsonapi_collection, :jsonapi_filter_hash, :current_user
9
+
10
+ protected
11
+
12
+ def initialize(a_collection, a_filter_hash, current_user)
13
+ @jsonapi_collection = a_collection
14
+ @jsonapi_filter_hash = a_filter_hash
15
+ @current_user = current_user
16
+ end
17
+
18
+ class << self
19
+ def jsonapi_allowed_filters
20
+ {}
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JsonApiable
2
4
  class Configuration
3
5
  attr_accessor :valid_query_params, :supported_media_type_proc, :not_found_exception_class, :page_size
@@ -29,4 +31,4 @@ module JsonApiable
29
31
  @not_found_exception_class = klass
30
32
  end
31
33
  end
32
- end
34
+ end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CoreExtensions
2
4
  module String
3
5
  def integer?
4
6
  to_i.to_s == self
5
7
  end
6
8
  end
7
- end
9
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JsonApiable
2
4
  module Errors
3
5
  class ApiError < StandardError; end
@@ -7,4 +9,4 @@ module JsonApiable
7
9
  class ForbiddenError < ApiError; end
8
10
  class ConfigurationError < ApiError; end
9
11
  end
10
- end
12
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonApiable
4
+ module FilterMatchers
5
+ def matches?(allowed, given)
6
+ given.all? do |value|
7
+ allowed.is_a?(Proc) ? allowed.call(value) : allowed.include?(value)
8
+ end
9
+ end
10
+
11
+ module_function :matches?
12
+
13
+ # returns true for boolean values, false for any other
14
+ def boolean_matcher
15
+ proc do |value|
16
+ handle_error(value) do
17
+ if true_matcher.call(value) || (value == false || value =~ /^(false|f|0)$/i)
18
+ true
19
+ else
20
+ false
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ # returns true for true values, false for any other
27
+ def true_matcher
28
+ proc do |value|
29
+ handle_error(value) do
30
+ if value == true || value =~ /^(true|t|1)$/i
31
+ true
32
+ else
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ # returns true if the value is a valid date or datetime
40
+ def datetime_matcher
41
+ proc do |value|
42
+ handle_error(value) do
43
+ datetime = value.in_time_zone(Time.zone)
44
+ datetime.present?
45
+ end
46
+ end
47
+ end
48
+
49
+ # returns true if the value is a an array of existing ids of the given model
50
+ def ids_matcher(model)
51
+ proc do |value|
52
+ handle_error(value) do
53
+ given_ids = value.split(',')
54
+ found_records = model.where(id: given_ids)
55
+
56
+ given_ids.count == found_records.count
57
+ end
58
+ end
59
+ end
60
+
61
+ def handle_error(value)
62
+ yield
63
+ rescue ArgumentError => e
64
+ raise ArgumentError, "#{value}: #{e.message}"
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonApiable
4
+ class FilterParser
5
+ def self.parse_filters!(jsonapi_build_params, filter_class)
6
+ FilterParser.new(jsonapi_build_params[:filter], filter_class).parse!
7
+ end
8
+
9
+ attr_reader :filter_param, :filter_class
10
+
11
+ def initialize(filter_param, filter_class)
12
+ @filter_param = filter_param
13
+ @filter_class = filter_class
14
+ end
15
+
16
+ # Support filtering in the form of example.com/v1/posts?filter[status]=draft,published
17
+ def parse!
18
+ raise_invalid_filter_class unless valid_filter_class?
19
+
20
+ filter_hash = ActiveSupport::HashWithIndifferentAccess.new
21
+ if valid_filter_query?
22
+ filter_param.keys.each do |k|
23
+ if valid_filter_key?(k)
24
+ # support notation ?filter[param]=value1,value2,value3&...
25
+ requested = filter_param[k].split(',')
26
+ allowed_values = allowed_filter_keys[k]
27
+ raise_invalid_filter_value(k) unless FilterMatchers.matches?(allowed_values, requested)
28
+
29
+ filter_hash[k] = requested
30
+ else
31
+ raise_invalid_filter_value(k)
32
+ end
33
+ end
34
+ elsif filter_param.present?
35
+ raise ArgumentError, 'filter'
36
+ end
37
+ filter_hash
38
+ end
39
+
40
+ private
41
+
42
+ def raise_argument_error(message)
43
+ raise ArgumentError, message
44
+ end
45
+
46
+ def raise_invalid_filter_value(k)
47
+ prefix = "filter[#{k}]"
48
+ msg = filter_param[k].present? ? "#{prefix}=#{filter_param[k]}" : prefix
49
+ raise_argument_error(msg)
50
+ end
51
+
52
+ def raise_invalid_filter_class
53
+ raise_argument_error("#{filter_class} does not specify jsonapi_allowed_filters")
54
+ end
55
+
56
+ def valid_filter_class?
57
+ filter_class.respond_to?(:jsonapi_allowed_filters)
58
+ end
59
+
60
+ def valid_filter_query?
61
+ filter_param.present? && (filter_param.is_a?(Hash) || filter_param.is_a?(ActionController::Parameters))
62
+ end
63
+
64
+ def valid_filter_key?(k)
65
+ allowed_filter_keys.key?(k) && filter_param[k].present?
66
+ end
67
+
68
+ def allowed_filter_keys
69
+ filter_class.jsonapi_allowed_filters.with_indifferent_access
70
+ end
71
+ end
72
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JsonApiable
2
4
  extend ActiveSupport::Concern
3
5
  include Errors
@@ -5,8 +7,8 @@ module JsonApiable
5
7
 
6
8
  JSONAPI_CONTENT_TYPE = 'application/vnd.api+json'
7
9
 
8
- attr_reader :jsonapi_page, :jsonapi_include, :jsonapi_build_params, :jsonapi_assign_params, :jsonapi_default_page_size,
9
- :jsonapi_exclude_attributes, :jsonapi_exclude_relationships
10
+ attr_reader :jsonapi_page_hash, :jsonapi_include_array, :jsonapi_filter_hash, :jsonapi_filter_class, :jsonapi_build_params,
11
+ :jsonapi_assign_params, :jsonapi_default_page_size, :jsonapi_exclude_attributes, :jsonapi_exclude_relationships
10
12
 
11
13
  included do
12
14
  before_action :ensure_jsonapi_content_type
@@ -25,7 +27,6 @@ module JsonApiable
25
27
  rescue_from JsonApiable.configuration.not_found_exception_class, with: :respond_to_not_found
26
28
  end
27
29
 
28
-
29
30
  class << self
30
31
  attr_writer :configuration
31
32
  end
@@ -63,8 +64,11 @@ module JsonApiable
63
64
  end
64
65
 
65
66
  def jsonapi_relationship_attribute(relationship, attribute)
66
- [:id, :type].include?(attribute.to_sym) ? jsonapi_relationship_data(relationship)&.dig(attribute) :
67
- jsonapi_relationship_data(relationship)&.dig(:attributes, attribute)
67
+ if [:id, :type].include?(attribute.to_sym)
68
+ jsonapi_relationship_data(relationship)&.dig(attribute)
69
+ else
70
+ jsonapi_relationship_data(relationship)&.dig(:attributes, attribute)
71
+ end
68
72
  end
69
73
 
70
74
  def jsonapi_assign_params
@@ -126,19 +130,24 @@ module JsonApiable
126
130
  end
127
131
  end
128
132
 
133
+ def set_jsonapi_filter(filter_class)
134
+ @jsonapi_filter_class = filter_class
135
+ @jsonapi_filter_hash = FilterParser.parse_filters!(jsonapi_build_params, filter_class)
136
+ end
137
+
129
138
  def set_jsonapi_content_type
130
139
  response.headers['Content-Type'] = JSONAPI_CONTENT_TYPE
131
140
  end
132
141
 
133
142
  def parse_jsonapi_pagination
134
- @jsonapi_page = PaginationParser.parse_pagination!(query_params, jsonapi_default_page_size)
143
+ @jsonapi_page_hash = PaginationParser.parse_pagination!(query_params, jsonapi_default_page_size)
135
144
  end
136
145
 
137
146
  def parse_jsonapi_include
138
- @jsonapi_include = query_params[:include].presence&.gsub(/ /, '')&.split(',')&.map(&:to_sym).to_a
147
+ @jsonapi_include_array = query_params[:include].presence&.gsub(/ /, '')&.split(',')&.map(&:to_sym).to_a
139
148
  end
140
149
 
141
150
  def query_params
142
151
  request.query_parameters
143
152
  end
144
- end
153
+ end
@@ -1,6 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JsonApiable
2
4
  class PaginationParser
3
-
4
5
  def self.parse_pagination!(query_params, default_page_size)
5
6
  PaginationParser.new(query_params[:page], query_params[:no_pagination], default_page_size).parse!
6
7
  end
@@ -15,7 +16,7 @@ module JsonApiable
15
16
 
16
17
  def parse!
17
18
  if no_pagination
18
- jsonapi_page = nil
19
+ jsonapi_page_hash = nil
19
20
  elsif invalid_page_param?
20
21
  raise ArgumentError, 'page'
21
22
  elsif invalid_page_number?
@@ -23,14 +24,14 @@ module JsonApiable
23
24
  elsif invalid_page_size?
24
25
  raise ArgumentError, 'page[size]'
25
26
  else
26
- jsonapi_page = page_param.presence.to_h.with_indifferent_access
27
+ jsonapi_page_hash = page_param.presence.to_h.with_indifferent_access
27
28
  # convert values to integers
28
- jsonapi_page = jsonapi_page.merge(jsonapi_page) { |k,v| v.to_i } if jsonapi_page.present?
29
- jsonapi_page = { number: Configuration::DEFAULT_PAGE_NUMBER, size: default_page_size } if jsonapi_page.blank?
30
- jsonapi_page[:number] = Configuration::DEFAULT_PAGE_NUMBER if jsonapi_page[:number].blank?
31
- jsonapi_page[:size] = default_page_size if jsonapi_page[:size].blank?
29
+ jsonapi_page_hash = jsonapi_page_hash.merge(jsonapi_page_hash) { |_, v| v.to_i } if jsonapi_page_hash.present?
30
+ jsonapi_page_hash = { number: Configuration::DEFAULT_PAGE_NUMBER, size: default_page_size } if jsonapi_page_hash.blank?
31
+ jsonapi_page_hash[:number] = Configuration::DEFAULT_PAGE_NUMBER if jsonapi_page_hash[:number].blank?
32
+ jsonapi_page_hash[:size] = default_page_size if jsonapi_page_hash[:size].blank?
32
33
  end
33
- jsonapi_page
34
+ jsonapi_page_hash
34
35
  end
35
36
 
36
37
  private
@@ -56,4 +57,4 @@ module JsonApiable
56
57
  number > Configuration::MAX_PAGE_SIZE || number.zero? || number.negative?
57
58
  end
58
59
  end
59
- end
60
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JsonApiable
2
4
  class ParamsParser
3
5
  class DataParams
@@ -39,7 +41,9 @@ module JsonApiable
39
41
  end
40
42
 
41
43
  def self.build_relationships_hash(relationships, excluded_relationships, request)
42
- attr_hash = {}; ids_array = []; ids_key = nil
44
+ attr_hash = {}
45
+ ids_array = []
46
+ ids_key = nil
43
47
 
44
48
  relationships&.each_pair do |key, data_hash|
45
49
  next if excluded_relationships&.include?(key.to_sym)
@@ -72,7 +76,7 @@ module JsonApiable
72
76
  end
73
77
 
74
78
  def self.hashify(allowed_relationships)
75
- allowed_relationships.map{|rel| { rel => {}}}
79
+ allowed_relationships.map { |rel| { rel => {} } }
76
80
  end
77
81
  end
78
- end
82
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JsonApiable
2
4
  module Renderers
3
5
  def respond_to_unsupported_media_type
@@ -49,4 +51,4 @@ module JsonApiable
49
51
  render json: { errors: json }, status: status
50
52
  end
51
53
  end
52
- end
54
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module JsonApiable
2
- VERSION = "0.3"
4
+ VERSION = "0.4"
3
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: json_apiable
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.3'
4
+ version: '0.4'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Polischuk
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-01-19 00:00:00.000000000 Z
11
+ date: 2020-02-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -201,6 +201,7 @@ extra_rdoc_files: []
201
201
  files:
202
202
  - ".gitignore"
203
203
  - ".rspec"
204
+ - ".rubocop.yml"
204
205
  - ".travis.yml"
205
206
  - Gemfile
206
207
  - Gemfile.lock
@@ -211,9 +212,12 @@ files:
211
212
  - bin/setup
212
213
  - json_apiable.gemspec
213
214
  - lib/json_apiable.rb
215
+ - lib/json_apiable/base_filter.rb
214
216
  - lib/json_apiable/configuration.rb
215
217
  - lib/json_apiable/core_extensions.rb
216
218
  - lib/json_apiable/errors.rb
219
+ - lib/json_apiable/filter_matchers.rb
220
+ - lib/json_apiable/filter_parser.rb
217
221
  - lib/json_apiable/json_apiable.rb
218
222
  - lib/json_apiable/pagination_parser.rb
219
223
  - lib/json_apiable/params_parser.rb