json_apiable 0.3 → 0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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