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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: b39d90754d8cde854f6cd239634dd7415ff190b1
4
- data.tar.gz: c7b727d00f76668701739f0e3a7f6abaf1438fce
3
+ metadata.gz: cbb6fa5d08ff453c7adef23b0c5a608e98897228
4
+ data.tar.gz: 1cb7de4f0f03fc553af3da253fea06bf33704cbe
5
5
  SHA512:
6
- metadata.gz: b70869284cacc37b7915f8dc17d819184c722620e6350098f8ddfb22934cf15e6358c12ba455da2f3214f9c5969d7b1dfda0914eced2c9e0930675984d4284fe
7
- data.tar.gz: 4af352dff332ab0770a74eed228c53e081da20f32925532c4546b774a61c41ba360e0247cb3fc4a8550c97822e7daebc2552247c5274a487d89c96b83d7e63ad
6
+ metadata.gz: be8824b7d81fc131fe50591cb9ff83495407d17f053845279add97a6eeccd8a473dba036df315f45db3b7097d84c9105fc6d1228308d195edc488dbfde054a9f
7
+ data.tar.gz: 2d5c7af796c4224c4868a66bf876d447337304ac9616da5a254f0253e4563dd003700774187f2075ef76e6d28b12551974c00c20fb203d559fa15a23929506fd
data/Rakefile CHANGED
@@ -17,7 +17,10 @@ end
17
17
  Bundler::GemHelper.install_tasks
18
18
 
19
19
  require 'rubocop/rake_task'
20
- RuboCop::RakeTask.new
20
+ require 'rubocop-rspec'
21
+ RuboCop::RakeTask.new do |task|
22
+ task.requires << 'rubocop-rspec'
23
+ end
21
24
 
22
25
  require 'rspec/core'
23
26
  require 'rspec/core/rake_task'
@@ -2,12 +2,39 @@ en:
2
2
  fun_with_json_api:
3
3
  exceptions:
4
4
  invalid_document: 'Request json_api document is invalid'
5
- invalid_attribute: 'Request json_api document attribute is invalid'
6
- invalid_relationship: 'Request json_api document relationship is invalid'
5
+ invalid_document_identifier: 'Request json_api data id is invalid'
6
+ invalid_client_generated_identifier: 'Request json_api data id has already been used for an existing resource'
7
+ illegal_client_generated_identifier: 'Request json_api attempted to set an unsupported client-generated id'
8
+ invalid_document_type: 'Request json_api data type does not match endpoint'
9
+ missing_resource: 'Unable to find the requested resource'
10
+ invalid_attribute: 'Request json_api attribute data is invalid'
11
+ unknown_attribute: 'Request json_api attribute is unsupported by the current endpoint'
12
+ invalid_relationship: 'Request json_api relationship data is invalid'
7
13
  missing_relationship: 'Unable to find the requested relationship'
14
+ unknown_relationship: 'Request json_api relationship is unsupported by the current endpoint'
15
+ invalid_relationship_type: 'Request json_api relationship type does not match expected resource'
8
16
  invalid_boolean_attribute: "Boolean value should only be true, false, or null"
9
17
  invalid_date_attribute: "Date value should be in the format YYYY-MM-DD"
10
18
  invalid_datetime_attribute: "Datetime value should be a ISO 8601 datetime"
11
19
  invalid_decimal_attribute: "Decimal value must be a decimal number (i.e. 123.45)"
12
20
  invalid_float_attribute: "Float value must be a floating point number (i.e. 123.45)"
13
21
  invalid_integer_attribute: "Integer value must be a integer number (i.e. 123)"
