fun_with_json_api 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/Rakefile +28 -0
  4. data/config/locales/fun_with_json_api.en.yml +13 -0
  5. data/lib/fun_with_json_api/attribute.rb +38 -0
  6. data/lib/fun_with_json_api/attributes/boolean_attribute.rb +24 -0
  7. data/lib/fun_with_json_api/attributes/date_attribute.rb +22 -0
  8. data/lib/fun_with_json_api/attributes/datetime_attribute.rb +20 -0
  9. data/lib/fun_with_json_api/attributes/decimal_attribute.rb +23 -0
  10. data/lib/fun_with_json_api/attributes/float_attribute.rb +20 -0
  11. data/lib/fun_with_json_api/attributes/integer_attribute.rb +20 -0
  12. data/lib/fun_with_json_api/attributes/relationship.rb +73 -0
  13. data/lib/fun_with_json_api/attributes/relationship_collection.rb +99 -0
  14. data/lib/fun_with_json_api/attributes/string_attribute.rb +9 -0
  15. data/lib/fun_with_json_api/controller_methods.rb +12 -0
  16. data/lib/fun_with_json_api/deserializer.rb +70 -0
  17. data/lib/fun_with_json_api/deserializer_class_methods.rb +83 -0
  18. data/lib/fun_with_json_api/deserializer_config_builder.rb +48 -0
  19. data/lib/fun_with_json_api/exception.rb +27 -0
  20. data/lib/fun_with_json_api/exception_payload.rb +29 -0
  21. data/lib/fun_with_json_api/exception_payload_serializer.rb +17 -0
  22. data/lib/fun_with_json_api/exception_serializer.rb +15 -0
  23. data/lib/fun_with_json_api/exceptions/invalid_attribute.rb +13 -0
  24. data/lib/fun_with_json_api/exceptions/invalid_document.rb +12 -0
  25. data/lib/fun_with_json_api/exceptions/invalid_relationship.rb +13 -0
  26. data/lib/fun_with_json_api/exceptions/missing_relationship.rb +15 -0
  27. data/lib/fun_with_json_api/pre_deserializer.rb +61 -0
  28. data/lib/fun_with_json_api/railtie.rb +11 -0
  29. data/lib/fun_with_json_api/version.rb +3 -0
  30. data/lib/fun_with_json_api.rb +24 -0
  31. data/lib/tasks/fun_with_json_api_tasks.rake +4 -0
  32. data/spec/dummy/README.rdoc +28 -0
  33. data/spec/dummy/Rakefile +6 -0
  34. data/spec/dummy/app/assets/javascripts/application.js +13 -0
  35. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  36. data/spec/dummy/app/controllers/application_controller.rb +5 -0
  37. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  38. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  39. data/spec/dummy/bin/bundle +3 -0
  40. data/spec/dummy/bin/rails +4 -0
  41. data/spec/dummy/bin/rake +4 -0
  42. data/spec/dummy/bin/setup +29 -0
  43. data/spec/dummy/config/application.rb +25 -0
  44. data/spec/dummy/config/boot.rb +5 -0
  45. data/spec/dummy/config/database.yml +25 -0
  46. data/spec/dummy/config/environment.rb +5 -0
  47. data/spec/dummy/config/environments/development.rb +41 -0
  48. data/spec/dummy/config/environments/production.rb +80 -0
  49. data/spec/dummy/config/environments/test.rb +42 -0
  50. data/spec/dummy/config/initializers/assets.rb +11 -0
  51. data/spec/dummy/config/initializers/backtrace_silencers.rb +9 -0
  52. data/spec/dummy/config/initializers/cookies_serializer.rb +3 -0
  53. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  54. data/spec/dummy/config/initializers/inflections.rb +16 -0
  55. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  56. data/spec/dummy/config/initializers/session_store.rb +3 -0
  57. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  58. data/spec/dummy/config/locales/en.yml +23 -0
  59. data/spec/dummy/config/routes.rb +56 -0
  60. data/spec/dummy/config/secrets.yml +22 -0
  61. data/spec/dummy/config.ru +4 -0
  62. data/spec/dummy/log/test.log +37839 -0
  63. data/spec/dummy/public/404.html +67 -0
  64. data/spec/dummy/public/422.html +67 -0
  65. data/spec/dummy/public/500.html +66 -0
  66. data/spec/dummy/public/favicon.ico +0 -0
  67. data/spec/example_spec.rb +64 -0
  68. data/spec/fixtures/active_record.rb +65 -0
  69. data/spec/fun_with_json_api/controller_methods_spec.rb +123 -0
  70. data/spec/fun_with_json_api/deserializer_class_methods_spec.rb +52 -0
  71. data/spec/fun_with_json_api/deserializer_spec.rb +450 -0
  72. data/spec/fun_with_json_api/exception_spec.rb +77 -0
  73. data/spec/fun_with_json_api/pre_deserializer_spec.rb +287 -0
  74. data/spec/fun_with_json_api_spec.rb +55 -0
  75. data/spec/spec_helper.rb +33 -0
  76. metadata +275 -0
