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
@@ -0,0 +1,112 @@
1
+ module FunWithJsonApi
2
+ class FindResourceFromDocument
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
+
12
+ def initialize(api_document, deserializer)
13
+ @api_document = api_document.deep_stringify_keys
14
+ @deserializer = deserializer
15
+ end
16
+
17
+ def find
18
+ raise build_invalid_document_error unless document_is_valid?
19
+
20
+ # Resource is being set to nil/null
21
+ return nil if document_is_null_resource?
22
+
23
+ # Ensure the document matches the expected resource
24
+ raise build_invalid_document_type_error unless document_matches_resource_type?
25
+
26
+ # Load resource from id value
27
+ deserializer.load_resource_from_id_value(document_id).tap do |resource|
28
+ raise build_missing_resource_error if resource.nil?
29
+ end
30
+ end
31
+
32
+ def document_id
33
+ @document_id ||= api_document['data']['id']
34
+ end
35
+
36
+ def document_type
37
+ @document_type ||= api_document['data']['type']
38
+ end
39
+
40
+ def resource_type
41
+ @resource_type ||= deserializer.type
42
+ end
43
+
44
+ def document_is_valid?
45
+ api_document.key?('data') && (
46
+ api_document['data'].is_a?(Hash) || document_is_null_resource?
47
+ )
48
+ end
49
+
50
+ def document_is_null_resource?
51
+ api_document['data'].nil?
52
+ end
53
+
54
+ def document_matches_resource_type?
55
+ resource_type == document_type
56
+ end
57
+
58
+ private
59
+
60
+ def build_invalid_document_error
61
+ payload = ExceptionPayload.new
62
+ payload.pointer = '/data'
63
+ payload.detail = document_is_invalid_message
64
+ Exceptions::InvalidDocument.new(
65
+ "Expected root data element with hash or null: #{api_document.inspect}",
66
+ payload
67
+ )
68
+ end
69
+
70
+ def build_invalid_document_type_error
71
+ message = "'#{document_type}' did not match expected resource type: '#{resource_type}'"
72
+ payload = ExceptionPayload.new(
73
+ detail: document_type_does_not_match_endpoint_message
74
+ )
75
+ Exceptions::InvalidDocumentType.new(message, payload)
76
+ end
77
+
78
+ def build_missing_resource_error
79
+ deserializer_name = deserializer.class.name || 'Deserializer'
80
+ message = "#{deserializer_name} was unable to find resource by '#{deserializer.id_param}'"\
81
+ ": '#{document_id}'"
82
+ payload = ExceptionPayload.new
83
+ payload.pointer = '/data/id'
84
+ payload.detail = missing_resource_message
85
+ Exceptions::MissingResource.new(message, payload)
86
+ end
87
+
88
+ def document_is_invalid_message
89
+ I18n.t(
90
+ :invalid_document,
91
+ scope: 'fun_with_json_api.find_resource_from_document'
92
+ )
93
+ end
94
+
95
+ def document_type_does_not_match_endpoint_message
96
+ I18n.t(
97
+ :invalid_document_type,
98
+ resource: resource_type,
99
+ scope: 'fun_with_json_api.find_resource_from_document'
100
+ )
101
+ end
102
+
103
+ def missing_resource_message
104
+ I18n.t(
105
+ :missing_resource,
106
+ resource: resource_type,
107
+ resource_id: document_id,
108
+ scope: 'fun_with_json_api.find_resource_from_document'
109
+ )
110
+ end
111
+ end
112
+ end
@@ -1,4 +1,5 @@
1
1
  require 'active_model_serializers'
2
+ require 'fun_with_json_api/deserializer_config_builder'
2
3
 
3
4
  module FunWithJsonApi
4
5
  # Converts a json_api document into a rails compatible hash.
@@ -1,9 +1,38 @@
1
1
  require 'fun_with_json_api/controller_methods'
2
+ require 'fun_with_json_api/action_controller_extensions/serialization'
3
+
4
+ Mime::Type.register FunWithJsonApi::MEDIA_TYPE, :json_api
2
5
 
3
6
  module FunWithJsonApi
4
7
  # Mountable engine for fun with json_api
5
8
  class Railtie < Rails::Railtie
