json_api_server 0.0.1

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.
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