@@ -0,0 +1,70 @@
1
+ require 'active_support/inflector'
2
+ require 'fun_with_json_api/attribute'
3
+ require 'fun_with_json_api/deserializer_class_methods'
4
+
5
+ module FunWithJsonApi
6
+ class Deserializer
7
+ extend FunWithJsonApi::DeserializerClassMethods
8
+
9
+ # Creates a new instance of a
10
+ def self.create(options = {})
11
+ new(options)
12
+ end
13
+
14
+ # Use DeserializerClass.create to build new instances
15
+ private_class_method :new
16
+
17
+ attr_reader :id_param
18
+ attr_reader :type
19
+ attr_reader :resource_class
20
+
21
+ attr_reader :attributes
22
+ attr_reader :relationships
23
+
24
+ def initialize(options = {})
25
+ @id_param = options.fetch(:id_param) { self.class.id_param }
26
+ @type = options[:type]
27
+ @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)
30
+ end
31
+
32
+ # Takes a parsed params hash from ActiveModelSerializers::Deserialization and sanitizes values
33
+ def sanitize_params(params)
34
+ Hash[
35
+ serialize_attribute_values(attributes, params) +
36
+ serialize_attribute_values(relationships, params)
37
+ ]
38
+ end
39
+
40
+ def type
41
+ @type ||= self.class.type
42
+ end
43
+
44
+ def resource_class
45
+ @resource_class ||= self.class.resource_class
46
+ end
47
+
48
+ private
49
+
50
+ def filter_attributes_by_name(attribute_names, attributes)
51
+ if attribute_names
52
+ attributes.keep_if { |attribute| attribute_names.include?(attribute.name) }
53
+ else
54
+ attributes
55
+ end
56
+ end
57
+
58
+ # Calls <attribute.as> on the current instance, override the #<as> method to change loading
59
+ def serialize_attribute_values(attributes, params)
60
+ attributes.select { |attribute| params.key?(attribute.param_value) }
61
+ .map { |attribute| serialize_attribute(attribute, params) }
62
+ end
63
+
64
+ # Calls <attribute.as> on the current instance, override the #<as> method to change loading
65
+ def serialize_attribute(attribute, params)
66
+ raw_value = params.fetch(attribute.param_value)
67
+ [attribute.param_value, public_send(attribute.sanitize_attribute_method, raw_value)]
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,83 @@
1
+ require 'fun_with_json_api/attribute'
2
+
3
+ module FunWithJsonApi
4
+ # Provides a basic DSL for defining a FunWithJsonApi::Deserializer
5
+ module DeserializerClassMethods
6
+ def id_param(id_param = nil)
7
+ @id_param = id_param if id_param
8
+ @id_param || :id
9
+ end
10
+
11
+ def type(type = nil)
12
+ @type = type if type
13
+ @type || type_from_class_name
14
+ end
15
+
16
+ def resource_class(resource_class = nil)
17
+ @resource_class = resource_class if resource_class
18
+ @resource_class || type_from_class_name.singularize.classify.constantize
19
+ end
20
+
21
+ # Attributes
22
+
23
+ def attribute(name, options = {})
24
+ Attribute.create(name, options).tap do |attribute|
25
+ add_parse_attribute_method(attribute)
26
+ attributes << attribute
27
+ end
28
+ end
29
+
30
+ def attributes
31
+ @attributes ||= []
32
+ end
33
+
34
+ # Relationships
35
+
36
+ def belongs_to(name, deserializer_class_or_callable, options = {})
37
+ Attributes::Relationship.create(
38
+ name,
39
+ deserializer_class_or_callable,
40
+ options
41
+ ).tap do |relationship|
42
+ add_parse_attribute_method(relationship)
43
+ relationships << relationship
44
+ end
45
+ end
46
+
47
+ # rubocop:disable Style/PredicateName
48
+
49
+ def has_many(name, deserializer_class_or_callable, options = {})
50
+ Attributes::RelationshipCollection.create(
51
+ name,
52
+ deserializer_class_or_callable,
53
+ options
54
+ ).tap do |relationship|
55
+ add_parse_attribute_method(relationship)
56
+ relationships << relationship
57
+ end
58
+ end
59
+
60
+ # rubocop:enable Style/PredicateName
61
+
62
+ def relationships
63
+ @relationships ||= []
64
+ end
65
+
66
+ private
67
+
68
+ def add_parse_attribute_method(attribute)
69
+ define_method(attribute.sanitize_attribute_method) do |param_value|
70
+ attribute.call(param_value)
71
+ end
72
+ end
73
+
74
+ def type_from_class_name
75
+ resource_class_name = name.demodulize.sub(/Deserializer/, '').underscore
76
+ if ActiveModelSerializers.config.jsonapi_resource_type == :singular
77
+ resource_class_name.singularize
78
+ else
79
+ resource_class_name.pluralize
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,48 @@
1
+ module FunWithJsonAPi
2
+ # Builds an options hash for ActiveModelSerializers::Deserialization.jsonapi_parse
3
+ class DeserializerConfigBuilder
4
+ def self.build(deserializer)
5
+ new(deserializer).build
6
+ end
7
+
8
+ private_class_method :new
9
+
10
+ attr_reader :deserializer
11
+
12
+ def initialize(deserializer)
13
+ @deserializer = deserializer
14
+ end
15
+
16
+ def build
17
+ {
18
+ only: build_only_values,
19
+ keys: build_keys_value
20
+ }
21
+ end
22
+
23
+ protected
24
+
25
+ def build_only_values
26
+ attribute_only_values(deserializer.attributes) +
27
+ attribute_only_values(deserializer.relationships)
28
+ end
29
+
30
+ def build_keys_value
31
+ Hash[
32
+ attribute_key_values(deserializer.attributes) +
33
+ attribute_key_values(deserializer.relationships)
34
+ ]
35
+ end
36
+
37
+ private
38
+
39
+ def attribute_only_values(attributes_or_relationships)
40
+ attributes_or_relationships.map(&:name)
41
+ end
42
+
43
+ def attribute_key_values(attributes_or_relationships)
44
+ attributes_or_relationships.select { |a| a.name != a.as }
45
+ .map { |a| [a.name, a.as] }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,27 @@
1
+ require 'fun_with_json_api/exception_payload'
2
+
3
+ module FunWithJsonApi
4
+ class Exception < StandardError
5
+ attr_reader :payload
6
+
7
+ def initialize(message, payload)
8
+ super(message)
9
+ @payload = Array.wrap(payload)
10
+ end
11
+
12
+ # @return [Integer] The http status code for rendering this error
13
+ def http_status
14
+ payload_statuses = payload.map(&:status).uniq
15
+ if payload_statuses.length == 1
16
+ Integer(payload_statuses.first || '400') # Return the unique status code
17
+ elsif payload_statuses.any? { |status| status.starts_with?('5') }
18
+ 500 # We have a server error
19
+ else
20
+ 400 # Bad Request
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ # Load known Exceptions
27
+ Dir["#{File.dirname(__FILE__)}/exceptions/**/*.rb"].each { |f| require f }
@@ -0,0 +1,29 @@
1
+ require 'active_model_serializers/model'
2
+
3
+ module FunWithJsonApi
4
+ # id: a unique identifier for this particular occurrence of the problem.
5
+ # links: a links object containing the following members:
6
+ # about: a link that leads to further details about this particular occurrence of the problem.
7
+ # status: the HTTP status code applicable to this problem, expressed as a string value.
8
+ # code: an application-specific error code, expressed as a string value.
9
+ # title: a short, human-readable summary of the problem that SHOULD NOT change from occurrence to
10
+ # occurrence of the problem, except for purposes of localization.
11
+ # detail: a human-readable explanation specific to this occurrence of the problem. Like title,
12
+ # this field's value can be localized.
13
+ # source: an object containing references to the source of the error, optionally including any of
14
+ # the following members:
15
+ # pointer: a JSON Pointer [RFC6901] to the associated entity in the request document
16
+ # [e.g. "/data" for a primary data object, or "/data/attributes/title" for a specific attribute].
17
+ # parameter: a string indicating which URI query parameter caused the error.
18
+ # meta: a meta object containing non-standard meta-information about the error.
19
+ class ExceptionPayload < ActiveModelSerializers::Model
20
+ [:id, :status, :code, :title, :detail, :pointer, :parameter].each do |param|
21
+ define_method param do
22
+ attributes[param]
23
+ end
24
+ define_method "#{param}=" do |value|
25
+ attributes[param] = value
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,17 @@
1
+ module FunWithJsonApi
2
+ class ExceptionPayloadSerializer < ActiveModel::Serializer
3
+ attributes :id, :status, :code, :title, :detail, :source
4
+
5
+ def attributes(*)
6
+ # Strips all empty values and empty arrays
7
+ super.select { |_k, v| v.present? }
8
+ end
9
+
10
+ def source
11
+ {
12
+ pointer: object.pointer,
13
+ parameter: object.parameter
14
+ }.select { |_k, v| v.present? }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ require 'fun_with_json_api/exception_payload_serializer'
2
+
3
+ module FunWithJsonApi
4
+ class ExceptionSerializer < ActiveModel::Serializer::CollectionSerializer
5
+ def initialize(exception, options = {})
6
+ super(exception.payload, options.reverse_merge(
7
+ serializer: ExceptionPayloadSerializer
8
+ ))
9
+ end
10
+
11
+ def root
12
+ 'errors'
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # Indicates a Supplied attributes value is not formatted correctly
4
+ class InvalidAttribute < FunWithJsonApi::Exception
5
+ def initialize(message, payload = ExceptionPayload.new)
6
+ payload.code ||= 'invalid_attribute'
7
+ payload.title ||= I18n.t(:invalid_attribute, scope: 'fun_with_json_api.exceptions')
8
+ payload.status ||= '400'
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ class InvalidDocument < FunWithJsonApi::Exception
4
+ def initialize(message, payload = ExceptionPayload.new)
5
+ payload.code ||= 'invalid_document'
6
+ payload.title ||= I18n.t(:invalid_document, scope: 'fun_with_json_api.exceptions')
7
+ payload.status ||= '400'
8
+ super
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # Indicates a Supplied relationships value is not formatted correctly
4
+ class InvalidRelationship < FunWithJsonApi::Exception
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'
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ module FunWithJsonApi
2
+ module Exceptions
3
+ # Indicates a Supplied relationships value is not able to be found
4
+ class MissingRelationship < FunWithJsonApi::Exception
5
+ def initialize(message, payload = ExceptionPayload.new)
6
+ payload = Array.wrap(payload).each do |missing|
7
+ missing.code ||= 'missing_relationship'
8
+ missing.title ||= I18n.t(:missing_relationship, 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,61 @@
1
+ require 'active_model_serializers'
2
+
3
+ module FunWithJsonApi
4
+ # Converts a json_api document into a rails compatible hash.
5
+ # Acts as an adaptor for ActiveModuleSerializer deserializer classes
6
+ class PreDeserializer
7
+ def self.parse(document, deserializer)
8
+ new(document, deserializer).parse
9
+ end
10
+
11
+ attr_reader :document
12
+ attr_reader :deserializer
13
+
14
+ def initialize(document, deserializer)
15
+ @document = document.to_h.deep_dup.deep_stringify_keys
16
+ @deserializer = deserializer
17
+ end
18
+
19
+ def parse
20
+ ams_deserializer_class.parse(document, ams_deserializer_config) do |invalid_document, reason|
21
+ exception_message = "Invalid payload (#{reason}): #{invalid_document}"
22
+ exception = convert_reason_into_exceptions(exception_message, reason).first ||
23
+ Exceptions::InvalidDocument.new(exception_message)
24
+ raise exception
25
+ end
26
+ end
27
+
28
+ protected
29
+
30
+ def convert_reason_into_exceptions(exception_message, reason, values = [])
31
+ if reason.is_a?(String)
32
+ return convert_reason_message_into_error(exception_message, reason, values.join)
33
+ end
34
+ return nil unless reason.is_a?(Hash)
35
+ return nil unless reason.size == 1
36
+
37
+ reason.flat_map do |key, value|
38
+ convert_reason_into_exceptions(exception_message, value, (values + ["/#{key}"]))
39
+ end
40
+ end
41
+
42
+ def convert_reason_message_into_error(exception_message, reason, source)
43
+ payload = ExceptionPayload.new
44
+ payload.pointer = source.presence
45
+ payload.detail = reason
46
+ Exceptions::InvalidDocument.new(exception_message, payload)
47
+ end
48
+
49
+ def ams_deserializer_class
50
+ if defined?(ActiveModel::Serializer::Adapter::JsonApi::Deserialization)
51
+ ActiveModel::Serializer::Adapter::JsonApi::Deserialization
52
+ else
53
+ ActiveModelSerializers::Adapter::JsonApi::Deserialization
54
+ end
55
+ end
56
+
57
+ def ams_deserializer_config
58
+ @ams_deserializer_config ||= FunWithJsonAPi::DeserializerConfigBuilder.build(deserializer)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,11 @@
1
+ require 'fun_with_json_api/controller_methods'
2
+
3
+ module FunWithJsonApi
4
+ # Mountable engine for fun with json_api
5
+ class Railtie < Rails::Railtie
6
+ initializer 'fun_with_json_api.add_locales' do
7
+ translations = File.expand_path('../../../config/locales/**/*.{rb,yml}', __FILE__)
8
+ Dir.glob(translations) { |f| config.i18n.load_path << f.to_s }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module FunWithJsonApi
2
+ VERSION = '0.0.1'.freeze
3
+ end
@@ -0,0 +1,24 @@
1
+ require 'fun_with_json_api/exception'
2
+ require 'fun_with_json_api/attribute'
3
+
4
+ require 'fun_with_json_api/pre_deserializer'
5
+ require 'fun_with_json_api/deserializer'
6
+ require 'fun_with_json_api/deserializer_config_builder'
7
+
8
+ # Makes working with JSON:API fun!
9
+ module FunWithJsonApi
10
+ module_function
11
+
12
+ def deserialize(api_document, deserializer_class, options = {})
13
+ # Prepare the deserializer and the expected config
14
+ deserializer = deserializer_class.create(options)
15
+
16
+ # Run through initial document structure validation and deserialization
17
+ unfiltered = FunWithJsonApi::PreDeserializer.parse(api_document, deserializer)
18
+
19
+ # Ensure document matches schema, and sanitize values
20
+ deserializer.sanitize_params(unfiltered)
21
+ end
22
+ end
23
+
24
+ require 'fun_with_json_api/railtie' if defined?(Rails)