22
+ invalid_string_attribute: 'String value must be a JSON String (i.e. "Example")'
23
+ invalid_uuid_v4_attribute: 'UUID value must be RFC 4122 Version 4 UUID (i.e. "f47ac10b-58cc-4372-a567-0e02b2c3d479")'
24
+ schema_validators:
25
+ document_id_does_not_match_resource: "Expected data id to match resource at endpoint: %{expected}"
26
+ document_type_does_not_match_endpoint: "Expected data type to be a '%{expected}' resource"
27
+ invalid_relationship_type_in_array: "Expected '%{relationship}' relationship to be an Array of '%{relationship_type}' resource identifiers"
28
+ invalid_relationship_type_in_hash: "Expected '%{relationship}' relationship to be null or a '%{relationship_type}' resource identifier Hash"
29
+ resource_id_can_not_be_client_generated: "The current endpoint does not allow you to set an id for a new '%{resource}' resource"
30
+ resource_id_has_already_been_assigned: "The provided id for a new '%{resource}' resource has already been used by another resource: %{id}"
31
+ unknown_attribute_for_resource: "The provided attribute '%{attribute}' can not be assigned to a '%{resource}' resource from the current endpoint"
32
+ unknown_relationship_for_resource: "The provided relationship '%{relationship}' can not be assigned to a '%{resource}' resource from the current endpoint"
33
+ find_resource_from_document:
34
+ invalid_document: "Expected data to be a Hash or null"
35
+ invalid_document_type: "Expected data type to be a '%{resource}' resource"
36
+ missing_resource: "Unable to find '%{resource}' with matching id: '%{resource_id}'"
37
+ find_collection_from_document:
38
+ invalid_document: "Expected data to be a Array of '%{resource}' resources"
39
+ invalid_document_type: "Expected '%{type}' to be a '%{resource}' resource"
40
+ missing_resource: "Unable to find '%{resource}' with matching id: '%{resource_id}'"
@@ -3,22 +3,50 @@ require 'fun_with_json_api/attribute'
3
3
 
4
4
  require 'fun_with_json_api/pre_deserializer'
5
5
  require 'fun_with_json_api/deserializer'
6
- require 'fun_with_json_api/deserializer_config_builder'
6
+ require 'fun_with_json_api/schema_validator'
7
+ require 'fun_with_json_api/find_collection_from_document'
8
+ require 'fun_with_json_api/find_resource_from_document'
7
9
 
8
10
  # Makes working with JSON:API fun!
9
11
  module FunWithJsonApi
12
+ MEDIA_TYPE = 'application/vnd.api+json'.freeze
13
+
10
14
  module_function
11
15
 
12
- def deserialize(api_document, deserializer_class, options = {})
16
+ def deserialize(api_document, deserializer_class, resource = nil, options = {})
13
17
  # Prepare the deserializer and the expected config
14
18
  deserializer = deserializer_class.create(options)
15
19
 
16
20
  # Run through initial document structure validation and deserialization
17
21
  unfiltered = FunWithJsonApi::PreDeserializer.parse(api_document, deserializer)
18
22
 
23
+ # Check the document matches up with expected resource parameters
24
+ FunWithJsonApi::SchemaValidator.check(api_document, deserializer, resource)
25
+
19
26
  # Ensure document matches schema, and sanitize values
20
27
  deserializer.sanitize_params(unfiltered)
21
28
  end
29
+
30
+ def deserialize_resource(api_document, deserializer_class, resource, options = {})
31
+ raise ArgumentError, 'resource cannot be nil' if resource.nil?
32
+ deserialize(api_document, deserializer_class, resource, options)
33
+ end
34
+
35
+ def find_resource(api_document, deserializer_class, options = {})
36
+ # Prepare the deserializer for loading a resource
37
+ deserializer = deserializer_class.create(options.merge(attributes: [], relationships: []))
38
+
39
+ # Load the resource from the document id
40
+ FunWithJsonApi::FindResourceFromDocument.find(api_document, deserializer)
41
+ end
42
+
43
+ def find_collection(api_document, deserializer_class, options = {})
44
+ # Prepare the deserializer for loading a resource
45
+ deserializer = deserializer_class.create(options.merge(attributes: [], relationships: []))
46
+
47
+ # Load the collection from the document
48
+ FunWithJsonApi::FindCollectionFromDocument.find(api_document, deserializer)
49
+ end
22
50
  end
23
51
 
24
52
  require 'fun_with_json_api/railtie' if defined?(Rails)
