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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Heroics
2
2
 
3
- TODO: Write a gem description
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
- TODO: Write usage instructions here
21
+ ### Generate a client from a JSON schema
22
22
 
23
- The interface is designed to match the workings of the (Heroku Platform API)[https://devcenter.heroku.com/articles/platform-api-reference].
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
- # apps
29
- heroics.apps.create(name: 'example') # returns new app named 'example'
30
- heroics.apps.list # returns list of all apps
31
- heroics.apps.info('example') # returns app with id or name of 'example'
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
- app = heroics.apps('example') # returns local reference to app with id or name 'example'
34
- app.update(name: 'rename') # returns updated app
35
- app.delete # returns deleted app
185
+ ### Handling failures
36
186
 
37
- # addons
38
- app = heroics.apps('example') # returns local reference to app with id or name 'example'
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
- addon = app.addons.info('heroku-postgresql:dev') # returns add-on with id or name 'heroku-postgresql:dev'
43
- addon.update(plan: { name: 'heroku-postgresql:basic' }) # returns updated add-on
44
- addon.delete # returns deleted add-on
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 it
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 Pull Request
204
+ 5. Create new pull request
data/lib/heroics.rb CHANGED
@@ -8,6 +8,7 @@ require 'zlib'
8
8
  module Heroics
9
9
  end
10
10
 
11
+ require 'heroics/version'
11
12
  require 'heroics/errors'
12
13
  require 'heroics/naming'
13
14
  require 'heroics/link'
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|
@@ -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
- @url = url
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(@url)
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
@@ -1,3 +1,3 @@
1
1
  module Heroics
2
- VERSION = "0.0.1"
2
+ VERSION = '0.0.2'
3
3
  end
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.1
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: 2013-12-16 00:00:00.000000000 Z
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: 761301083637383534
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: 761301083637383534
205
+ hash: 1762076871697657264
206
206
  requirements: []
207
207
  rubyforge_project:
208
208
  rubygems_version: 1.8.23