carwow-json_api_client 1.19.0

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/LICENSE +20 -0
  3. data/README.md +706 -0
  4. data/Rakefile +32 -0
  5. data/lib/json_api_client.rb +30 -0
  6. data/lib/json_api_client/associations.rb +8 -0
  7. data/lib/json_api_client/associations/base_association.rb +33 -0
  8. data/lib/json_api_client/associations/belongs_to.rb +31 -0
  9. data/lib/json_api_client/associations/has_many.rb +8 -0
  10. data/lib/json_api_client/associations/has_one.rb +16 -0
  11. data/lib/json_api_client/connection.rb +41 -0
  12. data/lib/json_api_client/error_collector.rb +91 -0
  13. data/lib/json_api_client/errors.rb +107 -0
  14. data/lib/json_api_client/formatter.rb +145 -0
  15. data/lib/json_api_client/helpers.rb +9 -0
  16. data/lib/json_api_client/helpers/associatable.rb +88 -0
  17. data/lib/json_api_client/helpers/callbacks.rb +27 -0
  18. data/lib/json_api_client/helpers/dirty.rb +75 -0
  19. data/lib/json_api_client/helpers/dynamic_attributes.rb +78 -0
  20. data/lib/json_api_client/helpers/uri.rb +9 -0
  21. data/lib/json_api_client/implementation.rb +12 -0
  22. data/lib/json_api_client/included_data.rb +58 -0
  23. data/lib/json_api_client/linking.rb +6 -0
  24. data/lib/json_api_client/linking/links.rb +22 -0
  25. data/lib/json_api_client/linking/top_level_links.rb +39 -0
  26. data/lib/json_api_client/meta_data.rb +19 -0
  27. data/lib/json_api_client/middleware.rb +7 -0
  28. data/lib/json_api_client/middleware/json_request.rb +26 -0
  29. data/lib/json_api_client/middleware/parse_json.rb +31 -0
  30. data/lib/json_api_client/middleware/status.rb +67 -0
  31. data/lib/json_api_client/paginating.rb +6 -0
  32. data/lib/json_api_client/paginating/nested_param_paginator.rb +140 -0
  33. data/lib/json_api_client/paginating/paginator.rb +89 -0
  34. data/lib/json_api_client/parsers.rb +5 -0
  35. data/lib/json_api_client/parsers/parser.rb +102 -0
  36. data/lib/json_api_client/query.rb +6 -0
  37. data/lib/json_api_client/query/builder.rb +239 -0
  38. data/lib/json_api_client/query/requestor.rb +73 -0
  39. data/lib/json_api_client/relationships.rb +6 -0
  40. data/lib/json_api_client/relationships/relations.rb +55 -0
  41. data/lib/json_api_client/relationships/top_level_relations.rb +30 -0
  42. data/lib/json_api_client/request_params.rb +57 -0
  43. data/lib/json_api_client/resource.rb +643 -0
  44. data/lib/json_api_client/result_set.rb +25 -0
  45. data/lib/json_api_client/schema.rb +154 -0
  46. data/lib/json_api_client/utils.rb +48 -0
  47. data/lib/json_api_client/version.rb +3 -0
  48. metadata +213 -0
