heroics 0.0.1

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