Initial commit

This commit is contained in:
Lucas Hosseini 2016-10-26 05:37:57 +02:00
commit 2443af13cb
14 changed files with 711 additions and 0 deletions

34
.gitignore vendored Normal file
View File

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

12
.travis.yml Normal file
View File

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

3
Gemfile Normal file
View File

@ -0,0 +1,3 @@
source 'https://rubygems.org'
gemspec

234
README.md Normal file
View File

@ -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: #<DateTime: 2016-01-10T02:30:00+00:00 ((2457398j,9000s,0n),+0s,2299161j)>,
# 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).

9
Rakefile Normal file
View File

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

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.1.1.beta3

View File

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

View File

@ -0,0 +1,2 @@
require 'jsonapi/deserializable/relationship'
require 'jsonapi/deserializable/resource'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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