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