6
- initializer 'fun_with_json_api.add_locales' do
9
+ initializer :register_json_api_mime_type do
10
+ parsers =
11
+ if Rails::VERSION::MAJOR >= 5
12
+ ActionDispatch::Http::Parameters
13
+ else
14
+ ActionDispatch::ParamsParser
15
+ end
16
+
17
+ parsers::DEFAULT_PARSERS[Mime::Type.lookup(FunWithJsonApi::MEDIA_TYPE)] = lambda do |body|
18
+ data = JSON.parse(body)
19
+ data = { _json: data } unless data.is_a?(Hash)
20
+ data.with_indifferent_access
21
+ end
22
+ end
23
+ initializer :register_json_api_renderer do
24
+ ActionController::Renderers.add :json_api do |json, options|
25
+ json = json.to_json(options) unless json.is_a?(String)
26
+ self.content_type ||= Mime::Type.lookup(FunWithJsonApi::MEDIA_TYPE)
27
+ json
28
+ end
29
+ end
30
+ initializer :register_json_api_serializers do
31
+ ActiveSupport.on_load(:action_controller) do
32
+ include(FunWithJsonApi::ActionControllerExtensions::Serialization)
33
+ end
34
+ end
35
+ initializer :add_json_api_locales do
7
36
  translations = File.expand_path('../../../config/locales/**/*.{rb,yml}', __FILE__)
8
37
  Dir.glob(translations) { |f| config.i18n.load_path << f.to_s }
9
38
  end
