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.
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
@@ -0,0 +1,13 @@
1
+ module Subjoin
2
+ # JSON-API version information
3
+ class JsonApi
4
+ include Metable
5
+ attr_reader :version
6
+ def initialize(data)
7
+ @version = data['version']
8
+ load_meta(data['version'])
9
+ end
10
+ end
11
+ end
12
+
13
+
@@ -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