@@ -0,0 +1,18 @@
1
+ module FunWithJsonApi
2
+ module ActionControllerExtensions
3
+ module Serialization
4
+ # Overrides the dynamic render json_api methods to use ActiveModelSerializer
5
+ [:_render_option_json_api, :_render_with_renderer_json_api].each do |renderer_method|
6
+ define_method renderer_method do |resource, options|
7
+ options.fetch(:adapter) { options[:adapter] ||= :json_api }
8
+ options.fetch(:serialization_context) do
9
+ options[:serialization_context] ||=
10
+ ActiveModelSerializers::SerializationContext.new(request)
11
+ end
12
+ serializable_resource = get_serializer(resource, options)
13
+ super(serializable_resource, options)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -16,8 +16,8 @@ module FunWithJsonApi
16
16
  def initialize(name, options = {})
17
17
  raise ArgumentError, 'name cannot be blank!' unless name.present?
18
18
 
19
- @name = name
20
- @as = options.fetch(:as, name)
19
+ @name = name.to_sym
20
+ @as = options.fetch(:as, name).to_sym
21
21
  end
22
22
 
23
23
  def call(value)
@@ -29,7 +29,7 @@ module FunWithJsonApi
29
29
  end
30
30
 
31
31
  def param_value
32
- as.to_sym
32
+ as
33
33
  end
34
34
  end
35
35
  end
@@ -10,18 +10,17 @@ module FunWithJsonApi
10
10
  new(name, deserializer_class_or_callable, options)
11
11
  end
12
12
 
13
- delegate :id_param,
14
- :type,
15
- :resource_class,
16
- to: :deserializer
13
+ attr_reader :deserializer_class
14
+ attr_reader :options
15
+ delegate :type, to: :deserializer
17
16
 
18
17
  def initialize(name, deserializer_class, options = {})
19
18
  super(name, options)
20
19
  @deserializer_class = deserializer_class
21
- end
22
-
23
- def deserializer
24
- @deserializer ||= create_deserializer_from_deserializer_class
20
+ @options = options.reverse_merge(
21
+ attributes: [],
22
+ relationships: []
23
+ )
25
24
  end
26
25
 
27
26
  def call(id_value)
@@ -29,44 +28,59 @@ module FunWithJsonApi
29
28
  raise build_invalid_relationship_error(id_value)
30
29
  end
31
30
 
32
- resource_class.find_by!(id_param => id_value).try(:id) if id_value
33
- rescue ActiveRecord::RecordNotFound => e
34
- raise convert_record_not_found_error(e, id_value)
31
+ resource = deserializer.load_resource_from_id_value(id_value)
32
+ return resource.id if resource
33
+
34
+ raise build_missing_relationship_error(id_value)
35
+ end
36
+
37
+ # rubocop:disable Style/PredicateName
38
+
39
+ def has_many?
40
+ false
35
41
  end
36
42
 
43
+ # rubocop:enable Style/PredicateName
44
+
37
45
  def param_value
38
46
  :"#{as}_id"
39
47
  end
40
48
 
49
+ def deserializer
50
+ @deserializer ||= build_deserializer_from_options
51
+ end
52
+
41
53
  private
42
54
 
43
- # Creates a new Deserializer from the deserializer class
44
- def create_deserializer_from_deserializer_class
55
+ def build_deserializer_from_options
45
56
  if @deserializer_class.respond_to?(:call)
46
57
  @deserializer_class.call
47
58
  else
48
59
  @deserializer_class
49
- end.create(
50
- attributes: [],
51
- relationships: []
52
- )
60
+ end.create(options)
53
61
  end
54
62
 
55
63
  def build_invalid_relationship_error(id_value)
56
- exception_message = "#{name} relationship should contain a single '#{type}' data hash"
64
+ exception_message = "#{name} relationship should contain a single '#{deserializer.type}'"\
65
+ ' data hash'
57
66
  payload = ExceptionPayload.new
58
67
  payload.pointer = "/data/relationships/#{name}"
59
68
  payload.detail = exception_message
60
69
  Exceptions::InvalidRelationship.new(exception_message + ": #{id_value.inspect}", payload)
61
70
  end
62
71
 
63
- def convert_record_not_found_error(exception, id_value)
72
+ def build_missing_relationship_error(id_value, message = nil)
73
+ message ||= missing_resource_debug_message(id_value)
64
74
  payload = ExceptionPayload.new
65
75
  payload.pointer = "/data/relationships/#{name}/id"
