heroics 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ vendor/
data/.travis.yml ADDED
@@ -0,0 +1,3 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in heroics.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 geemus
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # Heroics
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'heroics'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install heroics
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ The interface is designed to match the workings of the (Heroku Platform API)[https://devcenter.heroku.com/articles/platform-api-reference].
24
+
25
+ ```
26
+ heroics = Heroics.new(token: ENV['HEROKU_API_TOKEN'])
27
+
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'
32
+
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
36
+
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'
41
+
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
45
+ ```
46
+
47
+ ## Contributing
48
+
49
+ 1. Fork it
50
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
51
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
52
+ 4. Push to the branch (`git push origin my-new-feature`)
53
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new do |task|
5
+ task.verbose = true
6
+ task.ruby_opts << '-r turn/autorun'
7
+ task.ruby_opts << '-I test'
8
+ task.test_files = FileList['test/**/*_test.rb', 'test/**/*_spec.rb']
9
+ end
10
+
11
+ task :default => :test
data/TODO ADDED
@@ -0,0 +1,3 @@
1
+ how to handle ranges (note that this probably implies more complex cache behavior also)
2
+
3
+ how to recognize/handle singular resources (ie account and config-vars)
data/bin/heroku-api ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'heroics'
4
+ require 'netrc'
5
+
6
+ netrc = Netrc.read
7
+ username, token = netrc['api.heroku.com']
8
+ if username && token
9
+ username = username.split('@').first
10
+ url = "https://#{username}:#{token}@api.heroku.com/schema"
11
+ options = {
12
+ default_headers: {'Accept' => 'application/vnd.heroku+json; version=3'},
13
+ cache: Moneta.new(:File, dir: "#{Dir.home}/.heroics/heroku-api")}
14
+ cli = Heroics.cli_from_schema_url('heroku-api', STDOUT, url, options)
15
+ cli.run(*ARGV)
16
+ else
17
+ puts "ERROR Couldn't find credentials for api.heroku.com in ~/.netrc."
18
+ puts " Login with the Heroku Toolbelt by running 'heroku login'."
19
+ 1
20
+ end
data/heroics.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+
6
+ require 'heroics/version'
7
+
8
+ Gem::Specification.new do |spec|
9
+ spec.name = 'heroics'
10
+ spec.version = Heroics::VERSION
11
+ spec.authors = ['geemus', 'jkakar']
12
+ spec.email = ['geemus@gmail.com', 'jkakar@kakar.ca']
13
+ spec.description = 'A Ruby client for HTTP APIs described using a JSON schema'
14
+ spec.summary = 'A Ruby client for HTTP APIs described using a JSON schema'
15
+ spec.homepage = ''
16
+ spec.license = 'MIT'
17
+
18
+ spec.files = `git ls-files`.split($/)
19
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
20
+ spec.test_files = spec.files.grep('^(test|spec|features)/')
21
+ spec.require_paths = ['lib']
22
+
23
+ spec.add_development_dependency 'bundler', '~> 1.3'
24
+ spec.add_development_dependency 'minitest', '4.7.4'
25
+ spec.add_development_dependency 'rake'
26
+ spec.add_development_dependency 'turn'
27
+
28
+ spec.add_dependency 'excon'
29
+ spec.add_dependency 'netrc'
30
+ spec.add_dependency 'moneta'
31
+ spec.add_dependency 'multi_json'
32
+ end
data/lib/heroics.rb ADDED
@@ -0,0 +1,18 @@
1
+ require 'excon'
2
+ require 'moneta'
3
+ require 'multi_json'
4
+ require 'uri'
5
+ require 'zlib'
6
+
7
+ # Heroics is an HTTP client for an API described by a JSON schema.
8
+ module Heroics
9
+ end
10
+
11
+ require 'heroics/errors'
12
+ require 'heroics/naming'
13
+ require 'heroics/link'
14
+ require 'heroics/resource'
15
+ require 'heroics/client'
16
+ require 'heroics/schema'
17
+ require 'heroics/command'
18
+ require 'heroics/cli'
@@ -0,0 +1,92 @@
1
+ module Heroics
2
+ class CLI
3
+ # Instantiate a CLI for an API described by a JSON schema.
4
+ #
5
+ # @param name [String] The name of the CLI.
6
+ # @param schema [Schema] The JSON schema describing the API.
7
+ # @param client [Client] A client generated from the JSON schema.
8
+ # @param output [IO] The stream to write to.
9
+ def initialize(name, commands, output)
10
+ @name = name
11
+ @commands = commands
12
+ @output = output
13
+ end
14
+
15
+ # Run a command.
16
+ #
17
+ # @param parameters [Array] The parameters to use when running the
18
+ # command. The first parameters is the name of the command and the
19
+ # remaining parameters are passed to it.
20
+ def run(*parameters)
21
+ name = parameters.shift
22
+ if name.nil? || name == 'help'
23
+ if command_name = parameters.first
24
+ command = @commands[command_name]
25
+ command.usage
26
+ else
27
+ usage
28
+ end
29
+ else
30
+ command = @commands[name]
31
+ if command.nil?
32
+ @output.write("There is no command called '#{name}'.\n")
33
+ else
34
+ command.run(*parameters)
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # Write usage information to the output stream.
42
+ def usage
43
+ if @commands.empty?
44
+ @output.write 'No commands are available.'
45
+ return
46
+ end
47
+
48
+ @output.write <<-USAGE
49
+ Usage: #{@name} <command> [<parameter> [...]] [<body>]
50
+
51
+ Help topics, type "#{@name} help <topic>" for more details:
52
+
53
+ USAGE
54
+
55
+ name_width = @commands.keys.max_by { |key| key.size }.size
56
+ @commands.sort.each do |name, command|
57
+ name = name.ljust(name_width)
58
+ description = command.description
59
+ @output.puts(" #{name} #{description}")
60
+ end
61
+ end
62
+ end
63
+
64
+ def self.cli_from_schema(name, output, schema, url, options={})
65
+ client = client_from_schema(schema, url, options)
66
+ commands = {}
67
+ schema.resources.each do |resource_schema|
68
+ resource_schema.links.each do |link_schema|
69
+ command = Command.new(name, link_schema, client, output)
70
+ commands[command.name] = command
71
+ end
72
+ end
73
+ CLI.new(name, commands, output)
74
+ end
75
+
76
+ # Download a JSON schema and create a CLI with it.
77
+ #
78
+ # @param name [String] The name of the CLI.
79
+ # @param output [IO] The stream to write to.
80
+ # @param url [String] The URL for the schema. The URL will be used by the
81
+ # generated CLI when it makes requests.
82
+ # @param options [Hash] Configuration for links. Possible keys include:
83
+ # - default_headers: Optionally, a set of headers to include in every
84
+ # request made by the CLI. Default is no custom headers.
85
+ # - cache: Optionally, a Moneta-compatible cache to store ETags. Default
86
+ # is no caching.
87
+ # @return [CLI] A CLI with commands generated from the JSON schema.
88
+ def self.cli_from_schema_url(name, output, url, options={})
89
+ schema = download_schema(url, options)
90
+ cli_from_schema(name, output, schema, url, options)
91
+ end
92
+ end
@@ -0,0 +1,65 @@
1
+ module Heroics
2
+ # An HTTP client with methods mapped to API resources.
3
+ class Client
4
+ # Instantiate an HTTP client.
5
+ #
6
+ # @param resources [Hash<String,Resource>] A hash that maps method names
7
+ # to resources.
8
+ def initialize(resources)
9
+ @resources = resources
10
+ end
11
+
12
+ # Find a resource.
13
+ #
14
+ # @param name [String] The name of the resource to find.
15
+ # @raise [NoMethodError] Raised if the name doesn't match a known resource.
16
+ # @return [Resource] The resource matching the name.
17
+ def method_missing(name)
18
+ resource = @resources[name.to_s]
19
+ if resource.nil?
20
+ address = "<#{self.class.name}:0x00#{(self.object_id << 1).to_s(16)}>"
21
+ raise NoMethodError.new("undefined method `#{name}' for ##{address}")
22
+ end
23
+ resource
24
+ end
25
+ end
26
+
27
+ # Create an HTTP client from a JSON schema.
28
+ #
29
+ # @param schema [Schema] The JSON schema to build an HTTP client for.
30
+ # @param url [String] The URL the generated client should use when making
31
+ # requests. Include the username and password to use with HTTP basic
32
+ # auth.
33
+ # @param options [Hash] Configuration for links. Possible keys include:
34
+ # - default_headers: Optionally, a set of headers to include in every
35
+ # request made by the client. Default is no custom headers.
36
+ # - cache: Optionally, a Moneta-compatible cache to store ETags. Default
37
+ # is no caching.
38
+ # @return [Client] A client with resources and links from the JSON schema.
39
+ def self.client_from_schema(schema, url, options={})
40
+ resources = {}
41
+ schema.resources.each do |resource_schema|
42
+ links = {}
43
+ resource_schema.links.each do |link_schema|
44
+ links[link_schema.name] = Link.new(url, link_schema, options)
45
+ end
46
+ resources[resource_schema.name] = Resource.new(links)
47
+ end
48
+ Client.new(resources)
49
+ end
50
+
51
+ # Download a JSON schema and create an HTTP client with it.
52
+ #
53
+ # @param url [String] The URL for the schema. The URL will be used by the
54
+ # generated client when it makes requests.
55
+ # @param options [Hash] Configuration for links. Possible keys include:
56
+ # - default_headers: Optionally, a set of headers to include in every
57
+ # request made by the client. Default is no custom headers.
58
+ # - cache: Optionally, a Moneta-compatible cache to store ETags. Default
59
+ # is no caching.
60
+ # @return [Client] A client with resources and links from the JSON schema.
61
+ def self.client_from_schema_url(url, options={})
62
+ schema = download_schema(url, options)
63
+ client_from_schema(schema, URI::join(url, '/').to_s, options)
64
+ end
65
+ end
@@ -0,0 +1,67 @@
1
+ module Heroics
2
+ class Command
3
+ # Instantiate a command.
4
+ #
5
+ # @param cli_name [String] The name of the CLI.
6
+ # @param link_schema [LinkSchema] The schema for the underlying link this
7
+ # command represents.
8
+ # @param client [Client] The client to use when making requests.
9
+ # @param output [IO] The stream to write output to.
10
+ def initialize(cli_name, link_schema, client, output)
11
+ @cli_name = cli_name
12
+ @link_schema = link_schema
13
+ @client = client
14
+ @output = output
15
+ end
16
+
17
+ # The command name.
18
+ def name
19
+ "#{@link_schema.pretty_resource_name}:#{@link_schema.pretty_name}"
20
+ end
21
+
22
+ # The command description.
23
+ def description
24
+ @link_schema.description
25
+ end
26
+
27
+ # Write usage information to the output stream.
28
+ def usage
29
+ parameters = @link_schema.parameters.map { |parameter| "<#{parameter}>" }
30
+ parameters = parameters.empty? ? '' : " #{parameters.join(' ')}"
31
+ example_body = @link_schema.example_body
32
+ body_parameter = example_body.nil? ? '' : ' <body>'
33
+ @output.write <<-USAGE
34
+ Usage: #{@cli_name} #{name}#{parameters}#{body_parameter}
35
+
36
+ Description:
37
+ #{description}
38
+ USAGE
39
+ if example_body
40
+ example_body = MultiJson.dump(example_body, pretty: true)
41
+ example_body = example_body.lines.map do |line|
42
+ " #{line}"
43
+ end.join
44
+ @output.write <<-USAGE
45
+
46
+ Body example:
47
+ #{example_body}
48
+ USAGE
49
+ end
50
+ end
51
+
52
+ # Run the command and write the results to the output stream.
53
+ #
54
+ # @param parameters [Array] The parameters to pass when making a request
55
+ # to run the command.
56
+ def run(*parameters)
57
+ resource_name = @link_schema.resource_name
58
+ name = @link_schema.name
59
+ result = @client.send(resource_name).send(name, *parameters)
60
+ result = result.to_a if result.instance_of?(Enumerator)
61
+ if result && !result.instance_of?(String)
62
+ result = MultiJson.dump(result, pretty: true)
63
+ end
64
+ @output.puts(result) unless result.nil?
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,6 @@
1
+ module Heroics
2
+ # Raised when a schema has an error that prevents it from being parsed
3
+ # correctly.
4
+ class SchemaError < StandardError
5
+ end
6
+ end
@@ -0,0 +1,100 @@
1
+ module Heroics
2
+ # A link invokes requests with an HTTP server.
3
+ class Link
4
+ # Instantiate a link.
5
+ #
6
+ # @param url [String] The URL to use when making requests. Include the
7
+ # username and password to use with HTTP basic auth.
8
+ # @param link_schema [LinkSchema] The schema for this link.
9
+ # @param options [Hash] Configuration for the link. Possible keys
10
+ # include:
11
+ # - default_headers: Optionally, a set of headers to include in every
12
+ # request made by the client. Default is no custom headers.
13
+ # - cache: Optionally, a Moneta-compatible cache to store ETags.
14
+ # Default is no caching.
15
+ def initialize(url, link_schema, options={})
16
+ @url = url
17
+ @link_schema = link_schema
18
+ @default_headers = options[:default_headers] || {}
19
+ @cache = options[:cache] || Moneta.new(:Null)
20
+ end
21
+
22
+ # Make a request to the server.
23
+ #
24
+ # JSON content received with an ETag is cached. When the server returns a
25
+ # *304 Not Modified* status code content is loaded and returned from the
26
+ # cache. The cache considers headers, in addition to the URL path, when
27
+ # creating keys so that requests to the same path, such as for paginated
28
+ # results, don't cause cache collisions.
29
+ #
30
+ # When the server returns a *206 Partial Content* status code the result
31
+ # is assumed to be an array and an enumerator is returned. The enumerator
32
+ # yields results from the response until they've been consumed at which
33
+ # point, if additional content is available from the server, it blocks and
34
+ # makes a request to fetch the subsequent page of data. This behaviour
35
+ # continues until the client stops iterating the enumerator or the dataset
36
+ # from the server has been entirely consumed.
37
+ #
38
+ # @param parameters [Array] The list of parameters to inject into the
39
+ # path. A request body can be passed as the final parameter and will
40
+ # always be converted to JSON before being transmitted.
41
+ # @raise [ArgumentError] Raised if either too many or too few parameters
42
+ # were provided.
43
+ # @return [String,Object,Enumerator] A string for text responses, an
44
+ # object for JSON responses, or an enumerator for list responses.
45
+ def run(*parameters)
46
+ path, body = @link_schema.format_path(parameters)
47
+ headers = @default_headers
48
+ if body
49
+ headers = headers.merge({'Content-Type' => 'application/json'})
50
+ body = MultiJson.dump(body)
51
+ end
52
+ cache_key = "#{path}:#{headers.hash}"
53
+ if @link_schema.method == :get
54
+ etag = @cache["etag:#{cache_key}"]
55
+ headers = headers.merge({'If-None-Match' => etag}) if etag
56
+ end
57
+
58
+ connection = Excon.new(@url)
59
+ response = connection.request(method: @link_schema.method, path: path,
60
+ headers: headers, body: body,
61
+ expects: [200, 201, 202, 206, 304])
62
+ content_type = response.headers['Content-Type']
63
+ if response.status == 304
64
+ MultiJson.load(@cache["data:#{cache_key}"])
65
+ elsif content_type && content_type.include?('application/json')
66
+ etag = response.headers['ETag']
67
+ if etag
68
+ @cache["etag:#{cache_key}"] = etag
69
+ @cache["data:#{cache_key}"] = response.body
70
+ end
71
+ body = MultiJson.load(response.body)
72
+ if response.status == 206
73
+ next_range = response.headers['Next-Range']
74
+ Enumerator.new do |yielder|
75
+ while true do
76
+ # Yield the results we got in the body.
77
+ body.each do |item|
78
+ yielder << item
79
+ end
80
+
81
+ # Only make a request to get the next page if we have a valid
82
+ # next range.
83
+ break unless next_range
84
+ headers = headers.merge({'Range' => next_range})
85
+ response = connection.request(method: @link_schema.method,
86
+ path: path, headers: headers,
87
+ expects: [200, 201, 206])
88
+ body = MultiJson.load(response.body)
89
+ next_range = response.headers['Next-Range']
90
+ end
91
+ end
92
+ else
93
+ body
94
+ end
95
+ elsif !response.body.empty?
96
+ response.body
97
+ end
98
+ end
99
+ end
100
+ end