fun_with_json_api 0.0.2 → 0.0.3

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +4 -1
  3. data/config/locales/fun_with_json_api.en.yml +29 -2
  4. data/lib/fun_with_json_api.rb +30 -2
  5. data/lib/fun_with_json_api/action_controller_extensions/serialization.rb +18 -0
  6. data/lib/fun_with_json_api/attribute.rb +3 -3
  7. data/lib/fun_with_json_api/attributes/relationship.rb +37 -23
  8. data/lib/fun_with_json_api/attributes/relationship_collection.rb +55 -38
  9. data/lib/fun_with_json_api/attributes/string_attribute.rb +12 -1
  10. data/lib/fun_with_json_api/attributes/uuid_v4_attribute.rb +27 -0
  11. data/lib/fun_with_json_api/controller_methods.rb +1 -1
  12. data/lib/fun_with_json_api/deserializer.rb +61 -8
  13. data/lib/fun_with_json_api/deserializer_class_methods.rb +37 -7
  14. data/lib/fun_with_json_api/exceptions/illegal_client_generated_identifier.rb +17 -0
  15. data/lib/fun_with_json_api/exceptions/invalid_client_generated_identifier.rb +17 -0
  16. data/lib/fun_with_json_api/exceptions/invalid_document_identifier.rb +17 -0
  17. data/lib/fun_with_json_api/exceptions/invalid_document_type.rb +20 -0
  18. data/lib/fun_with_json_api/exceptions/invalid_relationship.rb +5 -3
  19. data/lib/fun_with_json_api/exceptions/invalid_relationship_type.rb +17 -0
  20. data/lib/fun_with_json_api/exceptions/missing_resource.rb +15 -0
  21. data/lib/fun_with_json_api/exceptions/unknown_attribute.rb +15 -0
  22. data/lib/fun_with_json_api/exceptions/unknown_relationship.rb +15 -0
  23. data/lib/fun_with_json_api/find_collection_from_document.rb +124 -0
  24. data/lib/fun_with_json_api/find_resource_from_document.rb +112 -0
  25. data/lib/fun_with_json_api/pre_deserializer.rb +1 -0
  26. data/lib/fun_with_json_api/railtie.rb +30 -1
  27. data/lib/fun_with_json_api/schema_validator.rb +47 -0
  28. data/lib/fun_with_json_api/schema_validators/check_attributes.rb +52 -0
  29. data/lib/fun_with_json_api/schema_validators/check_document_id_matches_resource.rb +96 -0
  30. data/lib/fun_with_json_api/schema_validators/check_document_type_matches_resource.rb +40 -0
  31. data/lib/fun_with_json_api/schema_validators/check_relationships.rb +127 -0
  32. data/lib/fun_with_json_api/version.rb +1 -1
  33. data/spec/dummy/log/test.log +172695 -0
  34. data/spec/fixtures/active_record.rb +6 -0
  35. data/spec/fun_with_json_api/controller_methods_spec.rb +8 -3
  36. data/spec/fun_with_json_api/deserializer_class_methods_spec.rb +14 -6
  37. data/spec/fun_with_json_api/deserializer_spec.rb +155 -40
  38. data/spec/fun_with_json_api/exception_spec.rb +9 -9
  39. data/spec/fun_with_json_api/find_collection_from_document_spec.rb +203 -0
  40. data/spec/fun_with_json_api/find_resource_from_document_spec.rb +100 -0
  41. data/spec/fun_with_json_api/pre_deserializer_spec.rb +26 -26
  42. data/spec/fun_with_json_api/railtie_spec.rb +88 -0
  43. data/spec/fun_with_json_api/schema_validator_spec.rb +94 -0
  44. data/spec/fun_with_json_api/schema_validators/check_attributes_spec.rb +52 -0
  45. data/spec/fun_with_json_api/schema_validators/check_document_id_matches_resource_spec.rb +115 -0
  46. data/spec/fun_with_json_api/schema_validators/check_document_type_matches_resource_spec.rb +30 -0
  47. data/spec/fun_with_json_api/schema_validators/check_relationships_spec.rb +150 -0
  48. data/spec/fun_with_json_api_spec.rb +148 -4
  49. metadata +49 -4
  50. data/spec/example_spec.rb +0 -64
@@ -3,7 +3,7 @@ require 'fun_with_json_api/exception_serializer'
3
3
  module FunWithJsonApi
