json_api_server 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +7 -0
  3. data/.gitignore +14 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +35 -0
  6. data/.travis.yml +5 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Dockerfile +9 -0
  9. data/Gemfile +10 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +432 -0
  12. data/Rakefile +6 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +8 -0
  15. data/config/locales/en.yml +32 -0
  16. data/docker-compose.yml +10 -0
  17. data/json_api_server.gemspec +50 -0
  18. data/lib/json_api_server.rb +76 -0
  19. data/lib/json_api_server/api_version.rb +8 -0
  20. data/lib/json_api_server/attributes_builder.rb +135 -0
  21. data/lib/json_api_server/base_serializer.rb +169 -0
  22. data/lib/json_api_server/builder.rb +201 -0
  23. data/lib/json_api_server/cast.rb +89 -0
  24. data/lib/json_api_server/configuration.rb +99 -0
  25. data/lib/json_api_server/controller/error_handling.rb +164 -0
  26. data/lib/json_api_server/engine.rb +5 -0
  27. data/lib/json_api_server/error.rb +64 -0
  28. data/lib/json_api_server/errors.rb +50 -0
  29. data/lib/json_api_server/exceptions.rb +6 -0
  30. data/lib/json_api_server/fields.rb +76 -0
  31. data/lib/json_api_server/filter.rb +255 -0
  32. data/lib/json_api_server/filter_builders.rb +135 -0
  33. data/lib/json_api_server/filter_config.rb +71 -0
  34. data/lib/json_api_server/filter_parser.rb +88 -0
  35. data/lib/json_api_server/include.rb +158 -0
  36. data/lib/json_api_server/meta_builder.rb +39 -0
  37. data/lib/json_api_server/mime_types.rb +21 -0
  38. data/lib/json_api_server/pagination.rb +189 -0
  39. data/lib/json_api_server/paginator.rb +134 -0
  40. data/lib/json_api_server/relationships_builder.rb +215 -0
  41. data/lib/json_api_server/resource_serializer.rb +245 -0
  42. data/lib/json_api_server/resources_serializer.rb +131 -0
  43. data/lib/json_api_server/serializer.rb +34 -0
  44. data/lib/json_api_server/sort.rb +156 -0
  45. data/lib/json_api_server/sort_configs.rb +63 -0
  46. data/lib/json_api_server/validation_errors.rb +51 -0
  47. data/lib/json_api_server/version.rb +3 -0
  48. metadata +259 -0
