json_api_server 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.dockerignore +7 -0
- data/.gitignore +14 -0
- data/.rspec +2 -0
- data/.rubocop.yml +35 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Dockerfile +9 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +432 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/config/locales/en.yml +32 -0
- data/docker-compose.yml +10 -0
- data/json_api_server.gemspec +50 -0
- data/lib/json_api_server.rb +76 -0
- data/lib/json_api_server/api_version.rb +8 -0
- data/lib/json_api_server/attributes_builder.rb +135 -0
- data/lib/json_api_server/base_serializer.rb +169 -0
- data/lib/json_api_server/builder.rb +201 -0
- data/lib/json_api_server/cast.rb +89 -0
- data/lib/json_api_server/configuration.rb +99 -0
- data/lib/json_api_server/controller/error_handling.rb +164 -0
- data/lib/json_api_server/engine.rb +5 -0
- data/lib/json_api_server/error.rb +64 -0
- data/lib/json_api_server/errors.rb +50 -0
- data/lib/json_api_server/exceptions.rb +6 -0
- data/lib/json_api_server/fields.rb +76 -0
- data/lib/json_api_server/filter.rb +255 -0
- data/lib/json_api_server/filter_builders.rb +135 -0
- data/lib/json_api_server/filter_config.rb +71 -0
- data/lib/json_api_server/filter_parser.rb +88 -0
- data/lib/json_api_server/include.rb +158 -0
- data/lib/json_api_server/meta_builder.rb +39 -0
- data/lib/json_api_server/mime_types.rb +21 -0
- data/lib/json_api_server/pagination.rb +189 -0
- data/lib/json_api_server/paginator.rb +134 -0
- data/lib/json_api_server/relationships_builder.rb +215 -0
- data/lib/json_api_server/resource_serializer.rb +245 -0
- data/lib/json_api_server/resources_serializer.rb +131 -0
- data/lib/json_api_server/serializer.rb +34 -0
- data/lib/json_api_server/sort.rb +156 -0
- data/lib/json_api_server/sort_configs.rb +63 -0
- data/lib/json_api_server/validation_errors.rb +51 -0
- data/lib/json_api_server/version.rb +3 -0
- metadata +259 -0
@@ -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
|