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