@@ -0,0 +1,643 @@
1
+ require 'forwardable'
2
+ require 'active_support/all'
3
+ require 'active_model'
4
+
5
+ module JsonApiClient
6
+ class Resource
7
+ extend ActiveModel::Naming
8
+ extend ActiveModel::Translation
9
+ include ActiveModel::Validations
10
+ include ActiveModel::Conversion
11
+ include ActiveModel::Serialization
12
+
13
+ include Helpers::DynamicAttributes
14
+ include Helpers::Dirty
15
+ include Helpers::Associatable
16
+
17
+ attr_accessor :last_result_set,
18
+ :links,
19
+ :relationships,
20
+ :request_params
21
+ class_attribute :site,
22
+ :primary_key,
23
+ :parser,
24
+ :paginator,
25
+ :connection_class,
26
+ :connection_object,
27
+ :connection_options,
28
+ :query_builder,
29
+ :linker,
30
+ :relationship_linker,
31
+ :read_only_attributes,
32
+ :requestor_class,
33
+ :json_key_format,
34
+ :route_format,
35
+ :request_params_class,
36
+ :keep_request_params,
37
+ :search_included_in_result_set,
38
+ :custom_type_to_class,
39
+ :raise_on_blank_find_param,
40
+ instance_accessor: false
41
+ class_attribute :add_defaults_to_changes,
42
+ instance_writer: false
43
+
44
+ class_attribute :_immutable,
45
+ instance_writer: false,
46
+ default: false
47
+
48
+ self.primary_key = :id
49
+ self.parser = Parsers::Parser
50
+ self.paginator = Paginating::Paginator
51
+ self.connection_class = Connection
52
+ self.connection_options = {}
53
+ self.query_builder = Query::Builder
54
+ self.linker = Linking::Links
55
+ self.relationship_linker = Relationships::Relations
56
+ self.read_only_attributes = [:id, :type, :links, :meta, :relationships]
57
+ self.requestor_class = Query::Requestor
58
+ self.request_params_class = RequestParams
59
+ self.keep_request_params = false
60
+ self.add_defaults_to_changes = false
61
+ self.search_included_in_result_set = false
62
+ self.custom_type_to_class = {}
63
+ self.raise_on_blank_find_param = false
64
+
65
+ #:underscored_key, :camelized_key, :dasherized_key, or custom
66
+ self.json_key_format = :underscored_key
67
+
68
+ #:underscored_route, :camelized_route, :dasherized_route, or custom
69
+ self.route_format = :underscored_route
70
+
71
+ class << self
72
+ extend Forwardable
73
+ def_delegators :_new_scope, :where, :order, :includes, :select, :all, :paginate, :page, :with_params, :first, :find, :last
74
+
75
+ def resolve_custom_type(type_name, class_name)
76
+ classified_type = key_formatter.unformat(type_name.to_s).singularize.classify
77
+ self.custom_type_to_class = custom_type_to_class.merge(classified_type => class_name.to_s)
78
+ end
79
+
80
+ # The table name for this resource. i.e. Article -> articles, Person -> people
81
+ #
82
+ # @return [String] The table name for this resource
83
+ def table_name
84
+ route_formatter.format(resource_name.pluralize)
85
+ end
86
+
87
+ # The name of a single resource. i.e. Article -> article, Person -> person
88
+ #
89
+ # @return [String]
90
+ def resource_name
91
+ name.demodulize.underscore
92
+ end
93
+
94
+ # Specifies the JSON API resource type. By default this is inferred
95
+ # from the resource class name.
96
+ #
97
+ # @return [String] Resource path
98
+ def type
99
+ table_name
100
+ end
101
+
102
+ # Indicates whether this resource is mutable or immutable;
103
+ # by default, all resources are mutable.
104
+ #
105
+ # @return [Boolean]
106
+ def immutable(flag = true)
107
+ self._immutable = flag
108
+ end
109
+
110
+ def inherited(subclass)
111
+ subclass._immutable = false
112
+ super
113
+ end
114
+
115
+ # Specifies the relative path that should be used for this resource;
116
+ # by default, this is inferred from the resource class name.
117
+ #
118
+ # @return [String] Resource path
119
+ def resource_path
120
+ table_name
121
+ end
122
+
123
+ # Load a resource object from attributes and consider it persisted
124
+ #
125
+ # @return [Resource] Persisted resource object
126
+ def load(params)
127
+ new(params).tap do |resource|
128
+ resource.mark_as_persisted!
129
+ resource.clear_changes_information
130
+ resource.relationships.clear_changes_information
131
+ end
132
+ end
133
+
134
+ # Return/build a connection object
135
+ #
136
+ # @return [Connection] The connection to the json api server
137
+ def connection(rebuild = false, &block)
138
+ _build_connection(rebuild, &block)
139
+ connection_object
140
+ end
141
+
142
+ # Param names that will be considered path params. They will be used
143
+ # to build the resource path rather than treated as attributes
144
+ #
145
+ # @return [Array] Param name symbols of parameters that will be treated as path parameters
146
+ def prefix_params
147
+ _belongs_to_associations.map(&:param)
148
+ end
149
+
150
+ # Return the path or path pattern for this resource
151
+ def path(params = nil)
152
+ parts = [resource_path]
153
+ if params && _prefix_path.present?
154
+ path_params = params.delete(:path) || params
155
+ parts.unshift(_set_prefix_path(path_params.symbolize_keys))
156
+ else
157
+ parts.unshift(_prefix_path)
158
+ end
159
+ parts.reject!(&:blank?)
160
+ File.join(*parts)
161
+ rescue KeyError
162
+ raise ArgumentError, "Not all prefix parameters specified"
163
+ end
164
+
165
+ # Create a new instance of this resource class
166
+ #
167
+ # @param attributes [Hash] The attributes to create this resource with
168
+ # @return [Resource] The instance you tried to create. You will have to check the persisted state or errors on this object to see success/failure.
169
+ def create(attributes = {})
170
+ new(attributes).tap do |resource|
171
+ resource.save
172
+ end
173
+ end
174
+
175
+ # Within the given block, add these headers to all requests made by
176
+ # the resource class
177
+ #
178
+ # @param headers [Hash] The headers to send along
179
+ # @param block [Block] The block where headers will be set for
180
+ def with_headers(headers)
181
+ self._custom_headers = headers
182
+ yield
183
+ ensure
184
+ self._custom_headers = {}
185
+ end
186
+
187
+ # The current custom headers to send with any request made by this
188
+ # resource class
189
+ #
190
+ # @return [Hash] Headers
191
+ def custom_headers
192
+ return _header_store.to_h if superclass == Object
193
+
194
+ superclass.custom_headers.merge(_header_store.to_h)
195
+ end
196
+
197
+ # Returns the requestor for this resource class
198
+ #
199
+ # @return [Requestor] The requestor for this resource class
200
+ def requestor
201
+ @requestor ||= requestor_class.new(self)
202
+ end
203
+
204
+ # Default attributes that every instance of this resource should be
205
+ # initialized with. Optionally, override this method in a subclass.
206
+ #
207
+ # @return [Hash] Default attributes
208
+ def default_attributes
209
+ {type: type}
210
+ end
211
+
212
+ # Returns the schema for this resource class
213
+ #
214
+ # @return [Schema] The schema for this resource class
215
+ def schema
216
+ @schema ||= Schema.new
217
+ end
218
+
219
+ def key_formatter
220
+ JsonApiClient::Formatter.formatter_for(json_key_format)
221
+ end
222
+
223
+ def route_formatter
224
+ JsonApiClient::Formatter.formatter_for(route_format)
225
+ end
226
+
227
+ protected
228
+
229
+ # Declares a new class/instance method that acts on the collection/member
230
+ #
231
+ # @param name [Symbol] the name of the endpoint
232
+ # @param options [Hash] endpoint options
233
+ # @option [Symbol] :on One of [:collection or :member] to decide whether it's a collect or member method
234
+ # @option [Symbol] :request_method The request method (:get, :post, etc)
235
+ def custom_endpoint(name, options = {})
236
+ if _immutable
237
+ request_method = options.fetch(:request_method, :get).to_sym
238
+ raise JsonApiClient::Errors::ResourceImmutableError if request_method != :get
239
+ end
240
+
241
+ if :collection == options.delete(:on)
242
+ collection_endpoint(name, options)
243
+ else
244
+ member_endpoint(name, options)
245
+ end
246
+ end
247
+
248
+ # Declares a new class method that acts on the collection
249
+ #
250
+ # @param name [Symbol] the name of the endpoint and the method name
251
+ # @param options [Hash] endpoint options
252
+ # @option options [Symbol] :request_method The request method (:get, :post, etc)
253
+ def collection_endpoint(name, options = {})
254
+ metaclass = class << self
255
+ self
256
+ end
257
+ metaclass.instance_eval do
258
+ define_method(name) do |*params|
259
+ request_params = params.first || {}
260
+ requestor.custom(name, options, request_params)
261
+ end
262
+ end
263
+ end
264
+
265
+ # Declares a new instance method that acts on the member object
266
+ #
267
+ # @param name [Symbol] the name of the endpoint and the method name
268
+ # @param options [Hash] endpoint options
269
+ # @option options [Symbol] :request_method The request method (:get, :post, etc)
270
+ def member_endpoint(name, options = {})
271
+ define_method name do |*params|
272
+ request_params = params.first || {}
273
+ request_params[self.class.primary_key] = attributes.fetch(self.class.primary_key)
274
+ self.class.requestor.custom(name, options, request_params)
275
+ end
276
+ end
277
+
278
+ # Declares a new property by name
279
+ #
280
+ # @param name [Symbol] the name of the property
281
+ # @param options [Hash] property options
282
+ # @option options [Symbol] :type The property type
283
+ # @option options [Symbol] :default The default value for the property
284
+ def property(name, options = {})
285
+ schema.add(name, options)
286
+ define_method(name) do
287
+ attributes[name]
288
+ end
289
+ define_method("#{name}=") do |value|
290
+ set_attribute(name, value)
291
+ end
292
+ end
293
+
294
+ # Declare multiple properties with the same optional options
295
+ #
296
+ # @param [Array<Symbol>] names
297
+ # @param options [Hash] property options
298
+ # @option options [Symbol] :type The property type
299
+ # @option options [Symbol] :default The default value for the property
300
+ def properties(*names)
301
+ options = names.last.is_a?(Hash) ? names.pop : {}
302
+ names.each do |name|
303
+ property name, options
304
+ end
305
+ end
306
+
307
+ def _belongs_to_associations
308
+ associations.select{|association| association.is_a?(Associations::BelongsTo::Association) }
309
+ end
310
+
311
+ def _prefix_path
312
+ paths = _belongs_to_associations.map do |a|
313
+ a.to_prefix_path(route_formatter)
314
+ end
315
+
316
+ paths.compact.join("/")
317
+ end
318
+
319
+ def _set_prefix_path(attrs)
320
+ paths = _belongs_to_associations.map do |a|
321
+ a.set_prefix_path(attrs, route_formatter)
322
+ end
323
+
324
+ paths.compact.join("/")
325
+ end
326
+
327
+ def _new_scope
328
+ query_builder.new(self)
329
+ end
330
+
331
+ def _custom_headers=(headers)
332
+ _header_store.replace(headers)
333
+ end
334
+
335
+ def _header_store
336
+ Thread.current["json_api_client-#{resource_name}"] ||= {}
337
+ end
338
+
339
+ def _build_connection(rebuild = false)
340
+ return connection_object unless connection_object.nil? || rebuild
341
+ self.connection_object = connection_class.new(connection_options.merge(site: site)).tap do |conn|
342
+ yield(conn) if block_given?
343
+ end
344
+ end
345
+ end
346
+
347
+ # Instantiate a new resource object
348
+ #
349
+ # @param params [Hash] Attributes, links, and relationships
350
+ def initialize(params = {})
351
+ params = params.symbolize_keys
352
+ @persisted = nil
353
+ @destroyed = nil
354
+ self.links = self.class.linker.new(params.delete(:links) || {})
355
+ self.relationships = self.class.relationship_linker.new(self.class, params.delete(:relationships) || {})
356
+ self.attributes = self.class.default_attributes.merge params.except(*self.class.prefix_params)
357
+ self.forget_change!(:type)
358
+ self.__belongs_to_params = params.slice(*self.class.prefix_params)
359
+
360
+ setup_default_properties
361
+
362
+ self.class.associations.each do |association|
363
+ if params.has_key?(association.attr_name.to_s)
364
+ set_attribute(association.attr_name, params[association.attr_name.to_s])
365
+ end
366
+ end
367
+ self.request_params = self.class.request_params_class.new(self.class)
368
+ end
369
+
370
+ # Set the current attributes and try to save them
371
+ #
372
+ # @param attrs [Hash] Attributes to update
373
+ # @return [Boolean] Whether the update succeeded or not
374
+ def update_attributes(attrs = {})
375
+ self.attributes = attrs
376
+ save
377
+ end
378
+
379
+ # Alias to update_attributes
380
+ #
381
+ # @param attrs [Hash] Attributes to update
382
+ # @return [Boolean] Whether the update succeeded or not
383
+ def update(attrs = {})
384
+ update_attributes(attrs)
385
+ end
386
+
387
+ # Mark the record as persisted
388
+ def mark_as_persisted!
389
+ @persisted = true
390
+ end
391
+
392
+ # Whether or not this record has been persisted to the database previously
393
+ #
394
+ # @return [Boolean]
395
+ def persisted?
396
+ !!@persisted && !destroyed? && has_attribute?(self.class.primary_key)
397
+ end
398
+
399
+ # Mark the record as destroyed
400
+ def mark_as_destroyed!
401
+ @destroyed = true
402
+ end
403
+
404
+ # Whether or not this record has been destroyed to the database previously
405
+ #
406
+ # @return [Boolean]
407
+ def destroyed?
408
+ !!@destroyed
409
+ end
410
+
411
+ # Returns true if this is a new record (never persisted to the database)
412
+ #
413
+ # @return [Boolean]
414
+ def new_record?
415
+ !persisted? && !destroyed?
416
+ end
417
+
418
+ # When we represent this resource as a relationship, we do so with id & type
419
+ #
420
+ # @return [Hash] Representation of this object as a relation
421
+ def as_relation
422
+ attributes.slice(:type, self.class.primary_key)
423
+ end
424
+
425
+ # When we represent this resource for serialization (create/update), we do so
426
+ # with this implementation
427
+ #
428
+ # @return [Hash] Representation of this object as JSONAPI object
429
+ def as_json_api(*)
430
+ attributes.slice(self.class.primary_key, :type).tap do |h|
431
+ relationships_for_serialization.tap do |r|
432
+ h[:relationships] = self.class.key_formatter.format_keys(r) unless r.empty?
433
+ end
434
+ h[:attributes] = self.class.key_formatter.format_keys(attributes_for_serialization)
435
+ end
436
+ end
437
+
438
+ def as_json(*)
439
+ attributes.slice(self.class.primary_key, :type).tap do |h|
440
+ relationships.as_json.tap do |r|
441
+ h[:relationships] = r unless r.empty?
442
+ end
443
+ h[:attributes] = attributes.except(self.class.primary_key, :type).as_json
444
+ end
445
+ end
446
+
447
+ # Mark all attributes for this record as dirty
448
+ def set_all_dirty!
449
+ set_all_attributes_dirty
450
+ relationships.set_all_attributes_dirty if relationships
451
+ end
452
+
453
+ def valid?(context = nil)
454
+ context ||= (new_record? ? :create : :update)
455
+ super(context)
456
+ end
457
+
458
+ # Commit the current changes to the resource to the remote server.
459
+ # If the resource was previously loaded from the server, we will
460
+ # try to update the record. Otherwise if it's a new record, then
461
+ # we will try to create it
462
+ #
463
+ # @return [Boolean] Whether or not the save succeeded
464
+ def save
465
+ return false unless valid?
466
+ raise JsonApiClient::Errors::ResourceImmutableError if _immutable
467
+
468
+ self.last_result_set = if persisted?
469
+ self.class.requestor.update(self)
470
+ else
471
+ self.class.requestor.create(self)
472
+ end
473
+
474
+ if last_result_set.has_errors?
475
+ fill_errors
476
+ false
477
+ else
478
+ self.errors.clear if self.errors
479
+ self.request_params.clear unless self.class.keep_request_params
480
+ mark_as_persisted!
481
+ if updated = last_result_set.first
482
+ self.attributes = updated.attributes
483
+ self.links.attributes = updated.links.attributes
484
+ self.relationships.attributes = updated.relationships.attributes
485
+ clear_changes_information
486
+ self.relationships.clear_changes_information
487
+ _clear_cached_relationships
488
+ end
489
+ true
490
+ end
491
+ end
492
+
493
+ # Try to destroy this resource
494
+ #
495
+ # @return [Boolean] Whether or not the destroy succeeded
496
+ def destroy
497
+ raise JsonApiClient::Errors::ResourceImmutableError if _immutable
498
+
499
+ self.last_result_set = self.class.requestor.destroy(self)
500
+ if last_result_set.has_errors?
501
+ fill_errors
502
+ false
503
+ else
504
+ mark_as_destroyed!
505
+ _clear_cached_relationships
506
+ _clear_belongs_to_params
507
+ true
508
+ end
509
+ end
510
+
511
+ def inspect
512
+ "#<#{self.class.name}:@attributes=#{attributes.inspect}>"
513
+ end
514
+
515
+ def request_includes(*includes)
516
+ self.request_params.add_includes(includes)
517
+ self
518
+ end
519
+
520
+ def reset_request_includes!
521
+ self.request_params.reset_includes!
522
+ self
523
+ end
524
+
525
+ def request_select(*fields)
526
+ fields_by_type = fields.extract_options!
527
+ fields_by_type[type.to_sym] = fields if fields.any?
528
+ fields_by_type.each do |field_type, field_names|
529
+ self.request_params.set_fields(field_type, field_names)
530
+ end
531
+ self
532
+ end
533
+
534
+ def reset_request_select!(*resource_types)
535
+ resource_types = self.request_params.field_types if resource_types.empty?
536
+ resource_types.each { |resource_type| self.request_params.remove_fields(resource_type) }
537
+ self
538
+ end
539
+
540
+ def path_attributes
541
+ _belongs_to_params.merge attributes.slice( self.class.primary_key ).symbolize_keys
542
+ end
543
+
544
+ protected
545
+
546
+ def setup_default_properties
547
+ self.class.schema.each_property do |property|
548
+ unless attributes.has_key?(property.name) || property.default.nil?
549
+ attribute_will_change!(property.name) if add_defaults_to_changes
550
+ attributes[property.name] = property.default
551
+ end
552
+ end
553
+ end
554
+
555
+ def relationship_definition_for(name)
556
+ relationships[name] if relationships && relationships.has_attribute?(name)
557
+ end
558
+
559
+ def included_data_for(name, relationship_definition)
560
+ last_result_set.included.data_for(name, relationship_definition)
561
+ end
562
+
563
+ def relationship_data_for(name, relationship_definition)
564
+ # look in included data
565
+ if relationship_definition.key?("data")
566
+ if relationships.attribute_changed?(name)
567
+ return relation_objects_for(name, relationship_definition)
568
+ else
569
+ return included_data_for(name, relationship_definition)
570
+ end
571
+ end
572
+
573
+ return unless links = relationship_definition["links"]
574
+ return unless url = links["related"]
575
+
576
+ association_for(name).data(url)
577
+ end
578
+
579
+ def relation_objects_for(name, relationship_definition)
580
+ data = relationship_definition["data"]
581
+ assoc = association_for(name)
582
+ return if data.nil? || assoc.nil?
583
+ assoc.load_records(data)
584
+ end
585
+
586
+ def method_missing(method, *args)
587
+ relationship_definition = relationship_definition_for(method)
588
+
589
+ return super unless relationship_definition
590
+
591
+ relationship_data_for(method, relationship_definition)
592
+ end
593
+
594
+ def respond_to_missing?(symbol, include_all = false)
595
+ return true if relationships && relationships.has_attribute?(symbol)
596
+ return true if association_for(symbol)
597
+ super
598
+ end
599
+
600
+ def set_attribute(name, value)
601
+ property = property_for(name)
602
+ value = property.cast(value) if property
603
+ super(name, value)
604
+ end
605
+
606
+ def has_attribute?(attr_name)
607
+ !!property_for(attr_name) || super
608
+ end
609
+
610
+ def property_for(name)
611
+ self.class.schema.find(name)
612
+ end
613
+
614
+ def association_for(name)
615
+ self.class.associations.detect do |association|
616
+ association.attr_name.to_s == self.class.key_formatter.unformat(name)
617
+ end
618
+ end
619
+
620
+ def non_serializing_attributes
621
+ self.class.read_only_attributes
622
+ end
623
+
624
+ def attributes_for_serialization
625
+ attributes.except(*non_serializing_attributes).slice(*changed)
626
+ end
627
+
628
+ def relationships_for_serialization
629
+ relationships.as_json_api
630
+ end
631
+
632
+ def error_message_for(error)
633
+ error.error_msg
634
+ end
635
+
636
+ def fill_errors
637
+ last_result_set.errors.each do |error|
638
+ key = self.class.key_formatter.unformat(error.error_key)
639
+ errors.add(key, error_message_for(error))
640
+ end
641
+ end
642
+ end
643
+ end