@@ -0,0 +1,5 @@
1
+ module JsonApiServer # :nodoc:
2
+ # Rails engine for gem.
3
+ class Engine < Rails::Engine
4
+ end
5
+ end
@@ -0,0 +1,64 @@
1
+ module JsonApiServer # :nodoc:
2
+ # Implements a single error based on spec: http://jsonapi.org/examples/#error-objects.
3
+ #
4
+ # Serializes to something like this. Skips attributes that are nil. Ignores
5
+ # non-jsonapi attributes.
6
+ # error.to_json =>
7
+ #
8
+ # {
9
+ # ":jsonapi": {
10
+ # ":version": "1.0"
11
+ # },
12
+ # ":errors": {
13
+ # ":id": 1234
14
+ # ":status": "422",
15
+ # ":code": 5,
16
+ # ":source": {
17
+ # ":pointer": "/data/attributes/first-name"
18
+ # },
19
+ # ":title": "Invalid Attribute",
20
+ # ":detail": "First name must contain at least three characters.",
21
+ # ":meta": {
22
+ # ":attrs": [1,2,3]
23
+ # },
24
+ # ":links": {
25
+ # ":self": "http://example.com/user"
26
+ # }
27
+ # }
28
+ # }
29
+ class Error
30
+ include JsonApiServer::Serializer
31
+ include JsonApiServer::ApiVersion
32
+
33
+ class << self
34
+ # Allowable error attributes.
35
+ attr_accessor :error_attrs
36
+ end
37
+
38
+ @error_attrs = %w[id status source title detail code meta links]
39
+
40
+ def initialize(attrs = {})
41
+ @error =
42
+ if attrs.respond_to?(:keys)
43
+ h = attrs.select { |k, _v| self.class.error_attrs.include?(k.to_s) }
44
+ h.empty? ? nil : h
45
+ end
46
+ end
47
+
48
+ attr_reader :error
49
+
50
+ # Object that's serializable to json.
51
+ def as_json
52
+ {
53
+ 'jsonapi' => jsonapi,
54
+ 'errors' => error_as_array
55
+ }
56
+ end
57
+
58
+ protected
59
+
60
+ def error_as_array
61
+ [@error].compact
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,50 @@
1
+ module JsonApiServer # :nodoc:
2
+ # For one or more errors: http://jsonapi.org/examples/#error-objects.
3
+ #
4
+ # Serializes to an arrray of errors. Skips attributes that are nil. Ignores
5
+ # non-jsonapi attributes.
6
+ #
7
+ # error.to_json
8
+ # {
9
+ # ":jsonapi": {
10
+ # ":version": "1.0"
11
+ # },
12
+ # ":errors": [{
13
+ # ":id": 1234
14
+ # ":status": "422",
15
+ # ":code": 5,
16
+ # ":source": {
17
+ # ":pointer": "/data/attributes/first-name"
18
+ # },
19
+ # ":title": "Invalid Attribute",
20
+ # ":detail": "First name must contain at least three characters.",
21
+ # ":meta": {
22
+ # ":attrs": [1,2,3]
23
+ # },
24
+ # ":links": {
25
+ # ":self": "http://example.com/user"
26
+ # }
27
+ # }]
28
+ # }
29
+ # Use for singular or multiple errors.
30
+ class Errors
31
+ include JsonApiServer::Serializer
32
+ include JsonApiServer::ApiVersion
33
+
34
+ def initialize(errors)
35
+ errors = errors.is_a?(Array) ? errors : [errors]
36
+ @errors = errors.map do |error|
37
+ JsonApiServer::Error.new(error).error
38
+ end
39
+ @errors.compact!
40
+ @errors
41
+ end
42
+
43
+ def as_json
44
+ {
45
+ 'jsonapi' => jsonapi,
46
+ 'errors' => @errors
47
+ }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,6 @@
1
+ module JsonApiServer # :nodoc:
2
+ # Exception thrown when something unsupported is requested, i.e., sort
3
+ # by a field that's not supported. If JsonApiServer::Controller::ErrorHandling
4
+ # is included in the controller, it will rescue and render a 400 error.
5
+ class BadRequest < StandardError; end
6
+ end
@@ -0,0 +1,76 @@
1
+ module JsonApiServer # :nodoc:
2
+ # ==== Description:
3
+ #
4
+ # Implements sparse fieldsets per JSON API spec http://jsonapi.org/format/#fetching-sparse-fieldsets.
5
+ # Spec states: "A client MAY request that an endpoint return only specific fields in
6
+ # the response on a per-type basis by including a fields[TYPE] parameter."
7
+ #
8
+ # This class extracts sparse fields and organizes them by 'type' which is associated with a
9
+ # a serializer. There is no whitelisting. It's assumed the serializer or view controls which
10
+ # fields to return.
11
+ #
12
+ # === Usage:
13
+ #
14
+ # A sparse fields request look like:
15
+ # /articles?include=author&fields[articles]=title,body,author&fields[people]=name
16
+ #
17
+ # This is converted to a hash:
18
+ # {
19
+ # 'articles' => ['title', 'body', 'author'],
20
+ # 'people' => ['name']
21
+ # }
22
+ #
23
+ # ==== Examples:
24
+ #
25
+ # Given request:
26
+ # <tt>articles?include=author&fields[articles]=title,body,author&fields[people]=name</tt>
27
+ #
28
+ # req = JsonApiServer::Fields.new(request)
29
+ # req.sparse_fields # => {'articles => ['title', 'body', 'author'], 'people' => ['name']}
30
+ #
31
+ # Given request: <tt>/articles</tt>
32
+ #
33
+ # req = JsonApiServer::Fields.new(request)
34
+ # req.sparse_fields # => nil
35
+ #
36
+ # ==== Note:
37
+ #
38
+ # - JsonApiServer::AttributesBuilder provides methods for using this class in serializers or views.
39
+ # - JsonApiServer::Builder class provides an easier way to use this class.
40
+ #
41
+ class Fields
42
+ # Controller request object.
43
+ attr_reader :request
44
+
45
+ # Query parameters from #request.
46
+ attr_reader :params
47
+
48
+ # Arguments:
49
+ #
50
+ # - <tt>request</tt> - ActionDispatch::Request object.
51
+ # - <tt>options</tt> (Hash) - Reserved but not used.
52
+ def initialize(request, **_options)
53
+ @request = request
54
+ @params = request.query_parameters
55
+ end
56
+
57
+ # nil when there are no sparse fields in the request. Otherwise,
58
+ # returns a hash of format:
59
+ # {'<type>' => ['<field name 1>', '<field name 2>', ... ], ...}.
60
+ def sparse_fields
61
+ @sparse_fields ||= begin
62
+ return nil unless @params[:fields].respond_to?(:key)
63
+ hash = @params[:fields].each_with_object({}) do |(k, v), sum|
64
+ sum[k.to_s] = convert(v) if v.present? && v.respond_to?(:split)
65
+ end
66
+ hash.any? ? hash : nil
67
+ end
68
+ end
69
+
70
+ protected
71
+
72
+ def convert(string)
73
+ string.split(',').map!(&:strip)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,255 @@
1
+ module JsonApiServer # :nodoc:
2
+ # Implements filter parameters per JSON API Spec: http://jsonapi.org/recommendations/#filtering.
3
+ # The spec says: "The filter query parameter is reserved for filtering data. Servers
4
+ # and clients SHOULD use this key for filtering operations."
5
+ #
6
+ # ie., GET /topics?filter[id]=1,2&filter[book]=*potter
7
+ #
8
+ # This class (1) whitelists filter params and (2) generates a sub-query
9
+ # based on filters. If a user requests an unsupported filter, a
10
+ # JsonApiServer::BadRequest exception is raised which renders a 400 error.
11
+ #
12
+ # Currently supports only ActiveRecord::Relation. Filters are combined with AND.
13
+ #
14
+ # === Usage:
15
+ #
16
+ # A filter request will look like:
17
+ # /topics?filter[id]=1,2&filter[book]=*potter
18
+ #
19
+ # ==== Configurations:
20
+ #
21
+ # Configurations look like:
22
+ # [
23
+ # { id: { type: 'Integer' } },
24
+ # { tags: { builder: :pg_jsonb_ilike_array } },
25
+ # { published: { type: 'Date' } },
26
+ # { published1: { col_name: :published, type: 'Date' } },
27
+ # :location,
28
+ # { book: { wildcard: :both } },
29
+ # { search: { builder: :model_query, method: :search } }
30
+ # ]
31
+ #
32
+ # ====== whitelist
33
+ # Filter attributes are whitelisted. Specify filters you want to support in filter configs.
34
+ #
35
+ # ====== :type
36
+ # :type (data type) defaults to String. Filter values are cast to this type.
37
+ # Supported types are:
38
+ #
39
+ # - String
40
+ # - Integer
41
+ # - Date (Note: invalid Date casts to nil)
42
+ # - DateTime (Note: invalid DateTime casts to nil)
43
+ # - Float (untested)
44
+ # - BigDecimal (untested)
45
+ #
46
+ # ====== :col_name
47
+ # If a filter name is different from its model column/attribute, specify the column/attribute with :col_name.
48
+ #
49
+ # ====== :wildcard
50
+ # A filter can enable wildcarding with the <tt>:wildcard</tt> option. <tt>:both</tt> wildcards both
51
+ # sides, <tt>:left</tt> wildcards the left, <tt>:right</tt> wildcards the right.
52
+ # A user triggers wildcarding by preceding a filter value with a * character (i.e., *weather).
53
+ #
54
+ # /comments?filter[comment]=*weather => "comments"."comment" LIKE '%weather%'
55
+ #
56
+ # Additional wildcard/like filters are available for Postgres.
57
+ #
58
+ # ILIKE for case insensitive searches:
59
+ # - <tt>pg_ilike</tt>: JsonApiServer::PgIlike
60
+ #
61
+ # For searching a JSONB array - case sensitive:
62
+ # - <tt>pg_jsonb_array</tt>: JsonApiServer::PgJsonbArray
63
+ #
64
+ # For searching a JSONB array - case insensitive:
65
+ # - <tt>pg_jsonb_ilike_array</tt>: JsonApiServer::PgJsonbIlikeArray
66
+ #
67
+ #
68
+ # ====== builder: :model_query
69
+ #
70
+ # A filter can be configured to call a model's singleton method.
71
+ #
72
+ # Example:
73
+ #
74
+ # [
75
+ # { search: { builder: :model_query, method: :search } }
76
+ # ]
77
+ #
78
+ # Request:
79
+ #
80
+ # /comments?filter[search]=tweet
81
+ #
82
+ # The singleton method <tt>search</tt> will be called on the model specified in the
83
+ # filter constructor.
84
+ #
85
+ # ====== builder:
86
+ #
87
+ # Specify a specific filter builder to handle the query. The list of default builders
88
+ # is in JsonApiServer::Configuration.
89
+ #
90
+ # [
91
+ # { tags: { builder: :pg_jsonb_ilike_array } }
92
+ # ]
93
+ #
94
+ # As mentioned above, there are additional filter builders for Postgres. Custom filter builders
95
+ # can be added. In this example, it's using the <tt>:pg_jsonb_ilike_array</tt> builder
96
+ # which performs a case insensitve search on a JSONB array column.
97
+ #
98
+ # === Features
99
+ #
100
+ # ====== IN statement
101
+ #
102
+ # Comma separated filter values translate into an IN statement.
103
+ # /topics?filter[id]=1,2 => "topics"."id" IN (1,2)'
104
+ #
105
+ # ===== Operators
106
+ #
107
+ # The following operators are supported:
108
+ #
109
+ # =, <, >, >=, <=, !=
110
+ #
111
+ # Example:
112
+ #
113
+ # /comments?filter[id]=>=20
114
+ # # note: special characters should be encoded -> /comments?filter[id]=%3E%3D20
115
+ #
116
+ # ====== Searching a Range
117
+ #
118
+ # Searching a range can be achieved with two filters for the same model attribute
119
+ # and operators:
120
+ #
121
+ # Configuration:
122
+ # [
123
+ # { published: { type: 'Date' } },
124
+ # { published1: { col_name: :published, type: 'Date' } }
125
+ # ]
126
+ #
127
+ # Request:
128
+ #
129
+ # /topics?filter[published]=>1998-01-01&filter[published1]=<1999-12-31
130
+ #
131
+ # Produces a query like:
132
+ #
133
+ # ("topics"."published" > '1998-01-01') AND ("topics"."published" < '1999-12-31')
134
+ #
135
+ # === Custom Filters
136
+ #
137
+ # Custom filters can be added. Filters should inherit from JsonApiServer::FilterBuilder.
138
+ #
139
+ # Example:
140
+ #
141
+ # # In config/initializers/json_api_server.rb
142
+ #
143
+ # # Create custom fitler.
144
+ # module JsonApiServer
145
+ # class MyCustomFilter < FilterBuilder
146
+ # def to_query(model)
147
+ # model.where("#{full_column_name(model)} LIKE :val", val: "%#{value}%")
148
+ # end
149
+ # end
150
+ # end
151
+ #
152
+ # # Update :filter_builders attribute to include your builder.
153
+ # JsonApiServer.configure do |c|
154
+ # c.base_url = 'http://localhost:3001'
155
+ # c.filter_builders = c.filter_builders.merge(my_custom_builder: JsonApiServer::MyCustomFilter)
156
+ # c.logger = Rails.logger
157
+ # end
158
+ #
159
+ # # and then use it in your controllers...
160
+ # # c.filter_options = [
161
+ # # { names: { builder: :my_custom_builder } }
162
+ # # ]
163
+ #
164
+ # ==== Note:
165
+ #
166
+ # - JsonApiServer::Builder class provides an easier way to use this class.
167
+ #
168
+ class Filter
169
+ # ActionDispatch::Request passed in constructor.
170
+ attr_reader :request
171
+
172
+ # Query parameters from #request.
173
+ attr_reader :params
174
+
175
+ # ActiveRecord::Base model passed in constructor.
176
+ attr_reader :model
177
+
178
+ # Filter configs passed in constructor.
179
+ attr_reader :permitted
180
+
181
+ # Arguments:
182
+ # - <tt>request</tt> - ActionDispatch::Request
183
+ # - <tt>model</tt> - ActiveRecord::Base model. Used to generate sub-query.
184
+ # - <tt>permitted</tt> (Array) - Defaults to empty array. Filter configurations.
185
+ def initialize(request, model, permitted = [])
186
+ @request = request
187
+ @model = model
188
+ @permitted = permitted.is_a?(Array) ? permitted : []
189
+ @params = request.query_parameters
190
+ end
191
+
192
+ # Filter params from query parameters.
193
+ def filter_params
194
+ @filter ||= params[:filter] || {}
195
+ end
196
+
197
+ # Returns an ActiveRecord Relation object (query fragment) which can be
198
+ # merged with another.
199
+ def relation
200
+ @conditions ||= begin
201
+ filter_params.each_with_object(model.all) do |(attr, val), result|
202
+ if attr.present? && val.present?
203
+ query = query_for(attr, val)
204
+ result.merge!(query) unless query.nil? # query.present? triggers a db call.
205
+ end
206
+ end
207
+ end
208
+ end
209
+
210
+ alias query relation
211
+
212
+ # Hash with filter meta information. It echos untrusted user input
213
+ # (no sanitizing).
214
+ #
215
+ # i.e.,
216
+ # {
217
+ # filter: [
218
+ # 'id: 1,2',
219
+ # 'comment: *weather'
220
+ # ]
221
+ # }
222
+ def meta_info
223
+ @meta_info ||= begin
224
+ { filter:
225
+ filter_params.each_with_object([]) do |(attr, val), result|
226
+ result << "#{attr}: #{val}" if attr.present? && val.present?
227
+ end }
228
+ end
229
+ end
230
+
231
+ protected
232
+
233
+ # Use classes. Allow classes to be pushed in via initializers.
234
+ def query_for(attr, val)
235
+ config = config_for(attr)
236
+ return nil if config.nil?
237
+ parser = FilterParser.new(attr, val, model, config)
238
+ parser.to_query
239
+ end
240
+
241
+ # Returns config information on permitted attributes. Raises
242
+ # JsonApiServer::BadRequest with descriptive message if attribute
243
+ # is not whitelisted.
244
+ def config_for(attr)
245
+ config = permitted.find do |a|
246
+ attr == (a.respond_to?(:keys) ? a.keys.first : a).to_s
247
+ end
248
+ if config.nil?
249
+ msg = I18n.t('json_api_server.render_400.filter', param: attr)
250
+ raise JsonApiServer::BadRequest, msg
251
+ end
252
+ FilterConfig.new(config)
253
+ end
254
+ end
255
+ end