api_bee 0.0.2 → 0.0.3
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/README.mkd +18 -4
- data/examples/github_api.rb +33 -21
- data/lib/api_bee/adapters/hash.rb +2 -2
- data/lib/api_bee/node.rb +15 -14
- data/lib/api_bee/proxy.rb +18 -5
- data/lib/api_bee/version.rb +1 -1
- data/lib/api_bee.rb +25 -8
- data/spec/node_spec.rb +9 -8
- data/spec/setup_spec.rb +92 -17
- metadata +2 -2
data/README.mkd
CHANGED
@@ -61,19 +61,33 @@ api.get('/my/resources').each do |r|
|
|
61
61
|
end
|
62
62
|
```
|
63
63
|
|
64
|
+
## Delegate to adapter
|
65
|
+
|
66
|
+
Lazy-loading and paginating resources is great for GET requests, but you might want to still use your adapter's other methods.
|
67
|
+
|
68
|
+
api = ApiBee.setup(MyCustomAdapter) do |config|
|
69
|
+
config.expose :delete, :post
|
70
|
+
end
|
71
|
+
|
72
|
+
# This still wraps your adapter's get() method and adds lazy-loading and pagination
|
73
|
+
api.get('/products').first[:title]
|
74
|
+
|
75
|
+
# This delegates directory to MyCustomAdapter#post()
|
76
|
+
api.post('/products', :title => 'Foo', :price => 100.0)
|
77
|
+
|
64
78
|
## finding a single resource
|
65
79
|
|
66
|
-
There's a special
|
80
|
+
There's a special get_one method that you can call on lists. It delegates to the adapter and it's useful for finding a single resource in the context of a paginated list.
|
67
81
|
|
68
82
|
resources = api.get('/my/resources')
|
69
|
-
resource = resources.
|
83
|
+
resource = resources.get_one('foobar')
|
70
84
|
|
71
|
-
That delegates to Adapter#
|
85
|
+
That delegates to Adapter#get_one passing 2 arguments: the list's href and the passed name or identifier, so:
|
72
86
|
|
73
87
|
```ruby
|
74
88
|
class ApiBee::Adapters::Special
|
75
89
|
# ...
|
76
|
-
def
|
90
|
+
def get_one(href, id)
|
77
91
|
get "#{href}/#{id}"
|
78
92
|
end
|
79
93
|
end
|
data/examples/github_api.rb
CHANGED
@@ -1,14 +1,15 @@
|
|
1
1
|
$LOAD_PATH.unshift '../lib'
|
2
2
|
require 'api_bee'
|
3
|
-
#require 'net/http'
|
4
3
|
require 'net/https'
|
5
4
|
require 'uri'
|
6
5
|
require 'json'
|
7
6
|
|
7
|
+
# Github adapter wraps raw Github API response and decorates collections with
|
8
|
+
# pagination parameters needed by APIBee
|
9
|
+
#
|
8
10
|
class GithubAdapter
|
9
11
|
|
10
12
|
def initialize
|
11
|
-
ApiBee.config.uri_property_name = :url
|
12
13
|
@url = URI.parse('https://api.github.com')
|
13
14
|
@http = Net::HTTP.new(@url.host, @url.port)
|
14
15
|
@http.use_ssl = true
|
@@ -16,8 +17,6 @@ class GithubAdapter
|
|
16
17
|
end
|
17
18
|
|
18
19
|
def get(path, options = {})
|
19
|
-
per_page = (options[:per_page] || 20).to_i
|
20
|
-
page = (options[:page] || 1).to_i
|
21
20
|
|
22
21
|
q = options.map{|k,v| "#{k}=#{v}"}.join('&')
|
23
22
|
|
@@ -28,21 +27,9 @@ class GithubAdapter
|
|
28
27
|
|
29
28
|
if response.kind_of?(Net::HTTPOK)
|
30
29
|
results = JSON.parse response.body
|
31
|
-
if results.is_a?(Array)
|
30
|
+
results = if results.is_a?(Array)
|
32
31
|
# decorate returned array so it complies with APiBee's pagination params
|
33
|
-
results
|
34
|
-
:entries => results,
|
35
|
-
:page => page,
|
36
|
-
:per_page => per_page,
|
37
|
-
:url => path
|
38
|
-
}
|
39
|
-
# Extract last page number and entries count. Github uses a 'Link' header
|
40
|
-
if link = response["link"]
|
41
|
-
last_page = extract_last_page(link) || page # if no 'last' link, we're on the last page
|
42
|
-
results.update(
|
43
|
-
:total_entries => last_page.to_i * per_page
|
44
|
-
)
|
45
|
-
end
|
32
|
+
paginate results, options, path, response
|
46
33
|
else
|
47
34
|
results
|
48
35
|
end
|
@@ -54,12 +41,32 @@ class GithubAdapter
|
|
54
41
|
|
55
42
|
end
|
56
43
|
|
57
|
-
def
|
44
|
+
def get_one(href, id)
|
58
45
|
get id
|
59
46
|
end
|
60
47
|
|
61
48
|
protected
|
62
49
|
|
50
|
+
def paginate(results, options, path, response)
|
51
|
+
per_page = (options[:per_page] || 20).to_i
|
52
|
+
page = (options[:page] || 1).to_i
|
53
|
+
|
54
|
+
results = {
|
55
|
+
:entries => results,
|
56
|
+
:page => page,
|
57
|
+
:per_page => per_page,
|
58
|
+
:url => path
|
59
|
+
}
|
60
|
+
# Extract last page number and entries count. Github uses a 'Link' header
|
61
|
+
if link = response["link"]
|
62
|
+
last_page = extract_last_page(link) || page # if no 'last' link, we're on the last page
|
63
|
+
results.update(
|
64
|
+
:total_entries => last_page.to_i * per_page
|
65
|
+
)
|
66
|
+
end
|
67
|
+
results
|
68
|
+
end
|
69
|
+
|
63
70
|
def extract_last_page(link)
|
64
71
|
aa = link.split('<https')
|
65
72
|
last_link = aa.find{|e| e=~ /rel="last"/}
|
@@ -69,12 +76,17 @@ class GithubAdapter
|
|
69
76
|
|
70
77
|
end
|
71
78
|
|
79
|
+
########### USAGE ##################################
|
80
|
+
|
72
81
|
## Instantiate your wrapped API
|
73
82
|
|
74
|
-
api = ApiBee.setup(GithubAdapter)
|
83
|
+
api = ApiBee.setup(GithubAdapter) do |config|
|
84
|
+
config.uri_property_name = :url
|
85
|
+
end
|
75
86
|
|
76
87
|
repos = api.get('/users/ismasan/repos')
|
77
88
|
|
89
|
+
# Recursive method prints results for each page
|
78
90
|
def show(data, c)
|
79
91
|
puts "+++++++++++++++ Page #{data.current_page} of #{data.total_pages} (#{data.total_entries} entries). #{data.size} now. ++++++++"
|
80
92
|
puts
|
@@ -97,7 +109,7 @@ show repos, 1
|
|
97
109
|
puts "First created at is: #{repos.first[:created_at]}"
|
98
110
|
|
99
111
|
# Fetch a single node
|
100
|
-
one = repos.
|
112
|
+
one = repos.get_one('https://api.github.com/repos/ismasan/websockets_examples')
|
101
113
|
|
102
114
|
# one[:owner][:public_repos] will trigger a new request to the resource URL because that property is not available in the excerpt
|
103
115
|
#
|
@@ -25,7 +25,7 @@ module ApiBee
|
|
25
25
|
found
|
26
26
|
end
|
27
27
|
|
28
|
-
def
|
28
|
+
def get_one(href, id)
|
29
29
|
get("#{href}/#{id}")
|
30
30
|
end
|
31
31
|
|
@@ -57,7 +57,7 @@ module ApiBee
|
|
57
57
|
end
|
58
58
|
|
59
59
|
def is_paginated?(hash)
|
60
|
-
hash[
|
60
|
+
hash[:href] && hash[:total_entries]
|
61
61
|
end
|
62
62
|
|
63
63
|
def paginate(list, page, per_page)
|
data/lib/api_bee/node.rb
CHANGED
@@ -9,20 +9,21 @@ module ApiBee
|
|
9
9
|
end
|
10
10
|
end
|
11
11
|
|
12
|
-
def self.resolve(adapter, attrs, href = nil)
|
12
|
+
def self.resolve(adapter, config, attrs, href = nil)
|
13
13
|
attrs = simbolized(attrs)
|
14
14
|
keys = attrs.keys.map{|k| k.to_sym}
|
15
|
-
if keys.include?(
|
16
|
-
List.new adapter, attrs, href
|
15
|
+
if keys.include?(config.total_entries_property_name) && keys.include?(config.uri_property_name.to_sym) # is a paginator
|
16
|
+
List.new adapter, config, attrs, href
|
17
17
|
else
|
18
|
-
Single.new adapter, attrs, href
|
18
|
+
Single.new adapter, config, attrs, href
|
19
19
|
end
|
20
20
|
end
|
21
21
|
|
22
22
|
attr_reader :adapter
|
23
23
|
|
24
|
-
def initialize(adapter, attrs, href)
|
24
|
+
def initialize(adapter, config, attrs, href)
|
25
25
|
@adapter = adapter
|
26
|
+
@config = config
|
26
27
|
@attributes = {}
|
27
28
|
@href = href
|
28
29
|
update_attributes attrs
|
@@ -54,7 +55,7 @@ module ApiBee
|
|
54
55
|
def resolve_values_to_nodes(value)
|
55
56
|
case value
|
56
57
|
when ::Hash
|
57
|
-
Node.resolve @adapter, value
|
58
|
+
Node.resolve @adapter, @config, value
|
58
59
|
when ::Array
|
59
60
|
value.map {|v| resolve_values_to_nodes(v)} # recurse
|
60
61
|
else
|
@@ -63,11 +64,11 @@ module ApiBee
|
|
63
64
|
end
|
64
65
|
|
65
66
|
def has_more?
|
66
|
-
!@complete && @attributes[
|
67
|
+
!@complete && @attributes[@config.uri_property_name]
|
67
68
|
end
|
68
69
|
|
69
70
|
def load_more!
|
70
|
-
more_data = @adapter.get(@attributes[
|
71
|
+
more_data = @adapter.get(@attributes[@config.uri_property_name])
|
71
72
|
update_attributes Node.simbolized(more_data) if more_data
|
72
73
|
@complete = true
|
73
74
|
end
|
@@ -80,9 +81,9 @@ module ApiBee
|
|
80
81
|
|
81
82
|
DEFAULT_PER_PAGE = 100
|
82
83
|
|
83
|
-
def
|
84
|
-
data = @adapter.
|
85
|
-
data.nil? ? nil : Node.resolve(@adapter, data, @href)
|
84
|
+
def get_one(id)
|
85
|
+
data = @adapter.get_one(@href, id)
|
86
|
+
data.nil? ? nil : Node.resolve(@adapter, @config, data, @href)
|
86
87
|
end
|
87
88
|
|
88
89
|
def total_entries
|
@@ -143,14 +144,14 @@ module ApiBee
|
|
143
144
|
end
|
144
145
|
|
145
146
|
def paginate(options = {})
|
146
|
-
data = @adapter.get(@attributes[
|
147
|
-
Node.resolve @adapter, data, @href
|
147
|
+
data = @adapter.get(@attributes[@config.uri_property_name], options)
|
148
|
+
Node.resolve @adapter, @config, data, @href
|
148
149
|
end
|
149
150
|
|
150
151
|
protected
|
151
152
|
|
152
153
|
def __entries
|
153
|
-
@entries ||= (self[
|
154
|
+
@entries ||= (self[@config.entries_property_name] || [])
|
154
155
|
end
|
155
156
|
|
156
157
|
end
|
data/lib/api_bee/proxy.rb
CHANGED
@@ -4,15 +4,16 @@ module ApiBee
|
|
4
4
|
|
5
5
|
attr_reader :adapter
|
6
6
|
|
7
|
-
def initialize(adapter, href = nil, opts = nil)
|
7
|
+
def initialize(adapter, config, href = nil, opts = nil)
|
8
8
|
@adapter = adapter
|
9
|
+
@config = config
|
9
10
|
@href = href
|
10
11
|
@opts = opts
|
11
12
|
end
|
12
13
|
|
13
14
|
def get(href, opts = {})
|
14
15
|
# Just delegate. No API calls at this point. We only load data when we need it.
|
15
|
-
Proxy.new @adapter, href, opts
|
16
|
+
Proxy.new @adapter, @config, href, opts
|
16
17
|
end
|
17
18
|
|
18
19
|
def [](key)
|
@@ -24,20 +25,32 @@ module ApiBee
|
|
24
25
|
end
|
25
26
|
|
26
27
|
def paginate(*args)
|
27
|
-
@list ||= Node::List.new(@adapter, {
|
28
|
+
@list ||= Node::List.new(@adapter, @config, {@config.uri_property_name => @href}, @href)
|
28
29
|
@list.paginate *args
|
29
30
|
end
|
30
31
|
|
32
|
+
def ==(other)
|
33
|
+
_node.to_data
|
34
|
+
end
|
35
|
+
|
31
36
|
protected
|
32
37
|
|
33
38
|
def method_missing(method_name, *args, &block)
|
34
|
-
|
39
|
+
if _adapter_delegators.include?(method_name)
|
40
|
+
@adapter.send(method_name, *args, &block)
|
41
|
+
else
|
42
|
+
_node.send(method_name, *args, &block)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def _adapter_delegators
|
47
|
+
@adapter_delegators ||= @config.adapter_delegators || []
|
35
48
|
end
|
36
49
|
|
37
50
|
def _node
|
38
51
|
@node ||= (
|
39
52
|
data = @adapter.get(@href, @opts)
|
40
|
-
Node.resolve @adapter, data, @href
|
53
|
+
Node.resolve @adapter, @config, data, @href
|
41
54
|
)
|
42
55
|
end
|
43
56
|
|
data/lib/api_bee/version.rb
CHANGED
data/lib/api_bee.rb
CHANGED
@@ -6,8 +6,6 @@ module ApiBee
|
|
6
6
|
|
7
7
|
def setup(adapter_klass, *args)
|
8
8
|
|
9
|
-
yield config if block_given?
|
10
|
-
|
11
9
|
adapter = if adapter_klass.is_a?(::Symbol)
|
12
10
|
require File.join('api_bee', 'adapters', adapter_klass.to_s)
|
13
11
|
klass = adapter_klass.to_s.gsub(/(^.{1})/){$1.upcase}
|
@@ -17,18 +15,37 @@ module ApiBee
|
|
17
15
|
end
|
18
16
|
|
19
17
|
raise NoMethodError, "Adapter must implement #get(path, *args) method" unless adapter.respond_to?(:get)
|
20
|
-
|
18
|
+
|
19
|
+
config = new_config
|
20
|
+
yield config if block_given?
|
21
|
+
Proxy.new adapter, config
|
22
|
+
end
|
23
|
+
|
24
|
+
# new config object with defaults
|
25
|
+
def new_config
|
26
|
+
Config.new(
|
27
|
+
# This field is expected in API responses
|
28
|
+
# and should point to an individual resource with more data
|
29
|
+
:uri_property_name => :href,
|
30
|
+
# Total number of entries
|
31
|
+
# Used to paginate lists
|
32
|
+
:total_entries_property_name => :total_entries,
|
33
|
+
# Name of array property
|
34
|
+
# that contains cureent page's entries
|
35
|
+
:entries_property_name => :entries
|
36
|
+
)
|
21
37
|
end
|
22
38
|
|
23
|
-
|
24
|
-
|
39
|
+
class Config < OpenStruct
|
40
|
+
|
41
|
+
def expose(*fields)
|
42
|
+
self.adapter_delegators = fields
|
43
|
+
end
|
44
|
+
|
25
45
|
end
|
26
46
|
|
27
47
|
end
|
28
48
|
|
29
|
-
# Defaults
|
30
|
-
self.config.uri_property_name = :href
|
31
|
-
|
32
49
|
module Adapters
|
33
50
|
|
34
51
|
end
|
data/spec/node_spec.rb
CHANGED
@@ -86,17 +86,18 @@ describe ApiBee do
|
|
86
86
|
|
87
87
|
before do
|
88
88
|
@adapter = mock('Adapter')
|
89
|
+
@config = ApiBee.new_config
|
89
90
|
end
|
90
91
|
|
91
92
|
it 'should resolve single nodes' do
|
92
|
-
node = ApiBee::Node.resolve(@adapter, {:title => 'Blah', :foo => [1,2,3]})
|
93
|
+
node = ApiBee::Node.resolve(@adapter, @config, {:title => 'Blah', :foo => [1,2,3]})
|
93
94
|
node[:title].should == 'Blah'
|
94
95
|
node.adapter.should == @adapter
|
95
96
|
node.should be_kind_of(ApiBee::Node::Single)
|
96
97
|
end
|
97
98
|
|
98
99
|
it 'should symbolize hash keys' do
|
99
|
-
node = ApiBee::Node.resolve(@adapter, {
|
100
|
+
node = ApiBee::Node.resolve(@adapter, @config, {
|
100
101
|
'title' => 'Blah',
|
101
102
|
'total_entries' => 4,
|
102
103
|
'href' => '/products',
|
@@ -109,7 +110,7 @@ describe ApiBee do
|
|
109
110
|
end
|
110
111
|
|
111
112
|
it 'should resolve paginated lists' do
|
112
|
-
node = ApiBee::Node.resolve(@adapter, {:title => 'Blah', :total_entries => 4, :href => '/products'})
|
113
|
+
node = ApiBee::Node.resolve(@adapter, @config, {:title => 'Blah', :total_entries => 4, :href => '/products'})
|
113
114
|
node.total_entries.should == 4
|
114
115
|
node.adapter.should == @adapter
|
115
116
|
node.should be_kind_of(ApiBee::Node::List)
|
@@ -138,25 +139,25 @@ describe ApiBee do
|
|
138
139
|
end
|
139
140
|
end
|
140
141
|
|
141
|
-
describe '#
|
142
|
+
describe '#get_one' do
|
142
143
|
|
143
144
|
before do
|
144
145
|
@products = @api.get('/products', :page => 1, :per_page => 2)
|
145
146
|
end
|
146
147
|
|
147
148
|
it 'should delegate to adapter. It knows how to find individual resoruces' do
|
148
|
-
@adapter.should_receive(:
|
149
|
-
@products.
|
149
|
+
@adapter.should_receive(:get_one).with('/products', 'foo-1')
|
150
|
+
@products.get_one('foo-1')
|
150
151
|
end
|
151
152
|
|
152
153
|
it 'should return a Node::Single' do
|
153
|
-
node = @products.
|
154
|
+
node = @products.get_one('foo-1')
|
154
155
|
node.should be_kind_of(ApiBee::Node::Single)
|
155
156
|
node[:title].should == 'Foo 1'
|
156
157
|
end
|
157
158
|
|
158
159
|
it 'should return nil if not found' do
|
159
|
-
@products.
|
160
|
+
@products.get_one('foo-1000').should be_nil
|
160
161
|
end
|
161
162
|
|
162
163
|
end
|
data/spec/setup_spec.rb
CHANGED
@@ -10,23 +10,6 @@ describe 'ApiBee.setup' do
|
|
10
10
|
end
|
11
11
|
end
|
12
12
|
|
13
|
-
describe 'config block' do
|
14
|
-
|
15
|
-
it 'should have default uri_property_name field name' do
|
16
|
-
ApiBee.config.uri_property_name.should == :href
|
17
|
-
end
|
18
|
-
|
19
|
-
it 'should set global variables' do
|
20
|
-
api = ApiBee.setup(:hash, {}) do |config|
|
21
|
-
config.foo = 11
|
22
|
-
config.bar = 22
|
23
|
-
end
|
24
|
-
|
25
|
-
ApiBee.config.foo.should == 11
|
26
|
-
ApiBee.config.bar.should == 22
|
27
|
-
end
|
28
|
-
end
|
29
|
-
|
30
13
|
describe 'with custom adapter class' do
|
31
14
|
before do
|
32
15
|
|
@@ -57,4 +40,96 @@ describe 'ApiBee.setup' do
|
|
57
40
|
end
|
58
41
|
|
59
42
|
end
|
43
|
+
|
44
|
+
context 'configuration' do
|
45
|
+
|
46
|
+
before do
|
47
|
+
@api1 = ApiBee.setup(:hash, {
|
48
|
+
:user => {
|
49
|
+
:name => 'ismael 1',
|
50
|
+
:href => '/users/ismael1'
|
51
|
+
}
|
52
|
+
})
|
53
|
+
|
54
|
+
@api2 = ApiBee.setup(:hash, {
|
55
|
+
:user => {
|
56
|
+
:name => 'ismael 2',
|
57
|
+
:url => '/users/ismael2'
|
58
|
+
}
|
59
|
+
}) do |config|
|
60
|
+
config.uri_property_name = :url
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe 'API config' do
|
65
|
+
|
66
|
+
it 'should produce a config object with default values' do
|
67
|
+
config = ApiBee.new_config
|
68
|
+
config.uri_property_name.should == :href
|
69
|
+
config.total_entries_property_name.should == :total_entries
|
70
|
+
config.entries_property_name.should == :entries
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'should have default :href configured' do
|
74
|
+
user = @api1.get('/user')
|
75
|
+
user[:name].should == 'ismael 1'
|
76
|
+
@api1.adapter.should_receive(:get).with('/users/ismael1').and_return(:last_name => 'Celis')
|
77
|
+
|
78
|
+
user[:last_name].should == 'Celis'
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'should overwrite config for individual apis' do
|
82
|
+
user1 = @api1.get('/user')
|
83
|
+
user1[:name].should == 'ismael 1'
|
84
|
+
@api1.adapter.should_receive(:get).with('/users/ismael1').and_return(:last_name => 'Celis 1')
|
85
|
+
|
86
|
+
user1[:last_name].should == 'Celis 1'
|
87
|
+
|
88
|
+
user2 = @api2.get('/user')
|
89
|
+
user2[:name].should == 'ismael 2'
|
90
|
+
@api2.adapter.should_receive(:get).with('/users/ismael2').and_return(:last_name => 'Celis 2')
|
91
|
+
|
92
|
+
user2[:last_name].should == 'Celis 2'
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
|
97
|
+
describe 'delegate to adapter' do
|
98
|
+
|
99
|
+
before do
|
100
|
+
|
101
|
+
adapter = Class.new(ApiBee::Adapters::Hash) do
|
102
|
+
def fetch(*args)
|
103
|
+
@data.fetch *args
|
104
|
+
end
|
105
|
+
|
106
|
+
def keys(*args)
|
107
|
+
@data.keys *args
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
@api = ApiBee.setup(adapter, {
|
112
|
+
:a => {:name => 1},
|
113
|
+
:b => {:name => 2}
|
114
|
+
}) do |config|
|
115
|
+
config.expose :fetch, :keys
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
|
120
|
+
it 'should still work' do
|
121
|
+
@api.get('/a').should == {:name => 1}
|
122
|
+
@api.get('/b').should == {:name => 2}
|
123
|
+
end
|
124
|
+
|
125
|
+
it 'should delegate configured methods on to adapter' do
|
126
|
+
@api.fetch(:a).should == {:name => 1}
|
127
|
+
@api.fetch(:x, 'X').should == 'X'
|
128
|
+
|
129
|
+
@api.keys.should == [:a, :b]
|
130
|
+
end
|
131
|
+
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
60
135
|
end
|
metadata
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
name: api_bee
|
3
3
|
version: !ruby/object:Gem::Version
|
4
4
|
prerelease:
|
5
|
-
version: 0.0.
|
5
|
+
version: 0.0.3
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
8
8
|
- Ismael Celis
|
@@ -10,7 +10,7 @@ autorequire:
|
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
12
|
|
13
|
-
date: 2011-09-
|
13
|
+
date: 2011-09-22 00:00:00 Z
|
14
14
|
dependencies: []
|
15
15
|
|
16
16
|
description: API Bee is a small client / spec for a particular style of JSON API. USe Hash adapter for local data access.
|