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