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