4
4
  module ControllerMethods
5
5
  def render_fun_with_json_api_exception(exception)
6
- render json: exception,
6
+ render json_api: exception,
7
7
  serializer: FunWithJsonApi::ExceptionSerializer,
8
8
  adapter: :json,
9
9
  status: exception.http_status
@@ -19,14 +19,28 @@ module FunWithJsonApi
19
19
  attr_reader :resource_class
20
20
 
21
21
  attr_reader :attributes
22
- attr_reader :relationships
23
22
 
24
23
  def initialize(options = {})
25
24
  @id_param = options.fetch(:id_param) { self.class.id_param }
26
- @type = options[:type]
25
+ @type = options.fetch(:type) { self.class.type }
27
26
  @resource_class = options[:resource_class]
28
- @attributes = filter_attributes_by_name(options[:attributes], self.class.attributes)
29
- @relationships = filter_attributes_by_name(options[:relationships], self.class.relationships)
27
+ @resource_collection = options[:resource_collection] if @type
28
+ load_attributes_from_options(options)
29
+ load_relationships_from_options(options)
30
+ end
31
+
32
+ # Loads a collection of of `resource_class` instances with `id_param` matching `id_values`
33
+ def load_collection_from_id_values(id_values)
34
+ resource_collection.where(id_param => id_values)
35
+ end
36
+
37
+ def format_collection_ids(collection)
38
+ collection.map { |resource| resource.public_send(id_param).to_s }
39
+ end
40
+
41
+ # Loads a single instance of `resource_class` with a `id_param` matching `id_value`
42
+ def load_resource_from_id_value(id_value)
43
+ resource_collection.find_by(id_param => id_value)
30
44
  end
31
45
 
32
46
  # Takes a parsed params hash from ActiveModelSerializers::Deserialization and sanitizes values
@@ -37,16 +51,47 @@ module FunWithJsonApi
37
51
  ]
38
52
  end
39
53
 
40
- def type
41
- @type ||= self.class.type
42
- end
43
-
44
54
  def resource_class
45
55
  @resource_class ||= self.class.resource_class
46
56
  end
47
57
 
58
+ def resource_collection
59
+ @resource_collection ||= resource_class
60
+ end
61
+
62
+ def relationships
63
+ relationship_lookup.values
64
+ end
65
+
66
+ def relationship_for(resource_name)
67
+ relationship_lookup.fetch(resource_name)
68
+ end
69
+
48
70
  private
49
71
 
72
+ attr_reader :relationship_lookup
73
+
74
+ def load_attributes_from_options(options)
75
+ @attributes = filter_attributes_by_name(options[:attributes], self.class.attributes)
76
+ end
77
+
78
+ def load_relationships_from_options(options = {})
79
+ options_config = {}
80
+
81
+ # Filter resources and build an options hash for each
82
+ filter_relationships_by_name(
83
+ options[:relationships], self.class.relationship_names
84
+ ).each do |relationship|
85
+ options_config[relationship] = options.fetch(relationship, {})
86
+ end
87
+
88
+ # Build the relationships and store them into a lookup hash
89
+ @relationship_lookup = {}
90
+ self.class.build_relationships(options_config).each do |relationship|
91
+ @relationship_lookup[relationship.name] = relationship
92
+ end
93
+ end
94
+
50
95
  def filter_attributes_by_name(attribute_names, attributes)
51
96
  if attribute_names
52
97
  attributes.keep_if { |attribute| attribute_names.include?(attribute.name) }
@@ -55,6 +100,14 @@ module FunWithJsonApi
55
100
  end
56
101
  end
57
102
 
103
+ def filter_relationships_by_name(relationship_names, relationships)
104
+ if relationship_names
105
+ relationships.keep_if { |relationship| relationship_names.include?(relationship) }
106
+ else
107
+ relationships
108
+ end
109
+ end
110
+
58
111
  # Calls <attribute.as> on the current instance, override the #<as> method to change loading
59
112
  def serialize_attribute_values(attributes, params)
60
113
  attributes.select { |attribute| params.key?(attribute.param_value) }
@@ -3,9 +3,13 @@ require 'fun_with_json_api/attribute'
3
3
  module FunWithJsonApi
4
4
  # Provides a basic DSL for defining a FunWithJsonApi::Deserializer
