From 2443af13cb30b587a1d53b5957ebc5fcd3273873 Mon Sep 17 00:00:00 2001 From: Lucas Hosseini Date: Wed, 26 Oct 2016 05:37:57 +0200 Subject: [PATCH] Initial commit --- .gitignore | 34 +++ .travis.yml | 12 + Gemfile | 3 + README.md | 234 ++++++++++++++++++ Rakefile | 9 + VERSION | 1 + jsonapi-deserializable.gemspec | 19 ++ lib/jsonapi/deserializable.rb | 2 + lib/jsonapi/deserializable/relationship.rb | 51 ++++ .../deserializable/relationship_dsl.rb | 21 ++ lib/jsonapi/deserializable/resource.rb | 110 ++++++++ lib/jsonapi/deserializable/resource_dsl.rb | 39 +++ spec/deserializable_relationships_spec.rb | 44 ++++ spec/deserializable_resource_spec.rb | 132 ++++++++++ 14 files changed, 711 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 Gemfile create mode 100644 README.md create mode 100644 Rakefile create mode 100644 VERSION create mode 100644 jsonapi-deserializable.gemspec create mode 100644 lib/jsonapi/deserializable.rb create mode 100644 lib/jsonapi/deserializable/relationship.rb create mode 100644 lib/jsonapi/deserializable/relationship_dsl.rb create mode 100644 lib/jsonapi/deserializable/resource.rb create mode 100644 lib/jsonapi/deserializable/resource_dsl.rb create mode 100644 spec/deserializable_relationships_spec.rb create mode 100644 spec/deserializable_resource_spec.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6e74b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/spec/examples.txt +/test/tmp/ +/test/version_tmp/ +/tmp/ + +## Specific to RubyMotion: +.dat* +.repl_history +build/ + +## Documentation cache and generated files: +/.yardoc/ +/_yardoc/ +/doc/ +/rdoc/ + +## Environment normalization: +/.bundle/ +/vendor/bundle +/lib/bundler/man/ + +Gemfile.lock +.ruby-version +.ruby-gemset + +# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: +.rvmrc diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e0b7d7c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: ruby +sudo: false +before_install: + - bundle update +rvm: + - 2.1 + - 2.2 + - 2.3.0 + - ruby-head +matrix: + allow_failures: + - rvm: ruby-head diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..fa75df1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gemspec diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb355bd --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# jsonapi-deserializable +Ruby gem for deserializing [JSON API](http://jsonapi.org) payloads into custom +hashes. + +## Status + +[![Gem Version](https://badge.fury.io/rb/jsonapi-deserializable.svg)](https://badge.fury.io/rb/jsonapi-deserializable) +[![Build Status](https://secure.travis-ci.org/jsonapi-rb/deserializable.svg?branch=master)](http://travis-ci.org/jsonapi-rb/deserializable?branch=master) + +## Table of Contents + + - [Installation](#installation) + - [Usage/Examples](#usageexamples) + - [Documentation](#documentation) + - [Common methods](#common-methods) + - [`JSONAPI::Deserializable::Resource` DSL](#jsonapideserializableresource-dsl) + - [`JSONAPI::Deserializable::Relationship` DSL](#jsonapideserializablerelationship-dsl) + - [License](#license) + +## Installation +```ruby +# In Gemfile +gem 'jsonapi-deserializable' +``` +then +``` +$ bundle +``` +or manually via +``` +$ gem install jsonapi-deserializable +``` + +## Usage/Examples + +First, require the gem: +```ruby +require 'jsonapi/deserializable' +``` + +Then, define some resource/relationship deserializable classes: + +### Resource Example + +```ruby +class DeserializablePost < JSONAPI::Deserializable::Resource + type + attribute :title + attribute :date { |date| field date: DateTime.parse(date) } + has_one :author do |rel, id, type| + field author_id: id + field author_type: type + end + has_many :comments do |rel, ids, types| + field comment_ids: ids + field comment_types: types.map do |type| + camelize(singularize(type)) + end + end +end +``` + +which can then be used to deserialize post payloads: +```ruby +DeserializablePost.(payload) +# => { +# id: '1', +# title: 'Title', +# date: #, +# author_id: '1337', +# author_type: 'users', +# comment_ids: ['123', '234', '345'] +# comment_types: ['Comment', 'Comment', 'Comment'] +# } +``` + +### Relationship Example + +```ruby +class DeserializablePostComments < JSONAPI::Deserializable::Relationship + has_many do |rel, ids, types| + field comment_ids: ids + field comment_types: types.map do |ri| + camelize(singularize(type)) + end + field comments_meta: rel['meta'] + end +end +``` +```ruby +DeserializablePostComments.(payload) +# => { +# comment_ids: ['123', '234', '345'] +# comment_types: ['Comment', 'Comment', 'Comment'] +# } +``` + +## Documentation + +Whether deserializaing a resource or a relationship, the base idea is the same: +for every part of the payload, simply declare the fields you want to build from +their value. You can create as many fields as you want out of any one part of +the payload. + +It works according to a whitelisting mechanism: should the corresponding part of +the payload not be present, the fields will simply not be created on the result +hash. + +Note however that the library expects well formed JSONAPI payloads (which you +can ensure using, for instance, +[jsonapi-parser](https://github.com/beauby/jsonapi/tree/master/parser)), +and that deserialization does not substitute itself to validation of the +resulting hash (which you can handle using, for instance, +[dry-validation](http://dry-rb.org/gems/dry-validation/)). + +### Common Methods + ++ `::field(hash)` + +The `field` DSL method is the base of jsonapi-deserializable. It simply declares +a field of the result hash, with its value. The syntax is: +```ruby +field key: value +``` + +It is mainly used within the following DSL contexts, but can be used outside of +any to declare custom non payload-related fields. + ++ `#initialize(payload)` + +Build a deserializable instance, ready to be deserialized by calling `#to_h`. + ++ `#to_h` + +In order to deserialize a payload, simply do: +```ruby +DeserializablePost.new(payload).to_h +``` +or use the shorthand syntax: +```ruby +DeserializablePost.(payload) +``` + +### `JSONAPI::Deserializable::Resource` DSL + ++ `::type(&block)` +```ruby +type do |type| + field my_type_field: type +end +``` + +Shorthand syntax: +```ruby +type +``` + ++ `::id(&block)` +```ruby +id do |id| + field my_id_field: id +end +``` + +Shorthand syntax: +```ruby +id +``` + ++ `::attribute(key, &block)` +```ruby +attribute :title do |title| + field my_title_field: title +end +``` + +Shorthand syntax: +```ruby +attribute :title +``` + ++ `::has_one(key, &block)` +```ruby +has_one :author do |rel, id, type| + field my_author_type_field: type + field my_author_id_field: id + field my_author_meta_field: rel['meta'] +end +``` + +Shorthand syntax: +```ruby +has_one :author +``` +Note: this creates a field `:author` with value the whole relationship hash. + ++ `::has_many(key, &block)` +```ruby +has_many :comments do |rel, ids, types| + field my_comment_types_field: types + field my_comment_ids_field: ids + field my_comment_meta_field: rel['meta'] +end +``` + +Shorthand syntax: +```ruby +has_many :comments +``` +Note: this creates a field `:comments` with value the whole relationship hash. + +### `JSONAPI::Deserializable::Relationship` DSL + ++ `::has_one(key, &block)` +```ruby +has_one do |rel, id, type| + field my_relationship_id_field: id + field my_relationship_type_field: type + field my_relationship_meta_field: rel['meta'] +end +``` + ++ `has_many(key, &block)` +```ruby +has_many do |rel, ids, types| + field my_relationship_ids_field: ids + field my_relationship_types_field: types + field my_relationship_meta_field: rel['meta'] +end +``` + +## License + +jsonapi-deserializable is released under the [MIT License](http://www.opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..7a4e66c --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) do |t| + t.pattern = Dir.glob('spec/**/*_spec.rb') +end + +task default: :test +task test: :spec diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6365d92 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.1.beta3 diff --git a/jsonapi-deserializable.gemspec b/jsonapi-deserializable.gemspec new file mode 100644 index 0000000..d47611f --- /dev/null +++ b/jsonapi-deserializable.gemspec @@ -0,0 +1,19 @@ +version = File.read(File.expand_path('../VERSION', __FILE__)).strip + +Gem::Specification.new do |spec| + spec.name = 'jsonapi-deserializable' + spec.version = version + spec.author = 'Lucas Hosseini' + spec.email = 'lucas.hosseini@gmail.com' + spec.summary = 'Deserialize JSON API payloads.' + spec.description = 'DSL for deserializing incoming JSON API payloads ' \ + 'into custom hashes.' + spec.homepage = 'https://github.com/jsonapi-rb/deserializable' + spec.license = 'MIT' + + spec.files = Dir['README.md', 'lib/**/*'] + spec.require_path = 'lib' + + spec.add_development_dependency 'rake', '>=0.9' + spec.add_development_dependency 'rspec', '~>3.4' +end diff --git a/lib/jsonapi/deserializable.rb b/lib/jsonapi/deserializable.rb new file mode 100644 index 0000000..418b15c --- /dev/null +++ b/lib/jsonapi/deserializable.rb @@ -0,0 +1,2 @@ +require 'jsonapi/deserializable/relationship' +require 'jsonapi/deserializable/resource' diff --git a/lib/jsonapi/deserializable/relationship.rb b/lib/jsonapi/deserializable/relationship.rb new file mode 100644 index 0000000..eb2192f --- /dev/null +++ b/lib/jsonapi/deserializable/relationship.rb @@ -0,0 +1,51 @@ +require 'jsonapi/deserializable/relationship_dsl' + +module JSONAPI + module Deserializable + class Relationship + include RelationshipDSL + + class << self + attr_accessor :has_one_block, :has_many_block + end + + def self.inherited(klass) + klass.has_one_block = has_one_block + klass.has_many_block = has_many_block + end + + def self.call(payload) + new(payload).to_h + end + + def initialize(payload) + @document = payload + @data = payload['data'] + deserialize! + end + + def to_h + @hash + end + + private + + def deserialize! + @hash = {} + if @data.is_a?(Array) + ids = @data.map { |ri| ri['id'] } + types = @data.map { |ri| ri['type'] } + instance_exec(@document, ids, types, &self.class.has_many_block) + else + id = @data && @data['id'] + type = @data && @data['type'] + instance_exec(@document, id, type, &self.class.has_one_block) + end + end + + def field(hash) + @hash.merge!(hash) + end + end + end +end diff --git a/lib/jsonapi/deserializable/relationship_dsl.rb b/lib/jsonapi/deserializable/relationship_dsl.rb new file mode 100644 index 0000000..f49b196 --- /dev/null +++ b/lib/jsonapi/deserializable/relationship_dsl.rb @@ -0,0 +1,21 @@ +module JSONAPI + module Deserializable + module RelationshipDSL + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def has_one(&block) + block ||= proc { |rel| field key.to_sym => rel } + self.has_one_block = block + end + + def has_many(&block) + block ||= proc { |rel| field key.to_sym => rel } + self.has_many_block = block + end + end + end + end +end diff --git a/lib/jsonapi/deserializable/resource.rb b/lib/jsonapi/deserializable/resource.rb new file mode 100644 index 0000000..7729cbd --- /dev/null +++ b/lib/jsonapi/deserializable/resource.rb @@ -0,0 +1,110 @@ +require 'jsonapi/deserializable/resource_dsl' + +module JSONAPI + module Deserializable + class Resource + include ResourceDSL + + class << self + attr_accessor :type_block, :id_block + attr_accessor :attr_blocks + attr_accessor :has_one_rel_blocks, :has_many_rel_blocks + end + + self.attr_blocks = {} + self.has_one_rel_blocks = {} + self.has_many_rel_blocks = {} + + def self.inherited(klass) + super + klass.type_block = type_block + klass.id_block = id_block + klass.attr_blocks = attr_blocks.dup + klass.has_one_rel_blocks = has_one_rel_blocks.dup + klass.has_many_rel_blocks = has_many_rel_blocks.dup + end + + def self.call(payload) + new(payload).to_h + end + + def initialize(payload) + @document = payload + @data = @document['data'] + @type = @data['type'] + @id = @data['id'] + @attributes = @data['attributes'] || {} + @relationships = @data['relationships'] || {} + deserialize! + end + + def to_h + @hash + end + + private + + def deserialize! + @hash = {} + deserialize_type! + deserialize_id! + deserialize_attrs! + deserialize_rels! + end + + def deserialize_type! + return unless @type && self.class.type_block + instance_exec(@type, &self.class.type_block) + end + + def deserialize_id! + return unless @id && self.class.id_block + instance_exec(@id, &self.class.id_block) + end + + def deserialize_attrs! + self.class.attr_blocks.each do |attr, block| + next unless @attributes.key?(attr) + instance_exec(@attributes[attr], &block) + end + end + + def deserialize_rels! + deserialize_has_one_rels! + deserialize_has_many_rels! + end + + def deserialize_has_one_rels! + self.class.has_one_rel_blocks.each do |key, block| + rel = @relationships[key] + next unless rel && (rel['data'].nil? || rel['data'].is_a?(Hash)) + deserialize_has_one_rel!(rel, &block) + end + end + + def deserialize_has_one_rel!(rel, &block) + id = rel['data'] && rel['data']['id'] + type = rel['data'] && rel['data']['type'] + instance_exec(rel, id, type, &block) + end + + def deserialize_has_many_rels! + self.class.has_many_rel_blocks.each do |key, block| + rel = @relationships[key] + next unless rel && rel['data'].is_a?(Array) + deserialize_has_many_rel!(rel, &block) + end + end + + def deserialize_has_many_rel!(rel, &block) + ids = rel['data'].map { |ri| ri['id'] } + types = rel['data'].map { |ri| ri['type'] } + instance_exec(rel, ids, types, &block) + end + + def field(hash) + @hash.merge!(hash) + end + end + end +end diff --git a/lib/jsonapi/deserializable/resource_dsl.rb b/lib/jsonapi/deserializable/resource_dsl.rb new file mode 100644 index 0000000..ce8194a --- /dev/null +++ b/lib/jsonapi/deserializable/resource_dsl.rb @@ -0,0 +1,39 @@ +module JSONAPI + module Deserializable + module ResourceDSL + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def type(&block) + block ||= proc { |type| field type: type } + self.type_block = block + end + + def id(&block) + block ||= proc { |id| field id: id } + self.id_block = block + end + + def attribute(key, options = {}, &block) + unless block + options[:key] ||= key.to_sym + block = proc { |attr| field key => attr } + end + attr_blocks[key.to_s] = block + end + + def has_one(key, &block) + block ||= proc { |rel| field key.to_sym => rel } + has_one_rel_blocks[key.to_s] = block + end + + def has_many(key, &block) + block ||= proc { |rel| field key.to_sym => rel } + has_many_rel_blocks[key.to_s] = block + end + end + end + end +end diff --git a/spec/deserializable_relationships_spec.rb b/spec/deserializable_relationships_spec.rb new file mode 100644 index 0000000..5c1d1eb --- /dev/null +++ b/spec/deserializable_relationships_spec.rb @@ -0,0 +1,44 @@ +require 'jsonapi/deserializable' + +describe JSONAPI::Deserializable::Relationship, '#to_h' do + it 'deserializes has_one relationships' do + deserializable_klass = Class.new(JSONAPI::Deserializable::Relationship) do + has_one do |rel| + field sponsor_id: (rel['data'] && rel['data']['id']) + end + end + + payload = { + 'data' => { + 'type' => 'users', + 'id' => '1' + } + } + + actual = deserializable_klass.(payload) + expected = { sponsor_id: '1' } + + expect(actual).to eq(expected) + end + + it 'deserializes has_many relationships' do + deserializable_klass = Class.new(JSONAPI::Deserializable::Relationship) do + has_many do |rel| + field post_ids: rel['data'].map { |ri| ri['id'] } + end + end + + payload = { + 'data' => [ + { 'type' => 'postd', 'id' => '1' }, + { 'type' => 'postd', 'id' => '2' }, + { 'type' => 'postd', 'id' => '3' } + ] + } + + actual = deserializable_klass.(payload) + expected = { post_ids: %w(1 2 3) } + + expect(actual).to eq(expected) + end +end diff --git a/spec/deserializable_resource_spec.rb b/spec/deserializable_resource_spec.rb new file mode 100644 index 0000000..ea11d9c --- /dev/null +++ b/spec/deserializable_resource_spec.rb @@ -0,0 +1,132 @@ +require 'jsonapi/deserializable' + +describe JSONAPI::Deserializable::Resource, '#to_h' do + before(:all) do + @payload = { + 'data' => { + 'id' => '1', + 'type' => 'users', + 'attributes' => { + 'name' => 'Name', + 'address' => 'Address' + }, + 'relationships' => { + 'sponsor' => { + 'data' => { 'type' => 'users', 'id' => '1337' } + }, + 'posts' => { + 'data' => [ + { 'type' => 'posts', 'id' => '123' }, + { 'type' => 'posts', 'id' => '234' }, + { 'type' => 'posts', 'id' => '345' } + ] + } + } + } + } + end + + it 'deserializes primary type' do + deserializable_klass = Class.new(JSONAPI::Deserializable::Resource) do + type { |type| field type: type } + end + + actual = deserializable_klass.(@payload) + expected = { type: 'users' } + + expect(actual).to eq(expected) + end + + it 'deserializes primary id when present' do + deserializable_klass = Class.new(JSONAPI::Deserializable::Resource) do + id { |id| field id: id } + end + + actual = deserializable_klass.(@payload) + expected = { id: '1' } + + expect(actual).to eq(expected) + end + + it 'does not deserialize primary id when absent' do + deserializable_klass = Class.new(JSONAPI::Deserializable::Resource) do + id { |id| field id: id } + end + + payload = { + 'data' => { 'type' => 'users' } + } + actual = deserializable_klass.(payload) + expected = {} + + expect(actual).to eq(expected) + end + + it 'handles attributes' do + deserializable_klass = Class.new(JSONAPI::Deserializable::Resource) do + attribute(:name) { |name| field username: name } + attribute(:address) { |address| field address: address } + end + + actual = deserializable_klass.(@payload) + expected = { + username: 'Name', + address: 'Address' + } + + expect(actual).to eq(expected) + end + + it 'handles has_one relationships' do + deserializable_klass = Class.new(JSONAPI::Deserializable::Resource) do + has_one(:sponsor) { |rel| field sponsor_id: rel['data']['id'] } + end + + actual = deserializable_klass.(@payload) + expected = { + sponsor_id: '1337' + } + + expect(actual).to eq(expected) + end + + it 'handles has_many relationships' do + deserializable_klass = Class.new(JSONAPI::Deserializable::Resource) do + has_many(:posts) do |rel| + field post_ids: rel['data'].map { |ri| ri['id'] } + end + end + + actual = deserializable_klass.(@payload) + expected = { + post_ids: %w(123 234 345) + } + + expect(actual).to eq(expected) + end + + it 'works' do + deserializable_klass = Class.new(JSONAPI::Deserializable::Resource) do + id + attribute(:name) { |name| field username: name } + attribute :address + has_one :sponsor do |_, id| + field sponsor_id: id + end + has_many :posts do |_, ids| + field post_ids: ids + end + end + + actual = deserializable_klass.(@payload) + expected = { + id: '1', + username: 'Name', + address: 'Address', + sponsor_id: '1337', + post_ids: %w(123 234 345) + } + + expect(actual).to eq(expected) + end +end