json_apiable 0.1.0 → 0.4.1

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: b0527cb82ea3c0689d6086d1728291e77bff1cd94da969953b8965e729eb66b6
4
- data.tar.gz: e98ff51f719ca22f14574c9acdb4a8b1175e13d513b8f93f2595d4d83cdffa89
3
+ metadata.gz: d7f8ca1f3bf0021a5b326f69c09fa891813c63da56824acafb67f5edf22b8cc6
4
+ data.tar.gz: 13c3c4b1e542b688241eaf73be98e35d35c0d712ce552d77243cf958fdce3d34
5
5
  SHA512:
6
- metadata.gz: c424b59b59efa76d8f04c248345ce5a42b9c2b2f53bf539a73f0eea4e4b4551186f32c8923fc097182c54e123582e9a4bdfdf7a715e5417de724bbe485715715
7
- data.tar.gz: 73af3e75acfb6a3e8a69fafdb338882cbcd2e6bcc38d5b9e65600ee5824b7af98ae650e38ec8520fe9b1f38a2591d34652081a18e3d1df1d977c072c5ecfdc48
6
+ metadata.gz: 941b661ae943c9e6b5344be482740356f78ee75393ed334ddf97516ba5f08bc821252ae34fa3d33d2142366b779095aebe7f8e8600280ffa9ce22acb3eded9a1
7
+ data.tar.gz: 66f37012eb785a178d5dd2c14979f3296f3818c8b86f940c33b1d6269fa8b5350a1dab1de9993e61c366f98251542870afe66f5ed42bcd56569951ecd3c3395c
data/.gitignore CHANGED
@@ -13,3 +13,5 @@
13
13
  # rspec failure tracking
14
14
  .rspec_status