5
5
  module DeserializerClassMethods
6
- def id_param(id_param = nil)
7
- @id_param = id_param if id_param
8
- @id_param || :id
6
+ def id_param(id_param = nil, format: false)
7
+ @id_param = id_param.to_sym if id_param
8
+ (@id_param || :id).tap do |param|
9
+ if format
10
+ attribute(:id, as: param, format: format) # Create a new id attribute
11
+ end
12
+ end
9
13
  end
10
14
 
11
15
  def type(type = nil)
@@ -39,7 +43,7 @@ module FunWithJsonApi
39
43
  deserializer_class_or_callable,
40
44
  options
41
45
  ).tap do |relationship|
42
- add_parse_attribute_method(relationship)
46
+ add_parse_resource_method(relationship)
43
47
  relationships << relationship
44
48
  end
45
49
  end
@@ -52,26 +56,52 @@ module FunWithJsonApi
52
56
  deserializer_class_or_callable,
53
57
  options
54
58
  ).tap do |relationship|
55
- add_parse_attribute_method(relationship)
59
+ add_parse_resource_method(relationship)
56
60
  relationships << relationship
57
61
  end
58
62
  end
59
63
 
60
64
  # rubocop:enable Style/PredicateName
61
65
 
62
- def relationships
63
- @relationships ||= []
66
+ def relationship_names
67
+ relationships.map(&:name)
68
+ end
69
+
70
+ def build_relationships(options)
71
+ options.map do |name, relationship_options|
72
+ relationship = relationships.detect { |rel| rel.name == name }
73
+ relationship.class.create(
74
+ relationship.name,
75
+ relationship.deserializer_class,
76
+ relationship_options.reverse_merge(relationship.options)
77
+ )
78
+ end
64
79
  end
65
80
 
66
81
  private
67
82
 
83
+ def relationships
84
+ @relationships ||= []
85
+ end
86
+
68
87
  def add_parse_attribute_method(attribute)
69
88
  define_method(attribute.sanitize_attribute_method) do |param_value|
70
89
  attribute.call(param_value)
71
90
  end
72
91
  end
73
92
 
93
+ def add_parse_resource_method(resource)
94
+ define_method(resource.sanitize_attribute_method) do |param_value|
95
+ relationship_for(resource.name).call(param_value)
96
+ end
97
+ end
98
+
74
99
  def type_from_class_name
100
+ if name.nil?
101
+ Rails.logger.warn 'Unable to determine type for anonymous Deserializer'
102
+ return nil
103
+ end
104
+
75
105
  resource_class_name = name.demodulize.sub(/Deserializer/, '').underscore
76
106
  if ActiveModelSerializers.config.jsonapi_resource_type == :singular
77
107
  resource_class_name.singularize
@@ -0,0 +1,17 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # A server MUST return 403 Forbidden in response to an unsupported request to create a resource
4
+ # with a client-generated ID.
5
+ class IllegalClientGeneratedIdentifier < FunWithJsonApi::Exception
6
+ EXCEPTION_CODE = 'illegal_client_generated_identifier'.freeze
7
+
8
+ def initialize(message, payload = ExceptionPayload.new)
9
+ payload.code ||= EXCEPTION_CODE
10
+ payload.title ||= I18n.t(EXCEPTION_CODE, scope: 'fun_with_json_api.exceptions')
11
+ payload.status ||= '403'
12
+ payload.pointer ||= '/data/id'
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # A server MUST return 409 Conflict when processing a POST request to create a resource with a
4
+ # client-generated ID that already exists.
5
+ class InvalidClientGeneratedIdentifier < FunWithJsonApi::Exception
6
+ EXCEPTION_CODE = 'invalid_client_generated_identifier'.freeze
7
+
8
+ def initialize(message, payload = ExceptionPayload.new)
9
+ payload.code ||= EXCEPTION_CODE
10
+ payload.title ||= I18n.t(EXCEPTION_CODE, scope: 'fun_with_json_api.exceptions')
11
+ payload.status ||= '409'
12
+ payload.pointer ||= '/data/id'
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # A server MUST return 409 Conflict when processing a PATCH request in which the resource
4
+ # object's type and id do not match the server's endpoint
5
+ class InvalidDocumentIdentifier < FunWithJsonApi::Exception
6
+ EXCEPTION_CODE = 'invalid_document_identifier'.freeze
7
+
8
+ def initialize(message, payload = ExceptionPayload.new)
9
+ payload.code ||= EXCEPTION_CODE
10
+ payload.title ||= I18n.t(EXCEPTION_CODE, scope: 'fun_with_json_api.exceptions')
11
+ payload.status ||= '409'
12
+ payload.pointer ||= '/data/id'
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # A server MUST return 409 Conflict when processing a POST request in which the resource
4
+ # object's type is not among the type(s) that constitute the collection represented by the
5
+ # endpoint.
6
+ class InvalidDocumentType < FunWithJsonApi::Exception
7
+ EXCEPTION_CODE = 'invalid_document_type'.freeze
8
+
9
+ def initialize(message, payload = ExceptionPayload.new)
10
+ payload = Array.wrap(payload).each do |invalid|
11
+ invalid.code ||= EXCEPTION_CODE
12
+ invalid.title ||= I18n.t(EXCEPTION_CODE, scope: 'fun_with_json_api.exceptions')
13
+ invalid.status ||= '409'
14
+ invalid.pointer ||= '/data/type'
15
+ end
16
+ super
17
+ end
18
+ end
19
+ end
20
+ end
@@ -3,9 +3,11 @@ module FunWithJsonApi
3
3
  # Indicates a Supplied relationships value is not formatted correctly