66
- payload.detail = "Unable to find '#{type}' with matching id: #{id_value.inspect}"
67
- exception_message = "Couldn't find #{resource_class} where "\
68
- "#{id_param} = #{id_value.inspect}: #{exception.message}"
69
- Exceptions::MissingRelationship.new(exception_message, payload)
76
+ payload.detail = "Unable to find '#{deserializer.type}' with matching id"\
77
+ ": #{id_value.inspect}"
78
+ Exceptions::MissingRelationship.new(message, payload)
79
+ end
80
+
81
+ def missing_resource_debug_message(id_value)
82
+ "Couldn't find #{deserializer.resource_class.name}"\
83
+ " where #{deserializer.id_param} = #{id_value.inspect}"
70
84
  end
71
85
  end
72
86
  end
@@ -5,22 +5,19 @@ module FunWithJsonApi
5
5
  new(name, deserializer_class_or_callable, options)
6
6
  end
7
7
 
8
- delegate :id_param,
9
- :type,
10
- :resource_class,
11
- to: :deserializer
8
+ attr_reader :deserializer_class
9
+ attr_reader :options
10
+ delegate :type, to: :deserializer
12
11
 
13
12
  def initialize(name, deserializer_class, options = {})
14
13
  super(name, options.reverse_merge(as: name.to_s.singularize.to_sym))
15
14
  @deserializer_class = deserializer_class
15
+ @options = options.reverse_merge(
16
+ attributes: [],
17
+ relationships: []
18
+ )
16
19
 
17
- if as.to_s != as.to_s.singularize
18
- raise ArgumentError, "Use a singular relationship as value: {as: :#{as.to_s.singularize}}"
19
- end
20
- end
21
-
22
- def deserializer
23
- @deserializer ||= create_deserializer_from_deserializer_class
20
+ check_as_attribute_is_singular!
24
21
  end
25
22
 
26
23
  # Expects an array of id values for a nested collection
@@ -29,7 +26,7 @@ module FunWithJsonApi
29
26
  raise build_invalid_relationship_collection_error(values)
30
27
  end
31
28
 
32
- collection = resource_class.where(id_param => values)
29
+ collection = deserializer.load_collection_from_id_values(values)
33
30
 
34
31
  # Ensure the collection size matches
35
32
  expected_size = values.size
@@ -42,35 +39,50 @@ module FunWithJsonApi
42
39
  convert_collection_to_ids(collection)
43
40
  end
44
41
 
42
+ # rubocop:disable Style/PredicateName
43
+
44
+ def has_many?
45
+ true
46
+ end
47
+
48
+ # rubocop:enable Style/PredicateName
49
+
45
50
  # User the singular of `as` that is how AMS converts the value
46
51
  def param_value
47
52
  :"#{as}_ids"
48
53
  end
49
54
 
55
+ def deserializer
56
+ @deserializer ||= build_deserializer_from_options
57
+ end
58
+
50
59
  private
51
60
 
61
+ def build_deserializer_from_options
62
+ if @deserializer_class.respond_to?(:call)
63
+ @deserializer_class.call
64
+ else
65
+ @deserializer_class
66
+ end.create(options)
67
+ end
68
+
69
+ def check_as_attribute_is_singular!
70
+ if as.to_s != as.to_s.singularize
71
+ raise ArgumentError, "Use a singular relationship as value: {as: :#{as.to_s.singularize}}"
72
+ end
73
+ end
74
+
52
75
  def convert_collection_to_ids(collection)
53
76
  if collection.respond_to? :pluck
54
77
  # Well... pluck+arel doesn't work with SQLite, but select at least is safe
55
- collection = collection.select(resource_class.arel_table[:id])
78
+ collection = collection.select(deserializer.resource_class.arel_table[:id])
56
79
  end
57
80
  collection.map(&:id)
58
81
  end
59
82
 
60
- # Creates a new Deserializer from the deserializer class
61
- def create_deserializer_from_deserializer_class
62
- if @deserializer_class.respond_to?(:call)
63
- @deserializer_class.call
64
- else
65
- @deserializer_class
66
- end.create(
67
- attributes: [],
68
- relationships: []
69
- )
70
- end
71
-
72
83
  def build_invalid_relationship_collection_error(values)
