heroics 0.0.1 → 0.0.2
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.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
|