heroics 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +171 -20
- data/lib/heroics.rb +1 -0
- data/lib/heroics/cli.rb +14 -1
- data/lib/heroics/client.rb +4 -0
- data/lib/heroics/link.rb +22 -2
- data/lib/heroics/version.rb +1 -1
- data/test/cli_test.rb +23 -0
- data/test/client_test.rb +35 -0
- metadata +4 -4
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# Heroics
|
2
2
|
|
3
|
-
|
3
|
+
Ruby HTTP client for APIs represented with JSON schema.
|
4
4
|
|
5
5
|
## Installation
|
6
6
|
|
@@ -18,36 +18,187 @@ Or install it yourself as:
|
|
18
18
|
|
19
19
|
## Usage
|
20
20
|
|
21
|
-
|
21
|
+
### Generate a client from a JSON schema
|
22
22
|
|
23
|
-
|
23
|
+
Heroics generates an HTTP client from a JSON schema. The simplest way
|
24
|
+
to get started is to provide the URL to the schema you want to base
|
25
|
+
the client on:
|
24
26
|
|
27
|
+
```ruby
|
28
|
+
require 'cgi'
|
29
|
+
require 'heroics'
|
30
|
+
|
31
|
+
username = CGI.escape('username')
|
32
|
+
token = 'token'
|
33
|
+
url = "https://#{username}:#{token}@api.heroku.com/schema"
|
34
|
+
options = {default_headers: {'Accept' => 'application/vnd.heroku+json; version=3'}}
|
35
|
+
client = Heroics.client_from_schema_url(url, options)
|
36
|
+
```
|
37
|
+
|
38
|
+
The client will make requests to the API using the credentials from
|
39
|
+
the URL. The default headers will also be included in all requests
|
40
|
+
(including the one to download the schema and all subsequent
|
41
|
+
requests).
|
42
|
+
|
43
|
+
You can also create a client from an in-memory schema object:
|
44
|
+
|
45
|
+
```ruby
|
46
|
+
require 'cgi'
|
47
|
+
require 'json'
|
48
|
+
require 'heroics'
|
49
|
+
|
50
|
+
username = CGI.escape('username')
|
51
|
+
token = 'token'
|
52
|
+
url = "https://#{username}:#{token}@api.heroku.com/schema"
|
53
|
+
options = {default_headers: {'Accept' => 'application/vnd.heroku+json; version=3'}}
|
54
|
+
data = JSON.parse(File.read('schema.json'))
|
55
|
+
schema = Heroics::Schema.new(data)
|
56
|
+
client = Heroics.client_from_schema(schema, url, options)
|
57
|
+
```
|
58
|
+
|
59
|
+
### Client-side caching
|
60
|
+
|
61
|
+
Heroics handles ETags and will cache data on the client if you provide
|
62
|
+
a [Moneta](https://github.com/minad/moneta) cache instance.
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
username = 'username'
|
66
|
+
token = 'token'
|
67
|
+
url = "https://#{username}:#{token}@api.heroku.com/schema"
|
68
|
+
options = {default_headers: {'Accept' => 'application/vnd.heroku+json; version=3'},
|
69
|
+
cache: Moneta.new(:File, dir: "#{Dir.home}/.heroics/heroku-api")}
|
70
|
+
client = Heroics.client_from_schema_url(url, options)
|
71
|
+
```
|
72
|
+
|
73
|
+
### Making requests
|
74
|
+
|
75
|
+
The client exposes resources as top-level methods. Links described in
|
76
|
+
the JSON schema for those resources are represented as methods on
|
77
|
+
those top-level resources. For example, you can [list the apps](https://devcenter.heroku.com/articles/platform-api-reference#app-list)
|
78
|
+
in your Heroku account:
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
apps = client.app.list
|
82
|
+
```
|
83
|
+
|
84
|
+
The response received from the server will be returned without
|
85
|
+
modifications. Response content with type `application/json` is
|
86
|
+
automatically decoded into a Ruby object.
|
87
|
+
|
88
|
+
### Handling content ranges
|
89
|
+
|
90
|
+
Content ranges are handled transparently. In such cases the client
|
91
|
+
will return an `Enumerator` that can be used to access the data. It
|
92
|
+
only makes requests to the server to fetch additional data when the
|
93
|
+
current batch has been exhausted.
|
94
|
+
|
95
|
+
### Command-line interface
|
96
|
+
|
97
|
+
Heroics includes a builtin CLI that, like the client, is generated
|
98
|
+
from a JSON schema.
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
username = 'username'
|
102
|
+
token = 'token'
|
103
|
+
url = "https://#{username}:#{token}@api.heroku.com/schema"
|
104
|
+
options = {
|
105
|
+
default_headers: {'Accept' => 'application/vnd.heroku+json; version=3'},
|
106
|
+
cache: Moneta.new(:File, dir: "#{Dir.home}/.heroics/heroku-api")}
|
107
|
+
cli = Heroics.cli_from_schema_url('heroku-api', STDOUT, url, options)
|
108
|
+
cli.run(*ARGV)
|
109
|
+
```
|
110
|
+
|
111
|
+
Running it without arguments displays usage information:
|
112
|
+
|
113
|
+
```
|
114
|
+
$ bundle exec bin/heroku-api
|
115
|
+
Usage: heroku-api <command> [<parameter> [...]] [<body>]
|
116
|
+
|
117
|
+
Help topics, type "heroku-api help <topic>" for more details:
|
118
|
+
|
119
|
+
account-feature:info Info for an existing account feature.
|
120
|
+
account-feature:list List existing account features.
|
121
|
+
account-feature:update Update an existing account feature.
|
122
|
+
account:change-email Change Email for account.
|
123
|
+
account:change-password Change Password for account.
|
124
|
+
account:info Info for account.
|
125
|
+
account:update Update account.
|
126
|
+
addon-service:info Info for existing addon-service.
|
127
|
+
addon-service:list List existing addon-services.
|
128
|
+
addon:create Create a new add-on.
|
129
|
+
--- 8< --- snip --- 8< ---
|
130
|
+
```
|
131
|
+
|
132
|
+
Use the `help` command to learn about commands:
|
133
|
+
|
134
|
+
```
|
135
|
+
$ bundle exec bin/heroku-api help app:create
|
136
|
+
Usage: heroku-api app:create <body>
|
137
|
+
|
138
|
+
Description:
|
139
|
+
Create a new app.
|
140
|
+
|
141
|
+
Body example:
|
142
|
+
{
|
143
|
+
"name": "example",
|
144
|
+
"region": "",
|
145
|
+
"stack": ""
|
146
|
+
}
|
147
|
+
```
|
148
|
+
|
149
|
+
In addition to being a fun way to play with your API it also gives you
|
150
|
+
the basic information you need to use the same command from Ruby:
|
151
|
+
|
152
|
+
```ruby
|
153
|
+
client.app.create({'name' => 'example',
|
154
|
+
'region' => '',
|
155
|
+
'stack' => ''})
|
156
|
+
```
|
157
|
+
|
158
|
+
### Command arguments
|
159
|
+
|
160
|
+
Commands that take arguments will list them in help output from the
|
161
|
+
client.
|
162
|
+
|
163
|
+
```
|
164
|
+
$ bundle exec bin/heroku-api help app:info
|
165
|
+
Usage: heroku-api app:info <id|name>
|
166
|
+
|
167
|
+
Description:
|
168
|
+
Info for existing app.
|
25
169
|
```
|
26
|
-
heroics = Heroics.new(token: ENV['HEROKU_API_TOKEN'])
|
27
170
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
171
|
+
This command needs an app's UUID or name:
|
172
|
+
|
173
|
+
```ruby
|
174
|
+
info = client.app.info('sushi')
|
175
|
+
```
|
176
|
+
|
177
|
+
Some commands need arguments as well as a body. In such cases, pass
|
178
|
+
the arguments first with the body at the end.
|
179
|
+
|
180
|
+
### Using the Heroku API
|
181
|
+
|
182
|
+
Heroics comes with a builtin `heroku-api` program that serves as an
|
183
|
+
example and makes it easy to play with the [Heroku Platform API](https://devcenter.heroku.com/articles/platform-api-reference).
|
32
184
|
|
33
|
-
|
34
|
-
app.update(name: 'rename') # returns updated app
|
35
|
-
app.delete # returns deleted app
|
185
|
+
### Handling failures
|
36
186
|
|
37
|
-
|
38
|
-
|
39
|
-
app.addons.create(plan: { name: 'heroku-postgresql:dev' }) # returns new add-on with plan:name 'heroku-postgresql:dev'
|
40
|
-
app.addons.list # returns list of all add-ons for app with id or name of 'example'
|
187
|
+
The client uses [Excon](https://github.com/geemus/excon) under the hood and raises Excon errors when
|
188
|
+
failures occur.
|
41
189
|
|
42
|
-
|
43
|
-
|
44
|
-
|
190
|
+
```ruby
|
191
|
+
begin
|
192
|
+
client.app.create({'name' => 'example'})
|
193
|
+
rescue Excon::Errors::Forbidden => e
|
194
|
+
puts e
|
195
|
+
end
|
45
196
|
```
|
46
197
|
|
47
198
|
## Contributing
|
48
199
|
|
49
|
-
1. Fork
|
200
|
+
1. [Fork the repository](https://github.com/heroku/heroics/fork)
|
50
201
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
51
202
|
3. Commit your changes (`git commit -am 'Add some feature'`)
|
52
203
|
4. Push to the branch (`git push origin my-new-feature`)
|
53
|
-
5. Create new
|
204
|
+
5. Create new pull request
|
data/lib/heroics.rb
CHANGED
data/lib/heroics/cli.rb
CHANGED
@@ -61,8 +61,21 @@ USAGE
|
|
61
61
|
end
|
62
62
|
end
|
63
63
|
|
64
|
+
# Create a CLI from a JSON schema.
|
65
|
+
#
|
66
|
+
# @param name [String] The name of the CLI.
|
67
|
+
# @param output [IO] The stream to write to.
|
68
|
+
# @param schema [Hash] The JSON schema to use with the CLI.
|
69
|
+
# @param url [String] The URL used by the generated CLI when it makes
|
70
|
+
# requests.
|
71
|
+
# @param options [Hash] Configuration for links. Possible keys include:
|
72
|
+
# - default_headers: Optionally, a set of headers to include in every
|
73
|
+
# request made by the CLI. Default is no custom headers.
|
74
|
+
# - cache: Optionally, a Moneta-compatible cache to store ETags. Default
|
75
|
+
# is no caching.
|
76
|
+
# @return [CLI] A CLI with commands generated from the JSON schema.
|
64
77
|
def self.cli_from_schema(name, output, schema, url, options={})
|
65
|
-
client = client_from_schema(schema, url, options)
|
78
|
+
client = client_from_schema(schema, URI::join(url, '/').to_s, options)
|
66
79
|
commands = {}
|
67
80
|
schema.resources.each do |resource_schema|
|
68
81
|
resource_schema.links.each do |link_schema|
|
data/lib/heroics/client.rb
CHANGED
@@ -15,8 +15,12 @@ module Heroics
|
|
15
15
|
# @raise [NoMethodError] Raised if the name doesn't match a known resource.
|
16
16
|
# @return [Resource] The resource matching the name.
|
17
17
|
def method_missing(name)
|
18
|
+
name = name.to_s.gsub('_', '-')
|
18
19
|
resource = @resources[name.to_s]
|
19
20
|
if resource.nil?
|
21
|
+
# TODO(jkakar) Do we care about resource names in the schema specified
|
22
|
+
# with underscores? If so, we should check to make sure the name
|
23
|
+
# mangling we did above was actually a bad idea.
|
20
24
|
address = "<#{self.class.name}:0x00#{(self.object_id << 1).to_s(16)}>"
|
21
25
|
raise NoMethodError.new("undefined method `#{name}' for ##{address}")
|
22
26
|
end
|
data/lib/heroics/link.rb
CHANGED
@@ -13,7 +13,7 @@ module Heroics
|
|
13
13
|
# - cache: Optionally, a Moneta-compatible cache to store ETags.
|
14
14
|
# Default is no caching.
|
15
15
|
def initialize(url, link_schema, options={})
|
16
|
-
@
|
16
|
+
@root_url, @path_prefix = unpack_url(url)
|
17
17
|
@link_schema = link_schema
|
18
18
|
@default_headers = options[:default_headers] || {}
|
19
19
|
@cache = options[:cache] || Moneta.new(:Null)
|
@@ -44,6 +44,7 @@ module Heroics
|
|
44
44
|
# object for JSON responses, or an enumerator for list responses.
|
45
45
|
def run(*parameters)
|
46
46
|
path, body = @link_schema.format_path(parameters)
|
47
|
+
path = "#{@path_prefix}#{path}" unless @path_prefix == '/'
|
47
48
|
headers = @default_headers
|
48
49
|
if body
|
49
50
|
headers = headers.merge({'Content-Type' => 'application/json'})
|
@@ -55,7 +56,7 @@ module Heroics
|
|
55
56
|
headers = headers.merge({'If-None-Match' => etag}) if etag
|
56
57
|
end
|
57
58
|
|
58
|
-
connection = Excon.new(@
|
59
|
+
connection = Excon.new(@root_url)
|
59
60
|
response = connection.request(method: @link_schema.method, path: path,
|
60
61
|
headers: headers, body: body,
|
61
62
|
expects: [200, 201, 202, 206, 304])
|
@@ -96,5 +97,24 @@ module Heroics
|
|
96
97
|
response.body
|
97
98
|
end
|
98
99
|
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
# Unpack the URL and split it into a root URL and a path prefix, if one
|
104
|
+
# exists.
|
105
|
+
#
|
106
|
+
# @param url [String] The complete base URL to use when making requests.
|
107
|
+
# @return [String,String] A (root URL, path) prefix pair.
|
108
|
+
def unpack_url(url)
|
109
|
+
root_url = []
|
110
|
+
path_prefix = ''
|
111
|
+
parts = URI.split(url)
|
112
|
+
root_url << "#{parts[0]}://"
|
113
|
+
root_url << "#{parts[1]}@" unless parts[1].nil?
|
114
|
+
root_url << "#{parts[2]}"
|
115
|
+
root_url << ":#{parts[3]}" unless parts[3].nil?
|
116
|
+
path_prefix = parts[5]
|
117
|
+
return root_url.join(''), path_prefix
|
118
|
+
end
|
99
119
|
end
|
100
120
|
end
|
data/lib/heroics/version.rb
CHANGED
data/test/cli_test.rb
CHANGED
@@ -188,6 +188,29 @@ class CLIFromSchemaTest < MiniTest::Unit::TestCase
|
|
188
188
|
assert_equal(MultiJson.dump(result, pretty: true) + "\n", output.string)
|
189
189
|
end
|
190
190
|
|
191
|
+
# cli_from_schema returns a CLI that can make requests to APIs mounted under
|
192
|
+
# a prefix, such as http://example.com/api, for example.
|
193
|
+
def test_client_from_schema_with_url_prefix
|
194
|
+
uuid = '1ab1c589-df46-40aa-b786-60e83b1efb10'
|
195
|
+
body = {'Hello' => 'World!'}
|
196
|
+
result = {'Goodbye' => 'Universe!'}
|
197
|
+
Excon.stub(method: :patch) do |request|
|
198
|
+
assert_equal("/api/resource/#{uuid}", request[:path])
|
199
|
+
assert_equal('application/json', request[:headers]['Content-Type'])
|
200
|
+
assert_equal(body, MultiJson.load(request[:body]))
|
201
|
+
Excon.stubs.pop
|
202
|
+
{status: 200, headers: {'Content-Type' => 'application/json'},
|
203
|
+
body: MultiJson.dump(result)}
|
204
|
+
end
|
205
|
+
|
206
|
+
schema = Heroics::Schema.new(SAMPLE_SCHEMA)
|
207
|
+
output = StringIO.new
|
208
|
+
cli = Heroics.cli_from_schema('cli', output, schema,
|
209
|
+
'https://example.com/api')
|
210
|
+
cli.run('resource:update', uuid, body)
|
211
|
+
assert_equal(MultiJson.dump(result, pretty: true) + "\n", output.string)
|
212
|
+
end
|
213
|
+
|
191
214
|
# cli_from_schema optionally accepts custom headers to pass with every
|
192
215
|
# request made by the generated CLI.
|
193
216
|
def test_cli_from_schema_with_custom_headers
|
data/test/client_test.rb
CHANGED
@@ -33,6 +33,26 @@ class ClientTest < MiniTest::Unit::TestCase
|
|
33
33
|
end
|
34
34
|
assert_equal('Hello, world!', client.resource.link)
|
35
35
|
end
|
36
|
+
|
37
|
+
# Client converts underscores in resource method names to dashes to match
|
38
|
+
# names specified in the schema.
|
39
|
+
def test_resource_with_dashed_name
|
40
|
+
schema = Heroics::Schema.new(SAMPLE_SCHEMA)
|
41
|
+
link = Heroics::Link.new('https://username:secret@example.com',
|
42
|
+
schema.resource('another-resource').link('list'))
|
43
|
+
resource = Heroics::Resource.new({'link' => link})
|
44
|
+
client = Heroics::Client.new({'another-resource' => resource})
|
45
|
+
Excon.stub(method: :get) do |request|
|
46
|
+
assert_equal('Basic dXNlcm5hbWU6c2VjcmV0',
|
47
|
+
request[:headers]['Authorization'])
|
48
|
+
assert_equal('example.com', request[:host])
|
49
|
+
assert_equal(443, request[:port])
|
50
|
+
assert_equal('/another-resource', request[:path])
|
51
|
+
Excon.stubs.pop
|
52
|
+
{status: 200, body: 'Hello, world!'}
|
53
|
+
end
|
54
|
+
assert_equal('Hello, world!', client.another_resource.link)
|
55
|
+
end
|
36
56
|
end
|
37
57
|
|
38
58
|
class ClientFromSchemaTest < MiniTest::Unit::TestCase
|
@@ -52,6 +72,21 @@ class ClientFromSchemaTest < MiniTest::Unit::TestCase
|
|
52
72
|
assert_equal(body, client.resource.create)
|
53
73
|
end
|
54
74
|
|
75
|
+
# client_from_schema returns a Client that can make requests to APIs mounted
|
76
|
+
# under a prefix, such as http://example.com/api, for example.
|
77
|
+
def test_client_from_schema_with_url_prefix
|
78
|
+
schema = Heroics::Schema.new(SAMPLE_SCHEMA)
|
79
|
+
client = Heroics::client_from_schema(schema, 'https://example.com/api')
|
80
|
+
body = {'Hello' => 'World!'}
|
81
|
+
Excon.stub(method: :post) do |request|
|
82
|
+
assert_equal('/api/resource', request[:path])
|
83
|
+
Excon.stubs.pop
|
84
|
+
{status: 200, headers: {'Content-Type' => 'application/json'},
|
85
|
+
body: MultiJson.dump(body)}
|
86
|
+
end
|
87
|
+
assert_equal(body, client.resource.create)
|
88
|
+
end
|
89
|
+
|
55
90
|
# client_from_schema optionally accepts custom headers to pass with every
|
56
91
|
# request made by the generated client.
|
57
92
|
def test_client_from_schema_with_custom_headers
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: heroics
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.2
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -10,7 +10,7 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: bin
|
12
12
|
cert_chain: []
|
13
|
-
date:
|
13
|
+
date: 2014-01-31 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: bundler
|
@@ -193,7 +193,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
193
193
|
version: '0'
|
194
194
|
segments:
|
195
195
|
- 0
|
196
|
-
hash:
|
196
|
+
hash: 1762076871697657264
|
197
197
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
198
198
|
none: false
|
199
199
|
requirements:
|
@@ -202,7 +202,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
202
202
|
version: '0'
|
203
203
|
segments:
|
204
204
|
- 0
|
205
|
-
hash:
|
205
|
+
hash: 1762076871697657264
|
206
206
|
requirements: []
|
207
207
|
rubyforge_project:
|
208
208
|
rubygems_version: 1.8.23
|