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