fun_with_json_api 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
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