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,245 @@
1
+ module JsonApiServer # :nodoc:
2
+ # ==== Description
3
+ #
4
+ # Serializer class for a resource. Subclasses JsonApiServer::BaseSerializer.
5
+ #
6
+ # Example class:
7
+ #
8
+ # class CommentSerializer < JsonApiServer::ResourceSerializer
9
+ # resource_type 'comments'
10
+ #
11
+ # def links
12
+ # { self: File.join(base_url, "/comments/#{@object.id}") }
13
+ # end
14
+ #
15
+ # def data
16
+ # {}.tap do |h|
17
+ # h['type'] = self.class.type
18
+ # h['id'] = @object.id
19
+ # h['attributes'] = attributes
20
+ # h['relationships'] = inclusions.relationships if inclusions?
21
+ # end
22
+ # end
23
+ #
24
+ # def included
25
+ # inclusions.included if inclusions?
26
+ # end
27
+ #
28
+ # protected
29
+ #
30
+ # def attributes
31
+ # attributes_builder
32
+ # .add_multi(@object, 'title', 'comment')
33
+ # .add('created_at', @object.created_at.try(:iso8601, 9))
34
+ # .add('updated_at', @object.updated_at.try(:iso8601, 9))
35
+ # .attributes
36
+ # end
37
+ #
38
+ # def inclusions
39
+ # @inclusions ||= begin
40
+ # if relationship?('comment.author')
41
+ # relationships_builder.relate('author', user_serializer(@object.author))
42
+ # end
43
+ # if relationship?('comment.author.links')
44
+ # relationships_builder.include('author', user_serializer(@object.author),
45
+ # relate: { include: [:links] })
46
+ # end
47
+ # relationships_builder
48
+ # end
49
+ # end
50
+ #
51
+ # def user_serializer(user, as_json_options = { include: [:data] })
52
+ # ::UserSerializer.new(
53
+ # user,
54
+ # includes: includes,
55
+ # fields: fields,
56
+ # as_json_options: as_json_options
57
+ # )
58
+ # end
59
+ # end
60
+ #
61
+ # Create an instance from builder:
62
+ #
63
+ # builder = JsonApiServer::Builder.new(request, Comment.find(params[:id]))
64
+ # .add_include(['comment.author', 'comment.author.links'])
65
+ # .add_fields
66
+ #
67
+ # serializer = CommentSerializer.from_builder(builder)
68
+ #
69
+ class ResourceSerializer < JsonApiServer::BaseSerializer
70
+ # Array. Relationships to include. Array of strings. From the
71
+ # 'include' param. Extracted via JsonApiServer::Include#includes which
72
+ # is also available through JsonApiServer::Builder#includes. Defaults to
73
+ # nil. Set in initializer options.
74
+ #
75
+ # i.e.,
76
+ #
77
+ # GET /articles?include=comments.author,publisher becomes:
78
+ # includes = ['comments.author', 'publisher']
79
+ attr_reader :includes
80
+
81
+ # Hash. Fields requested by user. From the 'fields' param. Extracted
82
+ # via JsonApiServer::Fields#sparse_fields which is also available through
83
+ # JsonApiServer::Builder#sparse_fields. Defaults to nil. Set in initializer
84
+ # options.
85
+ #
86
+ # i.e.,
87
+ #
88
+ # GET /articles?include=author&fields[articles]=title,body&fields[people]=name becomes:
89
+ # fields = {'articles' => ['title', 'body'], 'people' => ['name']}
90
+ attr_reader :fields
91
+
92
+ # * <tt>object</tt> - instance of model or presenter or whatever stores data.
93
+ # * <tt>options</tt> - Hash (optional):
94
+ # * <tt>:includes</tt> - Instance of JsonApiServer::Include or nil. Sets #includes.
95
+ # * <tt>:fields</tt> - Instance of JsonApiServer::Fields or nil. Sets #fields.
96
+ def initialize(object, **options)
97
+ super(options)
98
+ @object = object
99
+ @includes = options[:includes]
100
+ @fields = options[:fields]
101
+ end
102
+
103
+ class << self
104
+ # 'type' used in #relationship_data and #data. i.e.:
105
+ #
106
+ # "data": {"type": "articles", "id": 2}
107
+ def type
108
+ @type || type_from_class_name
109
+ end
110
+
111
+ # 'type' used in #relationship_data.
112
+ def resource_type(type)
113
+ @type = type
114
+ end
115
+
116
+ # * <tt>:builder</tt> - Instance of JsonApiServer::Builder.
117
+ # #object, #includes and #fields will be extracted from it.
118
+ # * <tt>options</tt> - Hash, override values from Builder or set additional options.
119
+ # * <tt>:includes</tt> - Instance of JsonApiServer::Include or nil. Sets #includes.
120
+ # * <tt>:fields</tt> - Instance of JsonApiServer::Fields or nil. Sets #fields.
121
+ # * <tt>filter</tt> - Instance of JsonApiServer::Filter or nil. Sets #filter.
122
+ # * <tt>:paginator</tt> - Instance of JsonApiServer::Fields or nil. Sets #paginator.
123
+ # * <tt>:as_json_options</tt> - See options at JsonApiServer::BaseSerializer#as_json_options.
124
+ def from_builder(builder, **options)
125
+ opts = options.merge(fields: options[:fields] || builder.sparse_fields,
126
+ includes: options[:includes] || builder.includes,
127
+ paginator: options[:paginator] || builder.paginator,
128
+ filter: options[:filter] || builder.filter)
129
+ new(builder.query, opts)
130
+ end
131
+
132
+ protected
133
+
134
+ # Get type from class name.
135
+ def type_from_class_name
136
+ @type_from_class_name ||= begin
137
+ class_name = name.split('::').last
138
+ class_name.downcase!
139
+ class_name.gsub!(/serializer/, '')
140
+ class_name.pluralize
141
+ end
142
+ end
143
+ end
144
+
145
+ # Content when #as_json_options {include: [:relationship_data]} is specified. Defaults
146
+ # to { 'type' => <resource_type>, 'id' => <@object.id> }. resource_type can be
147
+ # set with class method #resource_type, otherwise it will be guessed from
148
+ # serializer class name.
149
+ def relationship_data
150
+ id = @object.try(:id) || @object.try(:[], :id)
151
+ { 'type' => self.class.type, 'id' => id }
152
+ end
153
+
154
+ protected
155
+
156
+ # Returns a new instance of JsonApiServer::AttributesBuilder for
157
+ # the specified #fields <tt>type</tt>.
158
+ #
159
+ # * <tt>type</tt> - the resource type.
160
+ #
161
+ # i.e.,
162
+ #
163
+ # # GET /articles?include=author&fields[articles]=title,body&fields[people]=name becomes:
164
+ # # fields = {'articles' => ['title', 'body'], 'people' => ['name']}
165
+ #
166
+ # self.attributes_builder_for('articles')
167
+ # .add('title', @object.title)
168
+ # .add('body', @object.body)
169
+ # .add('created_at', @object.created_at)
170
+ #
171
+ def attributes_builder_for(type)
172
+ JsonApiServer::AttributesBuilder.new(fields_for(type))
173
+ end
174
+
175
+ # Instance of JsonApiServer::AttributesBuilder for the class's resource 'type'.
176
+ #
177
+ # i.e.,
178
+ #
179
+ # self.attributes_builder # => attributes_builder_for(self.class.type)
180
+ # .add('title', @object.title)
181
+ # .add('body', @object.body)
182
+ # .add('created_at', @object.created_at)
183
+ def attributes_builder
184
+ @attributes_builder ||= attributes_builder_for(self.class.type)
185
+ end
186
+
187
+ # Instance of JsonApiServer::MetaBuilder.
188
+ #
189
+ # i.e.,
190
+ #
191
+ # self.meta_builder
192
+ # .add('total_records', 35)
193
+ # ...
194
+ # self.meta_builder
195
+ # .add('paging', 'showing 11 - 20')
196
+ # .meta # => { 'total_records': 35, 'paging': 'showing 11 - 20' }
197
+ #
198
+ def meta_builder
199
+ @meta_builder ||= JsonApiServer::MetaBuilder.new
200
+ end
201
+
202
+ # Instance of JsonApiServer::RelationshipsBuilder.
203
+ #
204
+ # i.e.,
205
+ #
206
+ # self.relationships_builder
207
+ # .relate(...)
208
+ # .relate_if(...)
209
+ # .relate_each(...)
210
+ #
211
+ # self.relationships_builder.relationships # get relationships section
212
+ # self.relationships_builder.included # get included section
213
+ def relationships_builder
214
+ @relationships_builder ||= JsonApiServer::RelationshipsBuilder.new
215
+ end
216
+
217
+ alias rb relationships_builder
218
+
219
+ # Returns true if relationship is in #includes array.
220
+ #
221
+ # * <tt>relationship</tt> - Name of relationship. String or symbol.
222
+ #
223
+ # i.e.,
224
+ #
225
+ # # GET /articles?include=comment.author,publisher becomes:
226
+ # # includes = ['comment.author', 'publisher']
227
+ #
228
+ # self.relationship?('comment.author') # => true
229
+ # self.relationship?('addresses') # => false
230
+ def relationship?(relationship)
231
+ @includes.respond_to?(:include?) && @includes.include?(relationship.to_s)
232
+ end
233
+
234
+ # Returns true if there are requested inclusions. False otherwise.
235
+ def inclusions?
236
+ @includes.respond_to?(:any?) && @includes.any?
237
+ end
238
+
239
+ # Returns the fields for a specific type. i.e., fields_for('articles') or nil
240
+ # if type doesn't exist or fields is nil.
241
+ def fields_for(type)
242
+ @fields.respond_to?(:key) ? @fields[type.to_s] : nil
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,131 @@
1
+ # TODO: inheritance is dubious with introduction of :type and relationship_data.
2
+ module JsonApiServer # :nodoc:
3
+ # ==== Description
4
+ #
5
+ # Serializer for a collection/array of resources. Inherits from
6
+ # JsonApiServer::ResourceSerializer.
7
+ #
8
+ # ==== Example
9
+ #
10
+ # Given a resource serializer for Topic:
11
+ #
12
+ # class TopicSerializer < JsonApiServer::ResourceSerializer
13
+ # resource_type 'topics'
14
+ #
15
+ # def links
16
+ # { self: File.join(base_url, "/topics/#{@object.id}") }
17
+ # end
18
+ #
19
+ # def data
20
+ # {
21
+ # type: self.class.type,
22
+ # id: @object.id,
23
+ # attributes: attributes
24
+ # }
25
+ # end
26
+ #
27
+ # protected
28
+ #
29
+ # def attributes
30
+ # attributes_builder
31
+ # .add('book', @object.book)
32
+ # .add('author', @object.author)
33
+ # .add('quote', @object.quote)
34
+ # .add('character', @object.character)
35
+ # .add('location', @object.location)
36
+ # .add('published', @object.published)
37
+ # .add('created_at', @object.created_at.try(:iso8601, 0))
38
+ # .add('updated_at', @object.updated_at.try(:iso8601, 0))
39
+ # .attributes
40
+ # end
41
+ # end
42
+ #
43
+ # Create a Topics serializer like so:
44
+ #
45
+ # class TopicsSerializer < JsonApiServer::ResourcesSerializer
46
+ # serializer TopicSerializer
47
+ # end
48
+ #
49
+ # Create an instance from builder:
50
+ #
51
+ # builder = JsonApiServer::Builder.new(request, Topic.all)
52
+ # .add_pagination(pagination_options)
53
+ # .add_filter(filter_options)
54
+ # .add_sort(sort_options)
55
+ # .add_include(include_options)
56
+ # .add_fields
57
+ #
58
+ # # populates links with pagination info, merges data from each
59
+ # # Topic serializer instance.
60
+ # serializer = TopicsSerializer.from_builder(builder)
61
+ #
62
+ class ResourcesSerializer < JsonApiServer::ResourceSerializer
63
+ # Instance of JsonApiServer::Paginator or nil (default). Based on pagination
64
+ # params. Extracted via JsonApiServer::Pagination and available
65
+ # through JsonApiServer::Builder#paginator. Set in initializer options.
66
+ attr_reader :paginator
67
+
68
+ # Instance of JsonApiServer::Filter or nil (default). Based on filter
69
+ # params. Extracted via JsonApiServer::Filter and available
70
+ # through JsonApiServer::Builder#filter. Set in initializer options.
71
+ attr_reader :filter
72
+
73
+ class << self
74
+ attr_reader :objects_serializer
75
+
76
+ # A serializer class. If set,'objects' will be converted to instances of
77
+ # this serializer.
78
+ def serializer(klass)
79
+ @objects_serializer = klass
80
+ end
81
+ end
82
+
83
+ # * <tt>objects</tt> - An array of objects. If #serializer is specified, the
84
+ # objects will be converted to this class.
85
+ # * <tt>options</tt> - Hash:
86
+ # * <tt>filter</tt> - Instance of JsonApiServer::Filter or nil. Sets #filter.
87
+ # * <tt>:paginator</tt> - Instance of JsonApiServer::Fields or nil. Sets #paginator.
88
+ # * <tt>:as_json_options</tt> - See options at JsonApiServer::BaseSerializer#as_json_options.
89
+ def initialize(objects, **options)
90
+ super(nil, options)
91
+ remove_instance_variable(:@object)
92
+ @paginator = options[:paginator]
93
+ @filter = options[:filter]
94
+ @objects = initalize_objects(objects)
95
+ end
96
+
97
+ def links
98
+ @paginator.try(:as_json) || {}
99
+ end
100
+
101
+ # Subclasses override for customized behaviour.
102
+ def data
103
+ data = @objects.try(:map) { |o| o.try(:data) }
104
+ data.try(:compact!) || data
105
+ end
106
+
107
+ # Subclasses override for customized behaviour.
108
+ def relationship_data
109
+ data = @objects.try(:map) { |o| o.try(:relationship_data) }
110
+ data.try(:compact!) || data
111
+ end
112
+
113
+ # Subclasses override for customized behaviour.
114
+ def included
115
+ included = @objects.try(:map) { |o| o.try(:included) }
116
+ included.try(:flatten!)
117
+ included.try(:compact!) || included
118
+ end
119
+
120
+ protected
121
+
122
+ def initalize_objects(objects)
123
+ klass = self.class.objects_serializer
124
+ if klass && objects.respond_to?(:map)
125
+ objects.map { |object| klass.new(object, includes: includes, fields: fields) }
126
+ else
127
+ objects
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,34 @@
1
+ #--
2
+ # TODO: https://github.com/ohler55/oj/issues/199
3
+ #++
4
+ module JsonApiServer # :nodoc:
5
+ # ==== Description
6
+ #
7
+ # to_json serializer method. Used by the various serializers.
8
+ module Serializer
9
+ # Serializer options from JsonApiServer::Configuration#serializer_options.
10
+ def serializer_options
11
+ JsonApiServer.configuration.serializer_options
12
+ end
13
+
14
+ # Classes override.
15
+ def as_json
16
+ {}
17
+ end
18
+
19
+ # Serializes to JSON. Serializer options default to
20
+ # JsonApiServer.configuration.serializer_options unless
21
+ # alternate are specified with the <tt>options</tt> parameter.
22
+ # Default options are:
23
+ # escape_mode: :xss_safe,
24
+ # time: :xmlschema,
25
+ # mode: :compat
26
+ #
27
+ # Parameters:
28
+ # - options (Hash) - OJ serialization options: https://github.com/ohler55/oj#options. If none specified, it uses defaults.
29
+ def to_json(**options)
30
+ opts = options.empty? ? serializer_options : options
31
+ Oj.dump(as_json, opts)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,156 @@
1
+ #--
2
+ # TODO:
3
+ # - Does not return 400 Bad Request if sort attribute is not supported.
4
+ # - Sort nested relationships specified with dot notation - topic.comments
5
+ #++
6
+ module JsonApiServer # :nodoc:
7
+ # === Description:
8
+ # Implements sort parameters per JSON API Spec:
9
+ # http://jsonapi.org/format/#fetching-sorting.
10
+ #
11
+ # From the spec: "The sort order for each sort field MUST be ascending unless it is
12
+ # prefixed with a minus (U+002D HYPHEN-MINUS, "-", in which case it MUST be
13
+ # descending."
14
+ #
15
+ # This class (1) whitelists sort params, (2) optionally specifies a default order,
16
+ # and (3) generates a sub-query based on these params.
17
+ #
18
+ # === Usage:
19
+ #
20
+ # A sort request will look like:
21
+ # /topics?sort=-created,title
22
+ #
23
+ # This gets converted to an array. Sort order is ASC by default. Minus = DESC.
24
+ # ['-created', 'title']
25
+ #
26
+ # Sort attributes are configured like so. <tt>:permitted</tt> are whitelisted attributes.
27
+ # <tt>:default</tt> specifies the default sort order. If a user specifies sort params other
28
+ # than those in <tt>:permitted</tt>, a JsonApiServer::BadRequest exception is
29
+ # raised which renders a 400 error.
30
+ # {
31
+ # permitted: [:id, :title, { created: { col_name: :created_at}],
32
+ # default: { id: :desc }
33
+ # }
34
+ # In this example, id, title and created (alias for created_at column)
35
+ # are permitted sort params.
36
+ #
37
+ # ==== Example:
38
+ # # create sort options
39
+ # sort_options = {
40
+ # permitted: [:id, :title, { created: { col_name: :created_at}],
41
+ # default: { id: :desc }
42
+ # }
43
+ #
44
+ # # create instance
45
+ # sort = JsonApiServer::Sort.new(request, Topic, sort_options)
46
+ #
47
+ # # merge into master query
48
+ # recent_topics = Topic.recent.merge(sort.query)
49
+ #
50
+ # # see sort params
51
+ # puts sort.sort
52
+ #
53
+ # ==== Note:
54
+ # JsonApiServer::Builder class provides an easier way to use this class.
55
+ #
56
+ class Sort
57
+ #--
58
+ # ActiveRecord::QueryMethods order is defined order(*args)
59
+ # i.e., User.order(:name, email: :desc)
60
+ #++
61
+
62
+ # Controller request object.
63
+ attr_reader :request
64
+
65
+ # Model passed in constructor.
66
+ attr_reader :model
67
+
68
+ # (Hash) Sort options. Specify the attributes that can be used in ActiveRecord
69
+ # query method 'sort'. No sort attributes can be used except for those
70
+ # specified here.
71
+ #
72
+ # - <tt>permitted</tt> - array of model attribute names
73
+ # - <tt>default</tt> - used if no sort params are specified (or rejected)
74
+ #
75
+ # i.e.,
76
+ #
77
+ # Allows sorting on model attributes :id, :title, :created.
78
+ # If no sort params are specified in the request, it sorts by 'id' desc.
79
+ #
80
+ # {
81
+ # permitted: [:id, :title, { created: { col_name: :created_at}],
82
+ # default: { id: :desc }
83
+ # }
84
+ attr_reader :options
85
+
86
+ # Request query params (request.query_parameters).
87
+ attr_reader :params
88
+
89
+ # Params:
90
+ # - request - instance of request object
91
+ # - options - sort options. See #options documentation.
92
+ def initialize(request, model, options = {})
93
+ @request = request
94
+ @model = model
95
+ @options = options
96
+ @params = request.query_parameters
97
+ end
98
+
99
+ # Returns an ActiveRecord::Relation if sort_params are present. Otherwise
100
+ # returns nil. Instance is a query fragment intended to be merged into another
101
+ # query.
102
+ #
103
+ # ==== Example:
104
+ #
105
+ # sort = JsonApiServer::Sort.new(request, Comment, options)
106
+ # Comment.recent.merge!(sort.query)
107
+ #
108
+ def relation
109
+ @relation ||= model.order(sort_params) if sort_params.present?
110
+ end
111
+
112
+ alias query relation
113
+
114
+ # Sort query parameter params[:sort].
115
+ def sort
116
+ @sort ||= params[:sort].to_s
117
+ end
118
+
119
+ # Instance of JsonApiServer::SortConfigs based on #options.
120
+ def configs
121
+ @configs ||= JsonApiServer::SortConfigs.new(options)
122
+ end
123
+
124
+ # Calculated ActiveRecord 'order' parameters. Use in queries.
125
+ def sort_params
126
+ @sort_params ||= begin
127
+ attrs = sort.split(',')
128
+ sort_params = convert(attrs)
129
+ sort_params.empty? ? configs.default_order : sort_params
130
+ end
131
+ end
132
+
133
+ protected
134
+
135
+ # Converts to ActiveRecord query order parameters; whitelists based on configs.
136
+ # Raises JsonApiServer::BadRequest with descriptive message if attribute
137
+ # is not whitelisted.
138
+ def convert(attrs)
139
+ whitelisted = []
140
+
141
+ attrs.each do |attr|
142
+ attr.strip!
143
+ order = attr.start_with?('-') ? :desc : :asc
144
+ attr_name = order == :desc ? attr.slice(1..attr.length) : attr
145
+ config = configs.config_for(attr_name)
146
+ if config.nil?
147
+ msg = I18n.t('json_api_server.render_400.sort', param: attr_name)
148
+ raise JsonApiServer::BadRequest, msg
149
+ end
150
+ whitelisted << { (config[:col_name] || config[:attr]) => order }
151
+ end
152
+
153
+ whitelisted
154
+ end
155
+ end
156
+ end