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 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