73
- exception_message = "#{name} relationship should contain a array of '#{type}' data"
84
+ exception_message = "#{name} relationship should contain a array of"\
85
+ " '#{deserializer.type}' data"
74
86
  payload = ExceptionPayload.new
75
87
  payload.pointer = "/data/relationships/#{name}"
76
88
  payload.detail = exception_message
@@ -78,21 +90,26 @@ module FunWithJsonApi
78
90
  end
79
91
 
80
92
  def build_missing_relationship_error_from_collection(collection, values)
81
- collection_values = collection.map { |resource| resource.public_send(id_param).to_s }
82
- missing_values = values.reject { |value| collection_values.include?(value.to_s) }
83
- payload = missing_values.map do |value|
84
- build_missing_relationship_payload(value)
85
- end
86
- exception_message = "Couldn't find #{resource_class} items with "\
87
- "#{id_param} in #{missing_values.inspect}"
93
+ collection_ids = deserializer.format_collection_ids(collection)
94
+
95
+ payload = build_missing_relationship_payload(collection_ids, values)
96
+
97
+ missing_values = values.reject { |value| collection_ids.include?(value.to_s) }
98
+ exception_message = "Couldn't find #{deserializer.resource_class} items with "\
99
+ "#{deserializer.id_param} in #{missing_values.inspect}"
88
100
  Exceptions::MissingRelationship.new(exception_message, payload)
89
101
  end
90
102
 
91
- def build_missing_relationship_payload(value)
92
- ExceptionPayload.new.tap do |payload|
93
- payload.pointer = "/data/relationships/#{name}/id"
94
- payload.detail = "Unable to find '#{type}' with matching id: #{value.inspect}"
95
- end
103
+ def build_missing_relationship_payload(collection_ids, values)
104
+ values.each_with_index.map do |resource_id, index|
105
+ next if collection_ids.include?(resource_id)
106
+
107
+ ExceptionPayload.new.tap do |payload|
108
+ payload.pointer = "/data/relationships/#{name}/#{index}/id"
109
+ payload.detail = "Unable to find '#{deserializer.type}' with matching id"\
110
+ ": \"#{resource_id}\""
111
+ end
112
+ end.reject(&:nil?)
96
113
  end
97
114
  end
98
115
  end
@@ -2,7 +2,18 @@ module FunWithJsonApi
2
2
  module Attributes
3
3
  class StringAttribute < Attribute
4
4
  def call(value)
5
- value.to_s if value
5
+ return value if value.nil? || value.is_a?(String)
6
+
7
+ raise build_invalid_attribute_error(value)
8
+ end
9
+
10
+ private
11
+
12
+ def build_invalid_attribute_error(value)
13
+ payload = ExceptionPayload.new
14
+ payload.detail = I18n.t('fun_with_json_api.exceptions.invalid_string_attribute')
15
+ payload.pointer = "/data/attributes/#{name}"
16
+ Exceptions::InvalidAttribute.new("Value is not a string: #{value.class.name}", payload)
6
17
  end
7
18
  end
8
19
  end
@@ -0,0 +1,27 @@
1
+ module FunWithJsonApi
2
+ module Attributes
3
+ # Attribute that only accepts a properly generated and formatted UUID version 4
4
+ # as described in RFC 4122
5
+ class UuidV4Attribute < Attribute
6
+ # http://blog.arkency.com/2014/10/how-to-start-using-uuid-in-activerecord-with-postgresql/
7
+ UUID_V4_REGEX = /\A[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}\z/
8
+
9
+ def call(value)
10
+ return value if value.nil? || value =~ UUID_V4_REGEX
11
+
12
+ raise build_invalid_attribute_error(value)
13
+ end
14
+
15
+ private
16
+
17
+ def build_invalid_attribute_error(value)
18
+ payload = ExceptionPayload.new
19
+ payload.detail = I18n.t('fun_with_json_api.exceptions.invalid_uuid_v4_attribute')
20
+ payload.pointer = "/data/attributes/#{name}"
21
+ Exceptions::InvalidAttribute.new(
22
+ "Value is not a RFC 4122 Version 4 UUID: #{value.class.name}", payload
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end