diff --git a/lib/ams.rb b/lib/ams.rb index 7851f0df..ef4280ca 100644 --- a/lib/ams.rb +++ b/lib/ams.rb @@ -1,2 +1,3 @@ # frozen_string_literal: true require 'ams/version' +require 'ams/serializer' diff --git a/lib/ams/serializer.rb b/lib/ams/serializer.rb new file mode 100644 index 00000000..7bb41bcf --- /dev/null +++ b/lib/ams/serializer.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'json' +module AMS + # Lightweight mapping of a model to a JSON API resource object + # with attributes and relationships + # + # The fundamental building block of AMS is the Serializer. + # A Serializer is used by subclassing it, and then declaring its + # type, attributes, relations, and uniquely identifying field. + # + # The term 'fields' may refer to attributes of the model or the names of related + # models, as in {http://jsonapi.org/format/#document-resource-object-fields + # JSON:API resource object fields} + # + # @example: + # + # class ApplicationSerializer < AMS::Serializer; end + # class UserModelSerializer < ApplicationSerializer + # type :users + # id_field :id + # attribute :first_name, key: 'first-name' + # attribute :last_name, key: 'last-name' + # attribute :email + # relation :department, type: :departments, to: :one + # relation :roles, type: :roles, to: :many + # end + # + # user = User.last + # ums = UserModelSerializer.new(user) + # ums.to_json + class Serializer < BasicObject + # delegate constant lookup to Object + def self.const_missing(name) + ::Object.const_get(name) + end + + class << self + attr_accessor :_attributes, :_relations, :_id_field, :_type + + def add_instance_method(body, receiver=self) + cl = caller_locations[0] + silence_warnings { receiver.module_eval body, cl.absolute_path, cl.lineno } + end + + def add_class_method(body, receiver) + cl = caller_locations[0] + silence_warnings { receiver.class_eval body, cl.absolute_path, cl.lineno } + end + + def silence_warnings + original_verbose = $VERBOSE + $VERBOSE = nil + yield + ensure + $VERBOSE = original_verbose + end + + def inherited(base) + super + base._attributes = _attributes.dup + base._relations = _relations.dup + base._type = base.name.split('::')[-1].sub('Serializer', '').downcase + add_class_method "def class; #{base}; end", base + add_instance_method "def id; object.id; end", base + end + + def type(type) + self._type = type + end + + def id_field(id_field) + self._id_field = id_field + add_instance_method <<-METHOD + def id + object.#{id_field} + end + METHOD + end + + def attribute(attribute_name, key: attribute_name) + fail 'ForbiddenKey' if attribute_name == :id + _attributes[attribute_name] = { key: key } + add_instance_method <<-METHOD + def #{attribute_name} + object.#{attribute_name} + end + METHOD + end + + def relation(relation_name, type:, to:, key: relation_name, **options) + _relations[relation_name] = { key: key, type: type, to: to } + case to + when :many then _relation_to_many(relation_name, type: type, key: key, **options) + when :one then _relation_to_one(relation_name, type: type, key: key, **options) + else + fail ArgumentError, "UnknownRelationship to='#{to}'" + end + end + + def _relation_to_many(relation_name, type:, key: relation_name, **options) + ids_method = options.fetch(:ids) do + "object.#{relation_name}.pluck(:id)" + end + add_instance_method <<-METHOD + def related_#{relation_name}_ids + #{ids_method} + end + + def #{relation_name} + related_#{relation_name}_ids.map do |id| + relationship_object(id, "#{type}") + end + end + METHOD + end + + def _relation_to_one(relation_name, type:, key: relation_name, **options) + id_method = options.fetch(:id) do + "object.#{relation_name}.id" + end + add_instance_method <<-METHOD + def related_#{relation_name}_id + #{id_method} + end + + def #{relation_name} + id = related_#{relation_name}_id + relationship_object(id, "#{type}") + end + METHOD + end + end + self._attributes = {} + self._relations = {} + + attr_reader :object + + # @param model [Object] the model whose data is used in serialization + def initialize(object) + @object = object + end + + def as_json + { + id: id, + type: type + }.merge({ + attributes: attributes, + relationships: relations + }.reject { |_, v| v.empty? }) + end + + def to_json + dump(as_json) + end + + def attributes + fields = {} + _attributes.each do |attribute_name, config| + fields[config[:key]] = send(attribute_name) + end + fields + end + + def relations + fields = {} + _relations.each do |relation_name, config| + fields[config[:key]] = send(relation_name) + end + fields + end + + def type + self.class._type + end + + def _attributes + self.class._attributes + end + + def _relations + self.class._relations + end + + def relationship_object(id, type) + { + "data": { "id": id, "type": type }, # resource linkage + } + end + + def dump(obj) + JSON.dump(obj) + end + + def send(*args) + __send__(*args) + end + end +end diff --git a/test/fixtures/poro.rb b/test/fixtures/poro.rb new file mode 100644 index 00000000..8cbb92eb --- /dev/null +++ b/test/fixtures/poro.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +class PlainModel + class << self + attr_accessor :attribute_names, :association_names + + def attributes(*names) + self.attribute_names |= names.map(&:to_sym) + # Silence redefinition of methods warnings + silence_warnings do + attr_accessor(*names) + end + end + + def associations(*names) + self.association_names |= names.map(&:to_sym) + # Silence redefinition of methods warnings + silence_warnings do + attr_accessor(*names) + end + end + + def silence_warnings + original_verbose = $VERBOSE + $VERBOSE = nil + yield + ensure + $VERBOSE = original_verbose + end + end + self.attribute_names = [] + self.association_names = [] + + def initialize(fields = {}) + fields ||= {} # protect against nil + fields.each do |key, value| + send("#{key}=", value) + end + end + + def attributes + self.class.attribute_names.each_with_object({}) do |attribute_name, result| + result[attribute_name] = public_send(attribute_name).freeze + end.freeze + end + + def associations + association_names.each_with_object({}) do |association_name, result| + result[association_name] = public_send(association_name).freeze + end.freeze + end +end + +class ParentModel< PlainModel + attributes :id, :name, :description + associations :child_models +end +class ChildModel < PlainModel + attributes :id, :name +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 8d85a9ef..2e0e97d7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,6 +3,7 @@ require 'bundler/setup' require 'simplecov' require 'minitest/autorun' require 'ams' +require 'fixtures/poro' module AMS class Test < Minitest::Test diff --git a/test/unit/serializer/as_json_test.rb b/test/unit/serializer/as_json_test.rb new file mode 100644 index 00000000..56ff10e4 --- /dev/null +++ b/test/unit/serializer/as_json_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +require 'test_helper' + +module AMS + class Serializer + class AsJsonTest < Test + class ParentModelSerializer < Serializer + id_field :id + type :profiles + attribute :name + attribute :description, key: :summary + relation :child_models, type: :comments, to: :many, ids: 'object.child_models.map(&:id)' + end + + def setup + super + @relation = ChildModel.new(id: 2, name: 'comment') + @object = ParentModel.new( + id: 1, + name: 'name', + description: 'description', + child_models: [@relation] + ) + @serializer_class = ParentModelSerializer + @serializer_instance = @serializer_class.new(@object) + end + + def test_model_instance_as_json + expected = { + id: 1, type: :profiles, + attributes: {name: "name", summary: "description"}, + relationships: + {child_models: [{data: {id: 2, type: "comments"}}]} + } + assert_equal expected, @serializer_instance.as_json + end + + def test_model_instance_to_json + expected = { + id: 1, type: :profiles, + attributes: {name: "name", summary: "description"}, + relationships: + {child_models: [{data: {id: 2, type: "comments"}}]} + }.to_json + assert_equal expected, @serializer_instance.to_json + end + + def test_model_instance_dump + expected = { + id: 1, type: :profiles + }.to_json + assert_equal expected, @serializer_instance.dump(id: 1, type: :profiles) + end + end + end +end diff --git a/test/unit/serializer/attributes_test.rb b/test/unit/serializer/attributes_test.rb new file mode 100644 index 00000000..069d7b40 --- /dev/null +++ b/test/unit/serializer/attributes_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +require 'test_helper' + +module AMS + class Serializer + class AttributesTest < Test + class ParentModelSerializer < Serializer + attribute :name + attribute :description, key: :summary + end + + def setup + super + @object = ParentModel.new( + id: 1, + name: 'name', + description: 'description' + ) + @serializer_class = ParentModelSerializer + @serializer_instance = @serializer_class.new(@object) + end + + def test_model_instance_attributes + expected_attributes = { + name: 'name', + summary: 'description' + } + assert_equal expected_attributes, @serializer_instance.attributes + end + end + end +end diff --git a/test/unit/serializer/identifier_test.rb b/test/unit/serializer/identifier_test.rb new file mode 100644 index 00000000..a7dbde68 --- /dev/null +++ b/test/unit/serializer/identifier_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require 'test_helper' + +module AMS + class Serializer + class IdentifierTest < Test + class ParentModelSerializer < Serializer + id_field :id + end + + def setup + super + @object = ParentModel.new( id: 1,) + @serializer_class = ParentModelSerializer + @serializer_instance = @serializer_class.new(@object) + end + + def test_model_instance_id + expected_id = 1 + assert_equal expected_id, @serializer_instance.id + end + end + end +end diff --git a/test/unit/serializer/relationships_test.rb b/test/unit/serializer/relationships_test.rb new file mode 100644 index 00000000..15352973 --- /dev/null +++ b/test/unit/serializer/relationships_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +require 'test_helper' + +module AMS + class Serializer + class RelationshipsTest < Test + class ParentModelSerializer < Serializer + relation :child_models, type: :comments, to: :many, ids: 'object.child_models.map(&:id)' + end + + def setup + super + @relation = ChildModel.new(id: 2, name: 'comment') + @object = ParentModel.new( + child_models: [@relation] + ) + @serializer_class = ParentModelSerializer + @serializer_instance = @serializer_class.new(@object) + end + + def test_model_instance_relations + expected_relations = { + child_models: [{ + data: {type: 'comments', id: 2 } + }] + } + assert_equal expected_relations, @serializer_instance.relations + end + + def test_model_instance_relationship_object + expected = { + data: {type: :bananas, id: 5 } + } + assert_equal expected, @serializer_instance.relationship_object(5, :bananas) + end + end + end +end