4
4
  class InvalidRelationship < FunWithJsonApi::Exception
5
5
  def initialize(message, payload = ExceptionPayload.new)
6
- payload.code ||= 'invalid_relationship'
7
- payload.title ||= I18n.t(:invalid_relationship, scope: 'fun_with_json_api.exceptions')
8
- payload.status ||= '400'
6
+ Array.wrap(payload).each do |invalid|
7
+ invalid.code ||= 'invalid_relationship'
8
+ invalid.title ||= I18n.t(:invalid_relationship, scope: 'fun_with_json_api.exceptions')
9
+ invalid.status ||= '400'
10
+ end
9
11
  super
10
12
  end
11
13
  end
@@ -0,0 +1,17 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # Indicates a supplied relationships type does match expected values
4
+ class InvalidRelationshipType < FunWithJsonApi::Exception
5
+ ERROR_CODE = 'invalid_relationship_type'.freeze
6
+
7
+ def initialize(message, payload = ExceptionPayload.new)
8
+ Array.wrap(payload).each do |invalid|
9
+ invalid.code ||= ERROR_CODE
10
+ invalid.title ||= I18n.t(ERROR_CODE, scope: 'fun_with_json_api.exceptions')
11
+ invalid.status ||= '409'
12
+ end
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # Indicates a Resource or Collection item was not able to be found
4
+ class MissingResource < FunWithJsonApi::Exception
5
+ def initialize(message, payload = ExceptionPayload.new)
6
+ payload = Array.wrap(payload).each do |missing|
7
+ missing.code ||= 'missing_resource'
8
+ missing.title ||= I18n.t('missing_resource', scope: 'fun_with_json_api.exceptions')
9
+ missing.status ||= '404'
10
+ end
11
+ super
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # Indicates a supplied attribute value is unknown to the current deserializer
4
+ class UnknownAttribute < FunWithJsonApi::Exception
5
+ def initialize(message, payload = ExceptionPayload.new)
6
+ payload = Array.wrap(payload).each do |unknown|
7
+ unknown.code ||= 'unknown_attribute'
8
+ unknown.title ||= I18n.t(:unknown_attribute, scope: 'fun_with_json_api.exceptions')
9
+ unknown.status ||= '422'
10
+ end
11
+ super
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # Indicates a supplied relationship value is unknown to the current deserializer
4
+ class UnknownRelationship < FunWithJsonApi::Exception
5
+ def initialize(message, payload = ExceptionPayload.new)
6
+ payload = Array.wrap(payload).each do |unknown|
7
+ unknown.code ||= 'unknown_relationship'
8
+ unknown.title ||= I18n.t(:unknown_relationship, scope: 'fun_with_json_api.exceptions')
9
+ unknown.status ||= '422'
10
+ end
11
+ super
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,124 @@
1
+ module FunWithJsonApi
2
+ class FindCollectionFromDocument
3
+ def self.find(*args)
4
+ new(*args).find
5
+ end
6
+
7
+ private_class_method :new
8
+
9
+ attr_reader :api_document
10
+ attr_reader :deserializer
11
+ delegate :id_param, :id_param, :resource_class, to: :deserializer
12
+
13
+ def initialize(api_document, deserializer)
14
+ @api_document = api_document.deep_stringify_keys
15
+ @deserializer = deserializer
16
+ end
17
+
18
+ def find
19
+ raise build_invalid_document_error unless document_is_valid_collection?
20
+
21
+ # Skip the checks, no point running through them for an empty array
22
+ return [] if document_ids.empty?
23
+
24
+ # Ensure the document matches the expected resource
25
+ check_document_types_match_deserializer!
26
+
27
+ # Load resource from id value
28
+ deserializer.load_collection_from_id_values(document_ids).tap do |collection|
29
+ check_collection_contains_all_requested_resources!(collection)
30
+ end
31
+ end
32
+
33
+ def document_ids
34
+ @document_id ||= api_document['data'].map { |item| item['id'] }
35
+ end
36
+
37
+ def document_types
38
+ @document_type ||= api_document['data'].map { |item| item['type'] }.uniq
39
+ end
40
+
41
+ def resource_type
42
+ @resource_type ||= deserializer.type
43
+ end
44
+
45
+ def document_is_valid_collection?
46
+ api_document.key?('data') && api_document['data'].is_a?(Array)
47
+ end
48
+
49
+ private
50
+
51
+ def check_collection_contains_all_requested_resources!(collection)
52
+ if collection.size != document_ids.size
53
+ collection_ids = deserializer.format_collection_ids(collection)
54
+ raise build_missing_resources_error(collection_ids)
55
+ end
56
+ end
57
+
58
+ def check_document_types_match_deserializer!
59
+ invalid_document_types = document_types.reject { |type| type == resource_type }
60
+ raise build_invalid_document_types_error if invalid_document_types.any?
61
+ end
62
+
63
+ def build_invalid_document_error
64
+ payload = ExceptionPayload.new
65
+ payload.pointer = '/data'
66
+ payload.detail = 'Expected data to be an Array of resources'
67
+ Exceptions::InvalidDocument.new(
68
+ "Expected root data element with an Array: #{api_document.inspect}",
69
+ payload
70
+ )
71
+ end
72
+
73
+ def build_invalid_document_types_error
74
+ message = 'Expected type for each item to match expected resource type'\
75
+ ": '#{resource_type}'"
76
+ payload = api_document['data'].each_with_index.map do |data, index|
77
+ next if data['type'] == resource_type
78
+ ExceptionPayload.new(
79
+ pointer: "/data/#{index}/type",
80
+ detail: document_type_does_not_match_endpoint_message(data['type'])
81
+ )
82
+ end.reject(&:nil?)
83
+ Exceptions::InvalidDocumentType.new(message, payload)
84
+ end
85
+
86
+ def build_missing_resources_error(collection_ids)
87
+ payload = document_ids.each_with_index.map do |resource_id, index|
88
+ build_missing_resource_payload(collection_ids, resource_id, index)
89
+ end.reject(&:nil?)
90
+
91
+ missing_values = document_ids.reject { |value| collection_ids.include?(value.to_s) }
92
+ message = "Couldn't find #{resource_class} items with "\
93
+ "#{id_param} in #{missing_values.inspect}"
94
+ Exceptions::MissingResource.new(message, payload)
95
+ end
96
+
97
+ def build_missing_resource_payload(collection_ids, resource_id, index)
98
+ unless collection_ids.include?(resource_id)
99
+ ExceptionPayload.new(
100
+ pointer: "/data/#{index}/id",
101
+ detail: missing_resource_message(resource_id)
102
+ )
103
+ end
104
+ end
105
+
106
+ def document_type_does_not_match_endpoint_message(type)
107
+ I18n.t(
108
+ :invalid_document_type,
109
+ type: type,
110
+ resource: resource_type,
111
+ scope: 'fun_with_json_api.find_collection_from_document'
112
+ )
113
+ end
114
+
115
+ def missing_resource_message(resource_id)
116
+ I18n.t(
117
+ :missing_resource,
118
+ resource: resource_type,
119
+ resource_id: resource_id,
120
+ scope: 'fun_with_json_api.find_collection_from_document'
121
+ )
122
+ end
123
+ end
124
+ end