json_api_server 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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,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