subjoin 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.travis.yml +23 -0
- data/.yardops +1 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +431 -0
- data/Rakefile +11 -0
- data/lib/subjoin.rb +85 -0
- data/lib/subjoin/attributable.rb +28 -0
- data/lib/subjoin/document.rb +136 -0
- data/lib/subjoin/errors.rb +15 -0
- data/lib/subjoin/identifier.rb +23 -0
- data/lib/subjoin/inclusions.rb +39 -0
- data/lib/subjoin/inheritable.rb +80 -0
- data/lib/subjoin/jsonapi.rb +13 -0
- data/lib/subjoin/link.rb +30 -0
- data/lib/subjoin/linkable.rb +18 -0
- data/lib/subjoin/meta.rb +10 -0
- data/lib/subjoin/metable.rb +21 -0
- data/lib/subjoin/relationship.rb +32 -0
- data/lib/subjoin/resource.rb +93 -0
- data/lib/subjoin/version.rb +4 -0
- data/spec/document_spec.rb +210 -0
- data/spec/identifier_spec.rb +33 -0
- data/spec/inclusions_spec.rb +69 -0
- data/spec/inheritable_resource_spec.rb +89 -0
- data/spec/link_spec.rb +60 -0
- data/spec/meta_spec.rb +11 -0
- data/spec/relationship_spec.rb +64 -0
- data/spec/resource_spec.rb +139 -0
- data/spec/responses/404.json +8 -0
- data/spec/responses/article_example.json +37 -0
- data/spec/responses/compound_example.json +73 -0
- data/spec/responses/links.json +9 -0
- data/spec/responses/meta.json +13 -0
- data/spec/spec_helper.rb +12 -0
- data/spec/subjoin_spec.rb +99 -0
- data/subjoin.gemspec +27 -0
- metadata +168 -0
data/Rakefile
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require "rspec/core/rake_task"
|
3
|
+
RSpec::Core::RakeTask.new(:no_inheritance) do |t|
|
4
|
+
t.exclude_pattern = "**/inheritable*_spec.rb"
|
5
|
+
end
|
6
|
+
|
7
|
+
RSpec::Core::RakeTask.new(:inheritance) do |t|
|
8
|
+
t.pattern = "**/inheritable*_spec.rb"
|
9
|
+
end
|
10
|
+
task :default => :spec
|
11
|
+
task :spec => [:no_inheritance, :inheritance]
|
data/lib/subjoin.rb
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
require "faraday"
|
2
|
+
require "json"
|
3
|
+
require "subjoin/attributable"
|
4
|
+
require "subjoin/errors"
|
5
|
+
require "subjoin/meta"
|
6
|
+
require "subjoin/metable"
|
7
|
+
require "subjoin/jsonapi"
|
8
|
+
require "subjoin/link"
|
9
|
+
require "subjoin/linkable"
|
10
|
+
require "subjoin/resource"
|
11
|
+
require "subjoin/identifier"
|
12
|
+
require "subjoin/inclusions"
|
13
|
+
require "subjoin/inheritable"
|
14
|
+
require "subjoin/relationship"
|
15
|
+
require "subjoin/document"
|
16
|
+
require "subjoin/version"
|
17
|
+
|
18
|
+
module Subjoin
|
19
|
+
|
20
|
+
# Connection used for all HTTP resquests
|
21
|
+
@@conn = Faraday.new
|
22
|
+
|
23
|
+
private
|
24
|
+
# Fetch and parse data from a URI
|
25
|
+
# @param [URI] uri The endpoint to get
|
26
|
+
# @return [Hash] Parsed JSON data
|
27
|
+
# @raise [ResponseError] if the endpoint returns an error response
|
28
|
+
def self.get(uri, params={})
|
29
|
+
params = {} if params.nil?
|
30
|
+
uri_params = uri.query.nil? ? {} : param_flatten(CGI::parse(uri.query))
|
31
|
+
final_params = uri_params.merge(stringify_params(params))
|
32
|
+
response = @@conn.get(uri,
|
33
|
+
final_params,
|
34
|
+
{"Accept" => "application/vnd.api+json"}
|
35
|
+
)
|
36
|
+
data = JSON.parse response.body
|
37
|
+
|
38
|
+
if data.has_key?("errors")
|
39
|
+
raise ResponseError.new
|
40
|
+
end
|
41
|
+
|
42
|
+
return data
|
43
|
+
end
|
44
|
+
|
45
|
+
# CGI::parse creates a hash whose values are arrays which is
|
46
|
+
# incompatible with Faraday.get, so flatten the values
|
47
|
+
def self.param_flatten(p)
|
48
|
+
Hash[p.map{|k,v| [k, v.join(',')]}]
|
49
|
+
end
|
50
|
+
|
51
|
+
# If param value is an Array, join elements into a string. `field` parameters
|
52
|
+
# passed as a Hash will be converted to key value pairs like
|
53
|
+
# "field[type]"="fields1,field2"
|
54
|
+
# @param p [Hash] parameters
|
55
|
+
# @return [Hash] with arrays joined into strings
|
56
|
+
def self.stringify_params(p)
|
57
|
+
fieldify(Hash[p.map{|k, v| [k, stringify_value(v, k) ]}])
|
58
|
+
end
|
59
|
+
|
60
|
+
# Turn parameter values into Strings if they are Hashes or Arrays
|
61
|
+
# @param v The value to check
|
62
|
+
# @param k The key corresponding to the value passed in
|
63
|
+
# @return [String,Hash] If a Hash is returned it will be taken care of by
|
64
|
+
# {fieldify}
|
65
|
+
def self.stringify_value(v, k=nil)
|
66
|
+
return v if v.is_a?(String)
|
67
|
+
return v.join(",") if v.is_a?(Array)
|
68
|
+
if v.is_a?(Hash)
|
69
|
+
return Hash[v.map{|key, val| ["#{k}[#{key}]", stringify_value(val)]}]
|
70
|
+
end
|
71
|
+
raise ArgumentError.new
|
72
|
+
end
|
73
|
+
|
74
|
+
# If a field paramter has been passed as a Hash it will still be a Hash and
|
75
|
+
# and we want to replace `field` with it's value
|
76
|
+
# @param h [Hash]
|
77
|
+
def self.fieldify(h)
|
78
|
+
if h.has_key? "fields"
|
79
|
+
f = h.delete("fields")
|
80
|
+
return h.merge(f)
|
81
|
+
end
|
82
|
+
|
83
|
+
return h
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Subjoin
|
2
|
+
# Generically handle arbitrary object attributes
|
3
|
+
# @see http://jsonapi.org/format/#document-resource-object-attributes
|
4
|
+
module Attributable
|
5
|
+
|
6
|
+
# The object's attributes
|
7
|
+
# @return [Hash]
|
8
|
+
attr_reader :attributes
|
9
|
+
|
10
|
+
# Load the object's attributes
|
11
|
+
# @param data [Hash] The object's parsed JSON `attribute` member
|
12
|
+
def load_attributes(data)
|
13
|
+
@attributes = data
|
14
|
+
end
|
15
|
+
|
16
|
+
|
17
|
+
# Access an attribute by property name
|
18
|
+
# @param name [String] the property name
|
19
|
+
# @return The property value, or nil if no such property exists
|
20
|
+
def [](name)
|
21
|
+
name = name.to_s
|
22
|
+
if @attributes.has_key?(name)
|
23
|
+
return @attributes[name]
|
24
|
+
end
|
25
|
+
return nil
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
module Subjoin
|
2
|
+
# A JSON-API top level document
|
3
|
+
class Document
|
4
|
+
include Metable
|
5
|
+
include Linkable
|
6
|
+
|
7
|
+
# The document's primary data
|
8
|
+
attr_reader :data
|
9
|
+
|
10
|
+
# Resources included in a compound document"
|
11
|
+
attr_reader :included
|
12
|
+
|
13
|
+
# JSON-API version information
|
14
|
+
attr_reader :jsonapi
|
15
|
+
|
16
|
+
|
17
|
+
# Create a document. Parameters can take several forms:
|
18
|
+
# 1. A URI object: Document will be created from the URI
|
19
|
+
# 2. A Hash: The Hash is assumed to be a parsed JSON response and the
|
20
|
+
# Document will be created from that
|
21
|
+
# 3. One string: Assumed to be a JSON-API object type. An attempt will be
|
22
|
+
# made to map this type to a class that inherits from
|
23
|
+
# {InheritableResource} and to load the create the Document from a URL
|
24
|
+
# provided by that class. There is also the assumption that this URL
|
25
|
+
# returns all objects of that type.
|
26
|
+
# 4. Two strings: Assumed to be a JSON-API object type and id. The same
|
27
|
+
# mapping is attempted as before, and the second parameter is added to
|
28
|
+
# the URL
|
29
|
+
# @param [Array] args
|
30
|
+
def initialize(*args)
|
31
|
+
if args.count < 1
|
32
|
+
raise ArgumentError.new
|
33
|
+
end
|
34
|
+
|
35
|
+
contents = load_by_type(args[0], args[1..-1])
|
36
|
+
|
37
|
+
@meta = load_meta(contents['meta'])
|
38
|
+
@links = load_links(contents['links'])
|
39
|
+
@included = load_included(contents)
|
40
|
+
@data = load_data(contents)
|
41
|
+
@jsonapi = load_jsonapi(contents)
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [Boolean] true if there is primary data
|
45
|
+
def has_data?
|
46
|
+
return ! @data.nil?
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [Boolean] true if there are included resources
|
50
|
+
def has_included?
|
51
|
+
return ! @included.nil?
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [Boolean] true if there is version information
|
55
|
+
def has_jsonapi?
|
56
|
+
return ! @jsonapi.nil?
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def load_by_type(firstArg, restArgs)
|
62
|
+
# We were passed a URI. Load it
|
63
|
+
return Subjoin::get(firstArg, restArgs[0]) if firstArg.is_a?(URI)
|
64
|
+
|
65
|
+
# We were passed a Hash. Just use it
|
66
|
+
return firstArg if firstArg.is_a?(Hash)
|
67
|
+
|
68
|
+
# We were passed a type, and maybe an id.
|
69
|
+
return load_by_id(firstArg, restArgs) if firstArg.is_a?(String)
|
70
|
+
|
71
|
+
# None of the above
|
72
|
+
raise ArgumentError.new
|
73
|
+
end
|
74
|
+
|
75
|
+
def load_by_id(firstArg, restArgs)
|
76
|
+
type = firstArg
|
77
|
+
id = restArgs.first
|
78
|
+
|
79
|
+
return Subjoin::get(mapped_type(type)::type_url) if id.nil?
|
80
|
+
|
81
|
+
Subjoin::get(URI([mapped_type(type)::type_url, id].join('/')))
|
82
|
+
end
|
83
|
+
|
84
|
+
|
85
|
+
# Take the data element and make an Array of instantiated Resource
|
86
|
+
# objects. Turn single objects into a single item Array to be consistent.
|
87
|
+
# @param c [Hash] Parsed JSON
|
88
|
+
# @return [Array, nil]
|
89
|
+
def load_data(c)
|
90
|
+
return nil unless c.has_key?("data")
|
91
|
+
|
92
|
+
#single resource, but instantiate it and stick it in an Array
|
93
|
+
return [mapped_type(c["data"]["type"]).new(c["data"], self)] if c["data"].is_a? Hash
|
94
|
+
|
95
|
+
# Instantiate Resources for each array element
|
96
|
+
return c["data"].map{|d| mapped_type(d["type"]).new(d, self)}
|
97
|
+
end
|
98
|
+
|
99
|
+
# Instantiate a {Subjoin::Inclusions object if the included property is
|
100
|
+
# present
|
101
|
+
# @param c [Hash] Parsed JSON
|
102
|
+
# @return [Subjoin::Inclusions, nil]
|
103
|
+
def load_included(c)
|
104
|
+
return nil unless c.has_key? "included"
|
105
|
+
|
106
|
+
Inclusions.new(c['included'].map{|o| mapped_type(o["type"]).new(o, self)})
|
107
|
+
end
|
108
|
+
|
109
|
+
# Load jsonapi property if present
|
110
|
+
# @param c [Hash] Parsed JSON
|
111
|
+
# @return [Subjoin::JsonApi, nil]
|
112
|
+
def load_jsonapi(c)
|
113
|
+
return nil unless c.has_key?("jsonapi")
|
114
|
+
@jsonapi = JsonApi.new(c["jsonapi"])
|
115
|
+
end
|
116
|
+
|
117
|
+
def mapped_type(t)
|
118
|
+
type_map.fetch(t, Resource)
|
119
|
+
end
|
120
|
+
|
121
|
+
def type_map
|
122
|
+
@type_map ||= create_type_map
|
123
|
+
end
|
124
|
+
|
125
|
+
def create_type_map
|
126
|
+
d_types = Subjoin.constants.
|
127
|
+
map{|c| Subjoin.const_get(c)}.
|
128
|
+
select{|c| c.is_a?(Class) and c < Subjoin::Inheritable}
|
129
|
+
Hash[d_types.map{|c| [c::type_id, c]}]
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
|
135
|
+
|
136
|
+
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Subjoin
|
2
|
+
class Error < StandardError; end
|
3
|
+
|
4
|
+
class NoOverriddenRootError < Error
|
5
|
+
def message
|
6
|
+
"You must derive a class from Subjoin::Resource and override Resource#Root to return the root URL of the API you are using. This derived class should, in turn be used as the base class for your other custom classes."
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
class ResponseError < Error; end
|
11
|
+
|
12
|
+
class UnexpectedTypeError < Error; end
|
13
|
+
|
14
|
+
class SubclassError < Error; end
|
15
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Subjoin
|
2
|
+
# A resource identifier object
|
3
|
+
# @see http://jsonapi.org/format/#document-resource-identifier-objects
|
4
|
+
class Identifier
|
5
|
+
include Metable
|
6
|
+
|
7
|
+
attr_reader :type
|
8
|
+
attr_reader :id
|
9
|
+
|
10
|
+
def initialize(type, id, meta=nil)
|
11
|
+
#load_key(data)
|
12
|
+
@type = type
|
13
|
+
@id = id
|
14
|
+
@meta = load_meta(meta)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Test for equality. Two Ideintifers are considered equal if they
|
18
|
+
# have the same type and id
|
19
|
+
def ==(other)
|
20
|
+
return @type == other.type && @id == other.id
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Subjoin
|
2
|
+
# Container for related resources included in a compounf
|
3
|
+
# document. Alllows Hash-like access by {Identifier}, type/id pair,
|
4
|
+
# or Array-like access bu index
|
5
|
+
class Inclusions
|
6
|
+
def initialize(data)
|
7
|
+
@inc = data
|
8
|
+
end
|
9
|
+
|
10
|
+
# @return [Array<Subjoin::Resource>] all included resources
|
11
|
+
def all
|
12
|
+
@inc
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [Subjoin::Resource] first included resource
|
16
|
+
def first
|
17
|
+
@inc.first
|
18
|
+
end
|
19
|
+
|
20
|
+
# Access a particular resource by id
|
21
|
+
# @param id Either a {Subjoin::Identifier}, an Array of two strings
|
22
|
+
# taken as a type and an id, or an integer
|
23
|
+
# @return [Subjoin::Resource]
|
24
|
+
def [](id)
|
25
|
+
if id.is_a?(Identifier)
|
26
|
+
return @inc.select{|i| i.identifier == id}.first
|
27
|
+
end
|
28
|
+
|
29
|
+
if id.is_a?(Array) && id.count == 2
|
30
|
+
idd = Identifier.new(id[0], id[1])
|
31
|
+
return @inc.select{|i| i.identifier == idd}.first
|
32
|
+
end
|
33
|
+
|
34
|
+
if id.is_a?(Fixnum)
|
35
|
+
return @inc[id]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
module Subjoin
|
3
|
+
# Mixin providing methods necessary for using custom classes derived
|
4
|
+
# from {Resource}.
|
5
|
+
#
|
6
|
+
# Using this approach you create your own classes to represent
|
7
|
+
# JSON-API resource types of a specific JSON-API server
|
8
|
+
# implementation. These classes must be sub-classes of {Resource}
|
9
|
+
# and must include {Inheritable}. Next you must override a class
|
10
|
+
# variable, `ROOT_URI`, which should be the root of all URIs of the
|
11
|
+
# API.
|
12
|
+
#
|
13
|
+
# By default, Subjoin will use the lower-cased name of the class as
|
14
|
+
# the type in URIs. If the class name does not match the type, you
|
15
|
+
# can further override `TYPE_PATH` to indicate the name (or longer URI
|
16
|
+
# fragment) that should be used in URIs to request the resource
|
17
|
+
# type. Your custom classes must also be part of the Subjoin
|
18
|
+
# module. You should probably create one sub-class of
|
19
|
+
# Subjoin::Resource that overrides `ROOT_URI`, and then create other
|
20
|
+
# classes as sub-classes of this:
|
21
|
+
#
|
22
|
+
# module Subjoin
|
23
|
+
# # Use this class as the parent of further subclasses.
|
24
|
+
# # They will inherit the ROOT_URI defined here
|
25
|
+
# class ExampleResource < Subjoin::Resource
|
26
|
+
# include Inheritable
|
27
|
+
# ROOT_URI="http://example.com"
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# # Subjoin will make requests to http://example.com/articles
|
31
|
+
# class Articles < ExampleResource
|
32
|
+
# end
|
33
|
+
#
|
34
|
+
# # Use TYPE_PATH if you don't want to name the class the same thing as
|
35
|
+
# # the type
|
36
|
+
# class ArticleComments < ExampleResource
|
37
|
+
# TYPE_PATH="comments"
|
38
|
+
# end
|
39
|
+
# end
|
40
|
+
module Inheritable
|
41
|
+
# Root URI for all API requests
|
42
|
+
ROOT_URI = nil
|
43
|
+
|
44
|
+
# JSON-API type corresponding to this class, and presumably string
|
45
|
+
# to be used in requests for resources of this type. If not
|
46
|
+
# provided, the lower-cased name of the class will be used
|
47
|
+
TYPE_PATH = nil
|
48
|
+
|
49
|
+
# Callback invoked whenever module is included in another module
|
50
|
+
# or class.
|
51
|
+
def self.included(base)
|
52
|
+
base.extend(ClassMethods)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Class methods for objects that include this mixin
|
56
|
+
module ClassMethods
|
57
|
+
# @return [String] JSON-API type corresponding to this
|
58
|
+
# class. Lower-cased name of the class unless TYPE_PATH is
|
59
|
+
# specified
|
60
|
+
def type_id
|
61
|
+
return self.to_s.downcase.gsub(/^.*::/, '') if self::TYPE_PATH.nil?
|
62
|
+
return self::TYPE_PATH
|
63
|
+
end
|
64
|
+
|
65
|
+
# @return [URI] URI for requesting an object of this type, based
|
66
|
+
# of ROOT_URI and {#type_id}
|
67
|
+
def type_url
|
68
|
+
if self.class == Resource
|
69
|
+
raise Subjoin::SubclassError.new "Class must be a subclass of Resource to use this method"
|
70
|
+
end
|
71
|
+
|
72
|
+
if self::ROOT_URI.nil?
|
73
|
+
raise Subjoin::SubclassError.new "#{self.class} or a parent of #{self.class} derived from Subjoin::Resource must override ROOT_URI to use this method"
|
74
|
+
end
|
75
|
+
|
76
|
+
return URI([self::ROOT_URI, self::type_id].join('/'))
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
data/lib/subjoin/link.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
module Subjoin
|
2
|
+
# A link object
|
3
|
+
class Link
|
4
|
+
include Metable
|
5
|
+
|
6
|
+
# The URL for this link
|
7
|
+
# @return String
|
8
|
+
attr_reader :href
|
9
|
+
|
10
|
+
def initialize(data)
|
11
|
+
if data.is_a? String
|
12
|
+
@href = URI(data)
|
13
|
+
else
|
14
|
+
@href = URI(data['href'])
|
15
|
+
@meta = load_meta(data['meta'])
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Returns the {#href} attribute
|
20
|
+
def to_s
|
21
|
+
@href.to_s
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get the resource identified by the URL
|
25
|
+
# @return [Document]
|
26
|
+
def get
|
27
|
+
Document.new(@href)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|