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,135 @@
|
|
1
|
+
module JsonApiServer # :nodoc:
|
2
|
+
# Base filter/query builder class. All should inherit from this class.
|
3
|
+
class FilterBuilder
|
4
|
+
# The filter attribute, i.e., filter[foo]
|
5
|
+
attr_reader :attr
|
6
|
+
# Casted value(s). If value included a common, an array of casted values.
|
7
|
+
attr_reader :value
|
8
|
+
# Instance of FilterConfig for the specific attribute/column.
|
9
|
+
attr_reader :config
|
10
|
+
# Column name in the database. Specified when the filter name in the query doesn't
|
11
|
+
# match the column name in the database.
|
12
|
+
attr_reader :column_name
|
13
|
+
# Can be IN, <, >, <=, etc.
|
14
|
+
attr_reader :operator
|
15
|
+
|
16
|
+
def initialize(attr, value, operator, config)
|
17
|
+
@attr = attr
|
18
|
+
@value = value
|
19
|
+
@config = config
|
20
|
+
@column_name = @config.column_name
|
21
|
+
@operator = operator
|
22
|
+
end
|
23
|
+
|
24
|
+
# Subclasses must implement. Can return an ActiveRecord::Relation or
|
25
|
+
# nil.
|
26
|
+
def to_query(_model)
|
27
|
+
raise 'subclasses should implement this method.'
|
28
|
+
end
|
29
|
+
|
30
|
+
protected
|
31
|
+
|
32
|
+
def full_column_name(model)
|
33
|
+
"\"#{model.table_name}\".\"#{column_name}\""
|
34
|
+
end
|
35
|
+
|
36
|
+
# Delegate to protected method ActiveRecord::Base.sanitize_sql.
|
37
|
+
# def sanitize_sql(condition)
|
38
|
+
# ActiveRecord::Base.send(:sanitize_sql, condition)
|
39
|
+
# end
|
40
|
+
|
41
|
+
# For queries where wildcards are appropriate. Adds wildcards
|
42
|
+
# based on filter's configs.
|
43
|
+
def add_wildcards(value)
|
44
|
+
return value unless value.present?
|
45
|
+
|
46
|
+
case config.wildcard
|
47
|
+
when :left
|
48
|
+
return "%#{value}"
|
49
|
+
when :right
|
50
|
+
return "#{value}%"
|
51
|
+
when :both
|
52
|
+
return "%#{value}%"
|
53
|
+
else # none by default
|
54
|
+
return value
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# Query equality builder. i.e.. where(foo: 'bar').
|
60
|
+
class SqlEql < FilterBuilder
|
61
|
+
def to_query(model)
|
62
|
+
model.where("#{full_column_name(model)} = ?", value)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Query comparison builder, .i.e., where('id > ?', '22').
|
67
|
+
class SqlComp < FilterBuilder
|
68
|
+
def self.allowed_operators
|
69
|
+
['=', '<', '>', '>=', '<=', '!=']
|
70
|
+
end
|
71
|
+
|
72
|
+
def to_query(model)
|
73
|
+
if self.class.allowed_operators.include?(operator)
|
74
|
+
model.where("#{full_column_name(model)} #{operator} ?", value)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Query IN builder, .i.e., where('id IN (?)', [1,2,3]).
|
80
|
+
class SqlIn < FilterBuilder
|
81
|
+
def to_query(model)
|
82
|
+
model.where("#{full_column_name(model)} IN (?)", value)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Query LIKE builder, .i.e., where('title LIKE ?', '%foo%').
|
87
|
+
# Wildcards are added based on configs. Defaults to '%<value>%'
|
88
|
+
class SqlLike < FilterBuilder
|
89
|
+
def to_query(model)
|
90
|
+
val = add_wildcards(value)
|
91
|
+
model.where("#{full_column_name(model)} LIKE ?", val)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Query ILIKE builder, .i.e., where('title ILIKE ?', '%foo%').
|
96
|
+
# Wildcards are added based on configs. Defaults to '%<value>%'
|
97
|
+
# Postgres only. Case insensitive search.
|
98
|
+
class PgIlike < FilterBuilder
|
99
|
+
def to_query(model)
|
100
|
+
val = add_wildcards(value)
|
101
|
+
model.where("#{full_column_name(model)} ILIKE :val", val: val)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Searches a jsonb array ['foo', 'bar']. If multiple values passed, it performs
|
106
|
+
# an OR search ?|. Case sensitive search.
|
107
|
+
# Postgres only.
|
108
|
+
class PgJsonbArray < FilterBuilder
|
109
|
+
#--
|
110
|
+
# http://stackoverflow.com/questions/30629076/how-to-escape-the-question-mark-operator-to-query-postgresql-jsonb-type-in-r
|
111
|
+
# https://www.postgresql.org/docs/9.4/static/functions-json.html
|
112
|
+
#++
|
113
|
+
def to_query(model)
|
114
|
+
model.where("#{full_column_name(model)} ?| array[:name]", name: value)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Searches a jsonb array ['foo', 'bar']. The array is returned as text. It performs an
|
119
|
+
# ILIKE %value%. Does not work with multiple values.
|
120
|
+
# Postgres only.
|
121
|
+
class PgJsonbIlikeArray < FilterBuilder
|
122
|
+
def to_query(model)
|
123
|
+
val = add_wildcards(value)
|
124
|
+
model.where("#{full_column_name(model)}::text ILIKE :name", name: val)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Call a model singleton method to perform the query.
|
129
|
+
class ModelQuery < FilterBuilder
|
130
|
+
def to_query(model)
|
131
|
+
method = config.method
|
132
|
+
model.send(method, value) if method.present?
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module JsonApiServer # :nodoc:
|
2
|
+
# Configuration for a filter http://jsonapi.org/format/#fetching-filtering.
|
3
|
+
#
|
4
|
+
# Example filter configuration:
|
5
|
+
#
|
6
|
+
# filter_options = [
|
7
|
+
# { id: { type: 'Integer' } },
|
8
|
+
# { tags: { builder: :pg_jsonb_ilike_array } },
|
9
|
+
# :body,
|
10
|
+
# { title: { wildcard: :right }},
|
11
|
+
# { search: { builder: :model_query, method: :search } },
|
12
|
+
# { created: { col_name: :created_at, type: 'DateTime' } }
|
13
|
+
# ]
|
14
|
+
#
|
15
|
+
class FilterConfig
|
16
|
+
# Attribute used in queries. i.e., /path?filter[foo]=bar => foo
|
17
|
+
attr_reader :attr
|
18
|
+
# (optional) If the fitler name is not the same as the database column name,
|
19
|
+
# map it. i.e., map :created to :created_at.
|
20
|
+
# { created: { col_name: :created_at, type: 'DateTime' } }
|
21
|
+
attr_reader :column_name
|
22
|
+
# (optional) Data type. Specify data type class as string. i.e., 'String',
|
23
|
+
# 'DateTime', 'Time', 'BigDecimal', etc. Defaults to 'String'.
|
24
|
+
attr_reader :type
|
25
|
+
# (optional) Symbol - the builder class to use for LIKE queries. Defaults
|
26
|
+
# to :sql_like if not specified.
|
27
|
+
attr_reader :like
|
28
|
+
# (optional) Symbol - the builder class to use for IN queries. Defaults to
|
29
|
+
# :sql_in if not specified.
|
30
|
+
attr_reader :in
|
31
|
+
# (optional) Symbol - the builder class to use for '=', '<', '>', '>=', '<=', '=',
|
32
|
+
# '!<', '!>', '<>' queries. Defaults to :sql_comparison.
|
33
|
+
attr_reader :comparison
|
34
|
+
# (optional) Symbol - the builder class to use for all other queries. Defaults to
|
35
|
+
# :sql_eql.
|
36
|
+
attr_reader :default
|
37
|
+
# (optional) Symbol - the builder class to use for all queries for the attribute.
|
38
|
+
attr_reader :builder
|
39
|
+
# (optional) Use with ModelQuery builder which calls a class method on the model.
|
40
|
+
attr_reader :method
|
41
|
+
# (optional) Symbol - :left, :right or :none. Defaults to wildcarding beginning
|
42
|
+
# end of string, i.e., "%#{value}%",
|
43
|
+
attr_reader :wildcard
|
44
|
+
|
45
|
+
def initialize(config)
|
46
|
+
if config.respond_to?(:keys)
|
47
|
+
# i.e, c.filter_options = { permitted: [{created: {attr: :created_at, type: DateTime}}] }
|
48
|
+
key, value = config.first
|
49
|
+
@attr = key
|
50
|
+
@column_name = value[:col_name] || @attr
|
51
|
+
@type = value[:type] || self.class.default_type
|
52
|
+
@like = value[:like]
|
53
|
+
@in = value[:in]
|
54
|
+
@comparison = value[:comparison]
|
55
|
+
@default = value[:default]
|
56
|
+
@builder = value[:builder]
|
57
|
+
@wildcard = value[:wildcard]
|
58
|
+
@method = value[:method]
|
59
|
+
else
|
60
|
+
# i.e., c.filter_options = { permitted: [:body] }
|
61
|
+
@attr = @column_name = config
|
62
|
+
@type = self.class.default_type
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Default data type is String unless a filter config specifies.
|
67
|
+
def self.default_type
|
68
|
+
String
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module JsonApiServer # :nodoc:
|
2
|
+
# Returns query_builder class for key specified.
|
3
|
+
def self.filter_builder(key)
|
4
|
+
JsonApiServer.configuration.filter_builders[key]
|
5
|
+
end
|
6
|
+
# Takes a filter param and associated config and creates an
|
7
|
+
# ActiveRecord::Relation query which can be merged into a
|
8
|
+
# master query. Part of http://jsonapi.org/recommendations/#filtering.
|
9
|
+
class FilterParser
|
10
|
+
# The filter name, i.e., :id, :title
|
11
|
+
attr_reader :attr
|
12
|
+
# The original filter value.
|
13
|
+
attr_reader :value
|
14
|
+
# Original value cast to the appropriate data type.
|
15
|
+
attr_reader :casted_value
|
16
|
+
# Model class or ActiveRecord_Relation. Queries are built using this model.
|
17
|
+
attr_reader :model
|
18
|
+
# Instance of FilterConfig for the filter.
|
19
|
+
attr_reader :config
|
20
|
+
# Query operator if one applies. i.e., IN, =, <, >, >=, <=, !=
|
21
|
+
attr_reader :operator
|
22
|
+
|
23
|
+
# parameters:
|
24
|
+
# - attr (String) - filter name as it appears in the url.
|
25
|
+
# i.e., filter[tags]=art,theater => tags
|
26
|
+
# - value (String) - value from query, i.e., 'art,theater'
|
27
|
+
# - model (class or class name) - Model class or class name.
|
28
|
+
# i.e., User or 'User'.
|
29
|
+
# - config (instance of FilterConfig) - filter config for the filter.
|
30
|
+
def initialize(attr, value, model, config)
|
31
|
+
@attr = attr
|
32
|
+
@value = value
|
33
|
+
@model = model.is_a?(Class) ? model : model.constantize
|
34
|
+
@config = config
|
35
|
+
parse
|
36
|
+
end
|
37
|
+
|
38
|
+
# Converts filter into an ActiveRecord::Relation where query which
|
39
|
+
# can be merged with other queries.
|
40
|
+
def to_query
|
41
|
+
return nil if config.nil? # not a whitelisted attr
|
42
|
+
klass = JsonApiServer.filter_builder(builder_key) || raise("Query builder '#{builder_key}' doesn't exist.")
|
43
|
+
builder = klass.new(attr, casted_value, operator, config)
|
44
|
+
builder.to_query(@model)
|
45
|
+
end
|
46
|
+
|
47
|
+
protected
|
48
|
+
|
49
|
+
def builder_key
|
50
|
+
return config.builder if config.builder.present?
|
51
|
+
|
52
|
+
case operator
|
53
|
+
when 'IN'
|
54
|
+
config.in || configuration.default_in_builder
|
55
|
+
when '*'
|
56
|
+
config.like || configuration.default_like_builder
|
57
|
+
when *SqlComp.allowed_operators
|
58
|
+
config.comparison || configuration.default_comparison_builder
|
59
|
+
else
|
60
|
+
config.default || configuration.default_builder
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Value, operator, or specified class.
|
65
|
+
def parse
|
66
|
+
if value.include?(',')
|
67
|
+
arr = value.split(',')
|
68
|
+
arr.map!(&:strip)
|
69
|
+
@casted_value = cast(arr, config.type)
|
70
|
+
@operator = 'IN'
|
71
|
+
else
|
72
|
+
value =~ /\A(!?[<|>]?=?\*?)(.+)/
|
73
|
+
# JsonApiServer.logger.debug("VALUE IS #{Regexp.last_match(2)}")
|
74
|
+
# JsonApiServer.logger.debug("CONFIG.TYPE IS #{config.type}")
|
75
|
+
@casted_value = cast(Regexp.last_match(2), config.type)
|
76
|
+
@operator = Regexp.last_match(1)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def cast(value, type)
|
81
|
+
JsonApiServer::Cast.to(value, type)
|
82
|
+
end
|
83
|
+
|
84
|
+
def configuration
|
85
|
+
JsonApiServer.configuration
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# TODO: error message and internationalization.
|
2
|
+
# TODO: cache permitted inclusions?
|
3
|
+
module JsonApiServer # :nodoc:
|
4
|
+
# ==== Description:
|
5
|
+
#
|
6
|
+
# Handles include parameters per JSON API spec http://jsonapi.org/format/#fetching-includes.
|
7
|
+
#
|
8
|
+
# An endpoint may support an include request parameter to allow the client to
|
9
|
+
# customize which related resources should be returned.
|
10
|
+
# ie., GET /articles/1?include=comments,comment.author,tags HTTP/1.1
|
11
|
+
#
|
12
|
+
# This class (1) whitelists include params, (2) maintains an array of
|
13
|
+
# permitted inclusions, and (3) generates a sub-query
|
14
|
+
# if eagerloading is configured for inclusions.
|
15
|
+
#
|
16
|
+
# === Usage:
|
17
|
+
#
|
18
|
+
# An inclusion request looks like:
|
19
|
+
# /topics?include=author,comment.author,comments
|
20
|
+
#
|
21
|
+
# It is converted to an array of relationships:
|
22
|
+
# ['author', 'comment.author', 'comments']
|
23
|
+
#
|
24
|
+
# Includes are whitelisted with a configuration that looks like:
|
25
|
+
# {
|
26
|
+
# {'author': -> { includes(:author) }},
|
27
|
+
# {'comments': -> { includes(:comments) }},
|
28
|
+
# 'comment.author'
|
29
|
+
# }
|
30
|
+
#
|
31
|
+
# In this example, author, comments, and comment.author are allowed includes. If
|
32
|
+
# an unsupported include is requested, a JsonApiServer::BadRequest exception is
|
33
|
+
# raised which renders a 400 error.
|
34
|
+
#
|
35
|
+
# A proc/lambda can be specified to eagerload relationships. Be careful,
|
36
|
+
# to date, there is no way to apply limits to :includes.
|
37
|
+
#
|
38
|
+
# ==== Example:
|
39
|
+
# permitted = {
|
40
|
+
# {'author': -> { includes(:author) }},
|
41
|
+
# {'comments': -> { includes(:comments) }},
|
42
|
+
# 'comment.author'
|
43
|
+
# }
|
44
|
+
#
|
45
|
+
# # create instance
|
46
|
+
# include = JsonApiServer::Include.new(request, Topic, permitted)
|
47
|
+
#
|
48
|
+
# # merge into master query
|
49
|
+
# recent_topics = Topic.recent.merge(include.query)
|
50
|
+
#
|
51
|
+
# # use in serializers
|
52
|
+
# class CommentSerializer < JsonApiServer::ResourceSerializer
|
53
|
+
# def relationships
|
54
|
+
# if relationship?('comment.author') # relationship? is a helper methods in serializers.
|
55
|
+
# #...
|
56
|
+
# end
|
57
|
+
# end
|
58
|
+
# end
|
59
|
+
#
|
60
|
+
# ==== Note:
|
61
|
+
# JsonApiServer::Builder class provides an easier way to use this class.
|
62
|
+
#
|
63
|
+
class Include
|
64
|
+
# ActionDispatch::Request passed in constructor.
|
65
|
+
attr_reader :request
|
66
|
+
|
67
|
+
# Query parameters from #request.
|
68
|
+
attr_reader :params
|
69
|
+
|
70
|
+
# ActiveRecord::Base model passed in constructor.
|
71
|
+
attr_reader :model
|
72
|
+
|
73
|
+
# Include configs passed in constructor.
|
74
|
+
attr_reader :permitted
|
75
|
+
|
76
|
+
# Arguments:
|
77
|
+
#
|
78
|
+
# - <tt>request</tt> - ActionDispatch::Request
|
79
|
+
# - <tt>model</tt> (ActiveRecord::Base) - Model to append queries to.
|
80
|
+
# - <tt>permitted</tt> (Array) - Permitted inclusions. To eagerload the relationship, pass a proc:
|
81
|
+
#
|
82
|
+
# ===== Example:
|
83
|
+
#
|
84
|
+
# Eagerloads author, comments, comments -> authors.
|
85
|
+
#
|
86
|
+
# [
|
87
|
+
# {'author': -> { includes(:author) }},
|
88
|
+
# {'comments': -> { includes(:comments) }},
|
89
|
+
# {'comments.author': -> {includes(comments: :author) }},
|
90
|
+
# 'publisher.addresses'
|
91
|
+
# ]
|
92
|
+
def initialize(request, model, permitted = [])
|
93
|
+
@request = request
|
94
|
+
@model = model
|
95
|
+
@permitted = permitted.is_a?(Array) ? permitted : []
|
96
|
+
@params = request.query_parameters
|
97
|
+
end
|
98
|
+
|
99
|
+
# Array of whitelisted include params. Raises JsonApiServer::BadRequest if
|
100
|
+
# any #include_params is not whitelisted.
|
101
|
+
#
|
102
|
+
# ==== Examples
|
103
|
+
#
|
104
|
+
# include=comments becomes ['comments']
|
105
|
+
# include=comments.author,tags becomes ['comments.author', 'tags']
|
106
|
+
def includes
|
107
|
+
include_params.select { |i| config_for(i).present? }
|
108
|
+
end
|
109
|
+
|
110
|
+
# Array of include params from the request.
|
111
|
+
#
|
112
|
+
# ===== Examples
|
113
|
+
#
|
114
|
+
# include=comments becomes ['comments']
|
115
|
+
# include=comments.author,tags becomes ['comments.author', 'tags']
|
116
|
+
def include_params
|
117
|
+
@include_params ||= begin
|
118
|
+
params[:include].present? ? params[:include].split(',').map!(&:strip) : []
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Returns an ActiveRecord::Relation object (a query fragment). Returns nil
|
123
|
+
# if no eagerloading is configured.
|
124
|
+
def relation
|
125
|
+
@relation ||= begin
|
126
|
+
additions = false
|
127
|
+
# TODO: merge! has unexpected results.
|
128
|
+
frag = include_params.reduce(model.all) do |result, inclusion|
|
129
|
+
config = config_for(inclusion)
|
130
|
+
query = config.respond_to?(:keys) ? config.values.first : nil
|
131
|
+
unless query.nil?
|
132
|
+
additions = true
|
133
|
+
result = result.merge(query)
|
134
|
+
end
|
135
|
+
result
|
136
|
+
end
|
137
|
+
additions ? frag : nil
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
alias query relation
|
142
|
+
|
143
|
+
protected
|
144
|
+
|
145
|
+
# Returns config. Raises JsonApiServer::BadRequest if inclusion is not whitelisted.
|
146
|
+
def config_for(inclusion)
|
147
|
+
config = permitted.find do |v|
|
148
|
+
inc = inclusion.to_s
|
149
|
+
v.respond_to?(:keys) ? v.keys.first.to_s == inc : v.to_s == inc
|
150
|
+
end
|
151
|
+
if config.nil?
|
152
|
+
msg = I18n.t('json_api_server.render_400.inclusion', param: inclusion)
|
153
|
+
raise JsonApiServer::BadRequest, msg
|
154
|
+
end
|
155
|
+
config
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|