@@ -0,0 +1,47 @@
1
+ require 'fun_with_json_api/exception'
2
+
3
+ module FunWithJsonApi
4
+ class SchemaValidator
5
+ def self.check(api_document, deserializer, resource)
6
+ new(api_document, deserializer, resource).check
7
+ end
8
+
9
+ private_class_method :new
10
+
11
+ attr_reader :api_document
12
+ attr_reader :deserializer
13
+ attr_reader :resource
14
+
15
+ def initialize(api_document, deserializer, resource)
16
+ @api_document = api_document.deep_stringify_keys
17
+ @deserializer = deserializer
18
+ @resource = resource
19
+ end
20
+
21
+ def check
22
+ FunWithJsonApi::SchemaValidators::CheckDocumentTypeMatchesResource.call(self)
23
+ FunWithJsonApi::SchemaValidators::CheckDocumentIdMatchesResource.call(self)
24
+ FunWithJsonApi::SchemaValidators::CheckAttributes.call(api_document, deserializer)
25
+ FunWithJsonApi::SchemaValidators::CheckRelationships.call(api_document, deserializer)
26
+ end
27
+
28
+ def document_id
29
+ @document_id ||= api_document['data']['id']
30
+ end
31
+
32
+ def document_type
33
+ @document_type ||= api_document['data']['type']
34
+ end
35
+
36
+ def resource_id
37
+ @resource_id ||= resource.send(deserializer.id_param).to_s
38
+ end
39
+
40
+ def resource_type
41
+ @resource_type ||= deserializer.type
42
+ end
43
+ end
44
+ end
45
+
46
+ # Load known Schema Validators
47
+ Dir["#{File.dirname(__FILE__)}/schema_validators/**/*.rb"].each { |f| require f }
@@ -0,0 +1,52 @@
1
+ module FunWithJsonApi
2
+ module SchemaValidators
3
+ class CheckAttributes
4
+ def self.call(api_document, deserializer)
5
+ new(api_document, deserializer).call
6
+ end
7
+
8
+ attr_reader :api_document
9
+ attr_reader :deserializer
10
+
11
+ def initialize(api_document, deserializer)
12
+ @api_document = api_document
13
+ @deserializer = deserializer
14
+ end
15
+
16
+ def call
17
+ attributes = api_document['data'].fetch('attributes', {}).keys
18
+ unknown = attributes.reject { |attribute| resource_attributes.include?(attribute) }
19
+
20
+ return true if unknown.empty?
21
+
22
+ raise build_unknown_attribute_error(unknown)
23
+ end
24
+
25
+ def resource_attributes
26
+ @resource_attributes ||= deserializer.attributes.map(&:name).map(&:to_s)
27
+ end
28
+
29
+ private
30
+
31
+ def build_unknown_attribute_error(unknown_attributes)
32
+ payload = unknown_attributes.map do |attribute|
33
+ ExceptionPayload.new(
34
+ detail: unknown_attribute_error(attribute),
35
+ pointer: "/data/attributes/#{attribute}"
36
+ )
37
+ end
38
+ message = 'Unknown attributes were provided by endpoint'
39
+ FunWithJsonApi::Exceptions::UnknownAttribute.new(message, payload)
40
+ end
41
+
42
+ def unknown_attribute_error(attribute)
43
+ I18n.t(
44
+ :unknown_attribute_for_resource,
45
+ attribute: attribute,
46
+ resource: deserializer.type,
47
+ scope: 'fun_with_json_api.schema_validators'
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,96 @@
1
+ module FunWithJsonApi
2
+ module SchemaValidators
3
+ class CheckDocumentIdMatchesResource
4
+ def self.call(schema_validator)
5
+ new(schema_validator).call
6
+ end
7
+
8
+ attr_reader :schema_validator
9
+ delegate :resource,
10
+ :document_id,
11
+ :resource_id,
12
+ :resource_type,
13
+ :deserializer,
14
+ to: :schema_validator
15
+
16
+ def initialize(schema_validator)
17
+ @schema_validator = schema_validator
18
+ end
19
+
20
+ def call
21
+ if resource.try(:persisted?)
22
+ # Ensure correct update document is being sent
23
+ check_resource_id_matches_document_id
24
+ elsif document_id
25
+ # Ensure correct create document is being sent
26
+ check_resource_id_can_be_client_generated
27
+ check_resource_id_has_not_already_been_used
28
+ end
29
+ end
30
+
31
+ def check_resource_id_matches_document_id
32
+ if document_id != resource_id
33
+ message = "resource id '#{resource_id}' does not match the expected id for"\
34
+ " '#{resource_type}': '#{document_id}'"
35
+ payload = ExceptionPayload.new(
36
+ detail: document_id_does_not_match_resource_message
37
+ )
38
+ raise Exceptions::InvalidDocumentIdentifier.new(message, payload)
39
+ end
40
+ end
41
+
42
+ def check_resource_id_can_be_client_generated
43
+ # Ensure id has been provided as an attribute
44
+ if deserializer.attributes.none? { |attribute| attribute.name == :id }
45
+ deserializer_name = deserializer.class.name || 'Deserializer'
46
+ message = "id parameter for '#{resource_type}' cannot be set"\
47
+ " as it has not been defined as a #{deserializer_name} attribute"
48
+ payload = ExceptionPayload.new(
49
+ detail: resource_id_can_not_be_client_generated_message
50
+ )
51
+ raise Exceptions::IllegalClientGeneratedIdentifier.new(message, payload)
52
+ end
53
+ end
54
+
55
+ def check_resource_id_has_not_already_been_used
56
+ if (existing = deserializer.load_resource_from_id_value(document_id))
57
+ deserializer_class = deserializer.class.name || 'Deserializer'
58
+ message = "#{deserializer_class}#load_resource_from_id_value for '#{resource_type}' has"\
59
+ ' found a existing resource matching document id'\
60
+ ": #{existing.class.name}##{existing.id}"
61
+ payload = ExceptionPayload.new(
62
+ detail: resource_id_has_already_been_used_message
63
+ )
64
+ raise Exceptions::InvalidClientGeneratedIdentifier.new(message, payload)
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def document_id_does_not_match_resource_message
71
+ I18n.t(
72
+ :document_id_does_not_match_resource,
73
+ expected: resource_id,
74
+ scope: 'fun_with_json_api.schema_validators'
75
+ )
76
+ end
77
+
78
+ def resource_id_can_not_be_client_generated_message
79
+ I18n.t(
80
+ :resource_id_can_not_be_client_generated,
81
+ resource: resource_type,
82
+ scope: 'fun_with_json_api.schema_validators'
83
+ )
84
+ end
85
+
86
+ def resource_id_has_already_been_used_message
87
+ I18n.t(
88
+ :resource_id_has_already_been_assigned,
89
+ id: document_id,
90
+ resource: resource_type,
91
+ scope: 'fun_with_json_api.schema_validators'
92
+ )
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,40 @@
1
+ module FunWithJsonApi
2
+ module SchemaValidators
3
+ class CheckDocumentTypeMatchesResource
4
+ def self.call(schema_validator)
5
+ new(schema_validator).call
6
+ end
7
+
8
+ attr_reader :schema_validator
9
+ delegate :document_type,
10
+ :resource_type,
11
+ :deserializer,
12
+ to: :schema_validator
13
+
14
+ def initialize(schema_validator)
15
+ @schema_validator = schema_validator
16
+ end
17
+
18
+ def call
19
+ if document_type != resource_type
20
+ message = "'#{document_type}' does not match the expected resource"\
21
+ ": #{resource_type}"
22
+ payload = ExceptionPayload.new(
23
+ detail: document_type_does_not_match_endpoint_message
24
+ )
25
+ raise Exceptions::InvalidDocumentType.new(message, payload)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def document_type_does_not_match_endpoint_message
32
+ I18n.t(
33
+ :document_type_does_not_match_endpoint,
34
+ expected: resource_type,
35
+ scope: 'fun_with_json_api.schema_validators'
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,127 @@
1
+ module FunWithJsonApi
2
+ module SchemaValidators
3
+ class CheckRelationships
4
+ def self.call(api_document, deserializer)
5
+ new(api_document, deserializer).call
6
+ end
7
+
8
+ attr_reader :api_document
9
+ attr_reader :deserializer
10
+
11
+ def initialize(api_document, deserializer)
12
+ @api_document = api_document
13
+ @deserializer = deserializer
14
+ end
15
+
16
+ def call
17
+ relationships = api_document['data'].fetch('relationships', {})
18
+
19
+ check_for_unknown_relationships! relationships.keys
20
+ check_for_invalid_relationship_type! relationships
21
+
22
+ true
23
+ end
24
+
25
+ def check_for_unknown_relationships!(relationship_keys)
26
+ unknown = relationship_keys.reject { |rel| resource_relationships.include?(rel) }
27
+ return if unknown.empty?
28
+
29
+ raise build_unknown_relationship_error(unknown)
30
+ end
31
+
32
+ def check_for_invalid_relationship_type!(relationships_hash)
33
+ payload = build_invalid_relationship_type_payload(relationships_hash)
34
+ return if payload.empty?
35
+
36
+ message = 'A relationship received data with an incorrect type'
37
+ raise FunWithJsonApi::Exceptions::InvalidRelationshipType.new(message, payload)
38
+ end
39
+
40
+ def check_for_invalid_relationship_type_in_collection!(relationship, collection_data)
41
+ return unless collection_data.is_a?(Array)
42
+
43
+ collection_data.each_with_index.map do |item, index|
44
+ next if item['type'] == relationship.type
45
+
46
+ build_invalid_collection_item_payload(relationship, index)
47
+ end
48
+ end
49
+
50
+ def check_for_invalid_relationship_type_in_relationship!(relationship, relationship_data)
51
+ return unless relationship_data.is_a?(Hash)
52
+ return if relationship_data['type'] == relationship.type
53
+
54
+ build_invalid_relationship_item_payload(relationship)
55
+ end
56
+
57
+ def resource_relationships
58
+ @resource_relationships ||= deserializer.relationships.map(&:name).map(&:to_s)
59
+ end
60
+
61
+ private
62
+
63
+ def invalid_relationship_type_in_array_message(relationship)
64
+ I18n.t(
65
+ :invalid_relationship_type_in_array,
66
+ relationship: relationship.name,
67
+ relationship_type: relationship.type,
68
+ scope: 'fun_with_json_api.schema_validators'
69
+ )
70
+ end
71
+
72
+ def invalid_relationship_type_in_hash_message(relationship)
73
+ I18n.t(
74
+ :invalid_relationship_type_in_hash,
75
+ relationship: relationship.name,
76
+ relationship_type: relationship.type,
77
+ scope: 'fun_with_json_api.schema_validators'
78
+ )
79
+ end
80
+
81
+ def build_invalid_relationship_type_payload(relationships_hash)
82
+ deserializer.relationships.map do |relationship|
83
+ data = relationships_hash.fetch(relationship.name.to_s)['data']
84
+ if relationship.has_many?
85
+ check_for_invalid_relationship_type_in_collection!(relationship, data)
86
+ else
87
+ check_for_invalid_relationship_type_in_relationship!(relationship, data)
88
+ end
89
+ end.flatten.compact
90
+ end
91
+
92
+ def build_invalid_collection_item_payload(relationship, index)
93
+ ExceptionPayload.new(
94
+ detail: invalid_relationship_type_in_array_message(relationship),
95
+ pointer: "/data/relationships/#{relationship.name}/#{index}/type"
96
+ )
97
+ end
98
+
99
+ def build_invalid_relationship_item_payload(relationship)
100
+ ExceptionPayload.new(
101
+ detail: invalid_relationship_type_in_hash_message(relationship),
102
+ pointer: "/data/relationships/#{relationship.name}/type"
103
+ )
104
+ end
105
+
106
+ def build_unknown_relationship_error(unknown_relationships)
107
+ payload = unknown_relationships.map do |relationship|
108
+ ExceptionPayload.new(
109
+ detail: unknown_relationship_error(relationship),
110
+ pointer: "/data/relationships/#{relationship}"
111
+ )
112
+ end
113
+ message = 'Unknown relationships were provided by endpoint'
114
+ FunWithJsonApi::Exceptions::UnknownRelationship.new(message, payload)
115
+ end
116
+
117
+ def unknown_relationship_error(relationship)
118
+ I18n.t(
119
+ :unknown_relationship_for_resource,
120
+ relationship: relationship,
121
+ resource: deserializer.type,
122
+ scope: 'fun_with_json_api.schema_validators'
123
+ )
124
+ end
125
+ end
126
+ end
127
+ end