15
15
  /spec/support/rails_app/db/*.sqlite3
16
+ /spec/support/rails_app/tmp/
17
+ /spec/support/rails_app/log/
@@ -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
+
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- json_apiable (0.1.0)
4
+ json_apiable (0.4)
5
5
  activerecord (>= 4.2)
6
6
  activesupport (>= 4.2)
7
7
  fast_jsonapi (~> 1.5)
@@ -65,10 +65,10 @@ GEM
65
65
  tzinfo (~> 1.1)
66
66
  zeitwerk (~> 2.2)
67
67
  builder (3.2.4)
68
- byebug (11.0.1)
68
+ byebug (11.1.1)
69
69
  coderay (1.1.2)
70
- concurrent-ruby (1.1.5)
71
- crass (1.0.5)
70
+ concurrent-ruby (1.1.6)
71
+ crass (1.0.6)
72
72
  diff-lcs (1.3)
73
73
  erubi (1.9.0)
74
74
  factory_bot (5.1.1)
@@ -76,15 +76,15 @@ GEM
76
76
  factory_bot_rails (5.1.1)
77
77
  factory_bot (~> 5.1.0)
78
78
  railties (>= 4.2.0)
79
- faker (2.9.0)
80
- i18n (>= 1.6, < 1.8)
79
+ faker (2.10.2)
80
+ i18n (>= 1.6, < 2)
81
81
  fast_jsonapi (1.5)
82
82
  activesupport (>= 4.2)
83
83
  globalid (0.4.2)
84
84
  activesupport (>= 4.2.0)
85
- i18n (1.7.0)
85
+ i18n (1.8.3)
86
86
  concurrent-ruby (~> 1.0)
87
- loofah (2.4.0)
87
+ loofah (2.6.0)
88
88
  crass (~> 1.0.2)
89
89
  nokogiri (>= 1.5.9)
90
90
  mail (2.7.1)
@@ -92,20 +92,20 @@ GEM
92
92
  marcel (0.3.3)
93
93
  mimemagic (~> 0.3.2)
94
94
  method_source (0.9.2)
95
- mimemagic (0.3.3)
95
+ mimemagic (0.3.5)
96
96
  mini_mime (1.0.2)
97
97
  mini_portile2 (2.4.0)
98
- minitest (5.13.0)
98
+ minitest (5.14.1)
99
99
  nio4r (2.5.2)
100
- nokogiri (1.10.7)
100
+ nokogiri (1.10.9)
101
101
  mini_portile2 (~> 2.4.0)
102
102
  pry (0.12.2)
103
103
  coderay (~> 1.1.0)
104
104
  method_source (~> 0.9.0)
105
- pry-byebug (3.7.0)
105
+ pry-byebug (3.8.0)
106
106
  byebug (~> 11.0)
107
107
  pry (~> 0.10)
108
- rack (2.0.8)
108
+ rack (2.2.3)
109
109
  rack-test (1.1.0)
110
110
  rack (>= 1.0, < 3)
111
111
  rails (6.0.2.1)
@@ -138,13 +138,13 @@ GEM
138
138
  method_source
139
139
  rake (>= 0.8.7)
140
140
  thor (>= 0.20.3, < 2.0)
141
- rake (10.5.0)
142
- rspec-core (3.9.0)
143
- rspec-support (~> 3.9.0)
141
+ rake (13.0.1)
142
+ rspec-core (3.9.1)
143
+ rspec-support (~> 3.9.1)
144
144
  rspec-expectations (3.9.0)
145
145
  diff-lcs (>= 1.2.0, < 2.0)
146
146
  rspec-support (~> 3.9.0)
147
- rspec-mocks (3.9.0)
147
+ rspec-mocks (3.9.1)
148
148
  diff-lcs (>= 1.2.0, < 2.0)
149
149
  rspec-support (~> 3.9.0)
150
150
  rspec-rails (3.9.0)
@@ -155,7 +155,7 @@ GEM
155
155
  rspec-expectations (~> 3.9.0)
156
156
  rspec-mocks (~> 3.9.0)
157
157
  rspec-support (~> 3.9.0)
158
- rspec-support (3.9.0)
158
+ rspec-support (3.9.2)
159
159
  sprockets (4.0.0)
160
160
  concurrent-ruby (~> 1.0)
161
161
  rack (> 1, < 3)
@@ -166,12 +166,12 @@ GEM
166
166
  sqlite3 (1.4.2)
167
167
  thor (1.0.1)
168
168
  thread_safe (0.3.6)
169
- tzinfo (1.2.5)
169
+ tzinfo (1.2.7)
170
170
  thread_safe (~> 0.1)
171
171
  websocket-driver (0.7.1)
172
172
  websocket-extensions (>= 0.1.0)
173
- websocket-extensions (0.1.4)
174
- zeitwerk (2.2.2)
173
+ websocket-extensions (0.1.5)
174
+ zeitwerk (2.3.0)
175
175
 
176
176
  PLATFORMS
177
177
  ruby
@@ -185,8 +185,8 @@ DEPENDENCIES
185
185
  pry-byebug
186
186
  rails
187
187
  rails-controller-testing
188
- rake (~> 10.0)
189
- rspec-rails
188
+ rake (~> 13.0)
189
+ rspec-rails (~> 3.9)
190
190
  sqlite3
191
191
 
192
192
  BUNDLED WITH
data/README.md CHANGED
@@ -1,8 +1,12 @@
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
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/json_apiable`. To experiment with that code, run `bin/console` for an interactive prompt.
5
+ JsonApiable is a Ruby module that makes it easier for Rails API controllers to handle JSON:API parameter and relationship parsing,
6
+ strong parameter validation, returning well-structured errors and more - all in a Rails-friendly way.
4
7
 
5
- TODO: Delete this and the text above, and describe your gem
8
+ JsonApiable doesn't assume anything about other JSON:API gems you may be using.
9
+ Feel free to use it in conjunction with fast_jsonapi, active_model_serializer, jsonapi-resources or any other library.
6
10
 
7
11
  ## Installation
8
12
 
@@ -21,8 +25,211 @@ Or install it yourself as:
21
25
  $ gem install json_apiable
22
26
 
23
27
  ## Usage
28
+ ### Basics
29
+ ```ruby
30
+ # include JsonApiable in your Base API controller
31
+ class API::BaseController < ActionController::Base
32
+ # By including JsonApiable, you get the following before/after actions in your controllers:
33
+ #
34
+ # before_action :ensure_jsonapi_content_type - Ensure correct Content-Type (application/vnd.api+json) is set in
35
+ # the request, and return error othewrise
36
+ # before_action :ensure_jsonapi_valid_query_params - Ensure only valid query parameters are used
37
+ # before_action :parse_jsonapi_pagination - Parse "?page:{number:1, size:25}" query hash, set defaults and
38
+ # return errors when invalid values received
39
+ # Read more: https://jsonapi.org/format/#fetching-pagination
40
+ # before_action :parse_jsonapi_include - Parse "?include=posts.author" include directives .
41
+ # Read more: https://jsonapi.org/format/#fetching-includes
42
+ # after_action :set_jsonapi_content_type - Ensure correct Content-Type (application/vnd.api+json) is set
43
+ # in the response
44
+
45
+ # By including JsonApiable, you get the following exceptions handled automatically:
46
+ # rescue_from ArgumentError
47
+ # rescue_from ActionController::UnpermittedParameters
48
+ # rescue_from MalformedRequestError
49
+ # rescue_from UnprocessableEntityError
50
+ # rescue_from ActiveRecord::RecordNotFound
51
+ include JsonApiable
52
+ end
53
+
54
+ class API::PostsController < API::BaseController
55
+
56
+ # GET /v1/posts
57
+ def index
58
+ # pass page and include info to your logic
59
+ posts = GetPostsService.call(jsonapi_page_hash, jsonapi_include_array)
60
+ # some other gem, such as fast_jsonapi is assumed to produce the json:api output
61
+ render json: posts
62
+ end
63
+
64
+ # PATCH /v1/posts/123/update
65
+ # { "data":
66
+ # { "type": "post",
67
+ # "attributes": {
68
+ # "title": "My New Title"
69
+ # },
70
+ # "relationships": {
71
+ # "author": {
72
+ # "data": {
73
+ # "type": "user",
74
+ # "id": "4528"
75
+ # },
76
+ # "comments": {
77
+ # "data": [
78
+ # { "type": "comment", "id": "1489" },
79
+ # { "type": "comment", "id": "1490" }
80
+ # ]
81
+ # }
82
+ # }
83
+ # }
84
+ # }
85
+ # }
86
+ def update
87
+ @post = Post.find(params[:id])
88
+
89
+ # turn relationships into Rails associations and assign them together with attributes
90
+ # as you would normally do in Rails
91
+ #
92
+ # jsonapi_assign_params =>
93
+ # { "title"=>"My New Title",
94
+ # "author_id" => 4528,
95
+ # "comments_attributes"=>{
96
+ # "0"=>{"id"=>"1489", "_destroy"=>"false"},
97
+ # "1"=>{"id"=>"1490", "_destroy"=>"false"}},
98
+ # "comment_ids"=>["1489", "1490"],
99
+ #
100
+ # }
101
+ @post.update_attributes!(jsonapi_assign_params)
102
+ render json: @post
103
+ end
104
+
105
+ def create
106
+ # use jsonapi_attribute_present? to quickly test presence of specific attributes
107
+ raise UnprocessableEntityError, 'No title!' unless jsonapi_attribute_present?(:title)
108
+ # use jsonapi_attribute to get attribute values. If non-existent, nil would be returned
109
+ @title = jsonapi_attribute(:title)
110
+ # exclude 'author' attribute from assign params, for example because it's a separate table on the DB level)
111
+ @author_name = jsonapi_exclude_attribute(:author_name)
112
+ # exclude 'comments' relationship from assign params, for example because we want to filter which ones are added to post
113
+ @comments_hash = jsonapi_exclude_relationship(:comments)
114
+ do_some_logc_with_excluded_params
115
+ # jsonapi_assign_params wouldn't include 'author' attribute and 'comments' relationship
116
+ Post.create!(jsonapi_assign_params)
117
+ end
118
+
119
+ protected
120
+
121
+ # declare which attributes should be allowed to be assigned. Complex attributes are allowed
122
+ def jsonapi_allowed_attributes
123
+ [:title,
124
+ :body,
125
+ dates: %i[first_drafted published last_edited]]
126
+ end
127
+
128
+ # declare which relationships should be allowed to be assigned
129
+ def jsonapi_allowed_relationships
130
+ %i[comments contributors]
131
+ end
132
+
133
+
134
+
135
+ end
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
+
207
+ ### Configuration
208
+ Add an initializer to your app with the following config block:
209
+ ```ruby
210
+ JsonApiable.configure do |config|
211
+ # white-list query params that should be allowed
212
+ config.valid_query_params = %w[ id access_token user_id organization_id ]
213
+
214
+ # by default, error is returned if the request Content-Type isn't valid JSON-API. Override the behaviour by using this block:
215
+ config.supported_media_type_proc = proc do |request|
216
+ request.content_type == JsonApiable::JSONAPI_CONTENT_TYPE || request.headers['My-Special-Header'].present?
217
+ end
218
+
219
+ # by default, ActiveRecord::RecordNotFound is caught by JsonApiable and turned into an error response. If your backend raises a different class of exception, set it here
220
+ config.not_found_exception_class = MyExceptionClass
221
+
222
+ end
223
+ ```
224
+
225
+ ### Gotchas
226
+ - To make sure requests with invalid attributes/relationships result in a well-structured json-api error, configure your Rails app to raise
227
+ exceptions on invalid parameters (JsonApiable will catch them and return an appropriate response). In `config/application.rb` set `config.action_controller.action_on_unpermitted_parameters = :raise`
24
228
 
25
- TODO: Write usage instructions here
229
+ ### Limitations
230
+ - `has_one` associations are expected to be represented as complex-attributes on the API level. So if User `has_one` Address,
231
+ than on the API level, JsonApiable expects address to be specified as a hash inside User's `attributes` rather than a separate relationship.
232
+ This makes sense in most cases. If your API represantion differs, `@post.update_attributes!(jsonapi_assign_params)` assignment won't work correctly.
26
233
 
27
234
  ## Development
28
235
 
@@ -35,7 +35,7 @@ Gem::Specification.new do |spec|
35
35
  spec.add_development_dependency 'pry-byebug'
36
36
  spec.add_development_dependency 'rails'
37
37
  spec.add_development_dependency 'rails-controller-testing'
38
- spec.add_development_dependency 'rake', '~> 10.0'
39
- spec.add_development_dependency 'rspec-rails'
38
+ spec.add_development_dependency 'rake', '~> 13.0'
39
+ spec.add_development_dependency 'rspec-rails', '~> 3.9'
40
40
  spec.add_development_dependency 'sqlite3'
41
41
  end
@@ -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
@@ -8,7 +10,7 @@ module JsonApiable
8
10
  MAX_PAGE_SIZE = 214_748_364
9
11
 
10
12
  def initialize
11
- @valid_query_params = %w[id access_token filter include page]
13
+ @valid_query_params = %w[id user_id access_token filter include page]
12
14
  @supported_media_type_proc = nil
13
15
  @not_found_exception_class = ActiveRecord::RecordNotFound
14
16
  @page_size = DEFAULT_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,16 +7,16 @@ 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
- before_action :ensure_content_type
13
- before_action :ensure_valid_query_params
14
- before_action :parse_pagination
15
- before_action :parse_include
14
+ before_action :ensure_jsonapi_content_type
15
+ before_action :ensure_jsonapi_valid_query_params
16
+ before_action :parse_jsonapi_pagination
17
+ before_action :parse_jsonapi_include
16
18
 
17
- after_action :set_content_type
19
+ after_action :set_jsonapi_content_type
18
20
 
19
21
  rescue_from ArgumentError, with: :respond_to_bad_argument
20
22
  rescue_from ActionController::UnpermittedParameters, with: :respond_to_bad_argument
@@ -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
@@ -42,32 +43,63 @@ module JsonApiable
42
43
  yield(configuration)
43
44
  end
44
45
 
46
+ def jsonapi_attribute(attrib_key)
47
+ jsonapi_build_params.dig(:data, :attributes, attrib_key)
48
+ end
49
+
45
50
  def jsonapi_attribute_present?(attrib_key)
46
- jsonapi_build_params.dig(:data, :attributes, attrib_key).present?
51
+ jsonapi_attribute(attrib_key).present?
52
+ end
53
+
54
+ def jsonapi_relationship(attrib_key)
55
+ jsonapi_build_params.dig(:data, :relationships, attrib_key)
56
+ end
57
+
58
+ def jsonapi_relationship_data(attrib_key)
59
+ jsonapi_build_params.dig(:data, :relationships, attrib_key, :data)
60
+ end
61
+
62
+ def jsonapi_relationship_present?(attrib_key)
63
+ jsonapi_relationship(attrib_key).present?
64
+ end
65
+
66
+ def jsonapi_relationship_attribute(relationship, 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
47
72
  end
48
73
 
49
74
  def jsonapi_assign_params
50
- @jsonapi_assign_params ||= ParamsParser.parse_body_params(request,
51
- jsonapi_build_params,
52
- jsonapi_allowed_attributes,
53
- jsonapi_exclude_attributes,
54
- jsonapi_allowed_relationships,
55
- jsonapi_exclude_relationships)
75
+ return @jsonapi_assign_params if @jsonapi_assign_params.present? && !@invalidate_assign_params
76
+
77
+ @jsonapi_assign_params = ParamsParser.parse_body_params(request,
78
+ jsonapi_build_params,
79
+ jsonapi_allowed_attributes,
80
+ jsonapi_exclude_attributes,
81
+ jsonapi_allowed_relationships,
82
+ jsonapi_exclude_relationships)
83
+ @invalidate_assign_params = false
84
+ @jsonapi_assign_params
56
85
  end
57
86
 
58
87
  def jsonapi_exclude_attribute(attrib_key)
59
88
  @jsonapi_exclude_attributes ||= []
60
89
  @jsonapi_exclude_attributes << attrib_key.to_sym
90
+ @invalidate_assign_params = true
61
91
  jsonapi_build_params.dig(:data, :attributes, attrib_key)
62
92
  end
63
93
 
64
94
  def jsonapi_exclude_relationship(rel_key)
65
95
  @jsonapi_exclude_relationships ||= []
66
96
  @jsonapi_exclude_relationships << rel_key.to_sym
97
+ @invalidate_assign_params = true
67
98
  jsonapi_build_params.dig(:data, :relationships, rel_key)
68
99
  end
69
100
 
70
- # Should be overwritten in specific controllers
101
+ # Should be overwritten in specific controllers. If you need to manipulate params before they are parsed,
102
+ # that's the place to do it
71
103
  def jsonapi_build_params
72
104
  params
73
105
  end
@@ -87,11 +119,11 @@ module JsonApiable
87
119
  %i[]
88
120
  end
89
121
 
90
- def ensure_content_type
122
+ def ensure_jsonapi_content_type
91
123
  respond_to_unsupported_media_type unless supported_media_type?
92
124
  end
93
125
 
94
- def ensure_valid_query_params
126
+ def ensure_jsonapi_valid_query_params
95
127
  invalid_params = request.query_parameters.keys.reject { |k| JsonApiable.configuration.valid_query_params.include?(k) }
96
128
  respond_to_bad_argument(invalid_params.first) if invalid_params.present?
97
129
  end
@@ -104,19 +136,24 @@ module JsonApiable
104
136
  end
105
137
  end
106
138
 
107
- def set_content_type
139
+ def set_jsonapi_filter(filter_class)
140
+ @jsonapi_filter_class = filter_class
141
+ @jsonapi_filter_hash = FilterParser.parse_filters!(jsonapi_build_params, filter_class)
142
+ end
143
+
144
+ def set_jsonapi_content_type
108
145
  response.headers['Content-Type'] = JSONAPI_CONTENT_TYPE
109
146
  end
110
147
 
111
- def parse_pagination
112
- @jsonapi_page = PaginationParser.parse_pagination!(query_params, jsonapi_default_page_size)
148
+ def parse_jsonapi_pagination
149
+ @jsonapi_page_hash = PaginationParser.parse_pagination!(query_params, jsonapi_default_page_size)
113
150
  end
114
151
 
115
- def parse_include
116
- @jsonapi_include = query_params[:include].presence&.gsub(/ /, '')&.split(',')&.map(&:to_sym).to_a
152
+ def parse_jsonapi_include
153
+ @jsonapi_include_array = query_params[:include].presence&.gsub(/ /, '')&.split(',')&.map(&:to_sym).to_a
117
154
  end
118
155
 
119
156
  def query_params
120
157
  request.query_parameters
121
158
  end
122
- end
159
+ 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
@@ -24,18 +26,7 @@ module JsonApiable
24
26
  end
25
27
 
26
28
  def self.validate_data_params!(params, attributes, relationships)
27
- permitted = DataParams.build(params, attributes, relationships)
28
- unpermitted = params.dig(:data)&.keys.to_a - permitted&.keys.to_a
29
- raise ArgumentError, "Unpermitted member: #{unpermitted.first}" if unpermitted.present?
30
-
31
-
32
- unpermitted_arguments = params.dig(:data, :attributes)&.keys.to_a - permitted.dig(:attributes)&.keys.to_a
33
- raise ArgumentError, "Unpermitted attribute: #{unpermitted_arguments.first}" if unpermitted_arguments.present?
34
-
35
- unpermitted_relationships = params.dig(:data, :relationships)&.keys.to_a - permitted.dig(:relationships)&.keys.to_a
36
- raise ArgumentError, "Unpermitted relationship: #{unpermitted_relationships.first}" if unpermitted_relationships.present?
37
-
38
- permitted
29
+ DataParams.build(params, attributes, relationships)
39
30
  end
40
31
 
41
32
  def self.build_attributes_hash(attributes, excluded_attributes)
@@ -50,7 +41,9 @@ module JsonApiable
50
41
  end
51
42
 
52
43
  def self.build_relationships_hash(relationships, excluded_relationships, request)
53
- attr_hash = {}; ids_array = []; ids_key = nil
44
+ attr_hash = {}
45
+ ids_array = []
46
+ ids_key = nil
54
47
 
55
48
  relationships&.each_pair do |key, data_hash|
56
49
  next if excluded_relationships&.include?(key.to_sym)
@@ -83,7 +76,7 @@ module JsonApiable
83
76
  end
84
77
 
85
78
  def self.hashify(allowed_relationships)
86
- allowed_relationships.map{|rel| { rel => {}}}
79
+ allowed_relationships.map { |rel| { rel => {} } }
87
80
  end
88
81
  end
89
- 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.1.0"
4
+ VERSION = "0.4.1"
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.1.0
4
+ version: 0.4.1
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-06 00:00:00.000000000 Z
11
+ date: 2020-08-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -156,28 +156,28 @@ dependencies:
156
156
  requirements:
157
157
  - - "~>"
158
158
  - !ruby/object:Gem::Version
159
- version: '10.0'
159
+ version: '13.0'
160
160
  type: :development
161
161
  prerelease: false
162
162
  version_requirements: !ruby/object:Gem::Requirement
163
163
  requirements:
164
164
  - - "~>"
165
165
  - !ruby/object:Gem::Version
166
- version: '10.0'
166
+ version: '13.0'
167
167
  - !ruby/object:Gem::Dependency
168
168
  name: rspec-rails
169
169
  requirement: !ruby/object:Gem::Requirement
170
170
  requirements:
171
- - - ">="
171
+ - - "~>"
172
172
  - !ruby/object:Gem::Version
173
- version: '0'
173
+ version: '3.9'
174
174
  type: :development
175
175
  prerelease: false
176
176
  version_requirements: !ruby/object:Gem::Requirement
177
177
  requirements:
178
- - - ">="
178
+ - - "~>"
179
179
  - !ruby/object:Gem::Version
180
- version: '0'
180
+ version: '3.9'
181
181
  - !ruby/object:Gem::Dependency
182
182
  name: sqlite3
183
183
  requirement: !ruby/object:Gem::Requirement
@@ -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