komoju 0.0.0 → 0.0.3

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.
@@ -0,0 +1,88 @@
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
+ # 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.
77
+ def self.cli_from_schema(name, output, schema, url, options={})
78
+ client = client_from_schema(schema, url, options)
79
+ commands = {}
80
+ schema.resources.each do |resource_schema|
81
+ resource_schema.links.each do |link_schema|
82
+ command = Command.new(name, link_schema, client, output)
83
+ commands[command.name] = command
84
+ end
85
+ end
86
+ CLI.new(name, commands, output)
87
+ end
88
+ end
@@ -0,0 +1,109 @@
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
+ # @param url [String] The URL used by this client.
9
+ def initialize(resources, url)
10
+ @resources = resources
11
+ @url = url
12
+ end
13
+
14
+ # Find a resource.
15
+ #
16
+ # @param name [String] The name of the resource to find.
17
+ # @raise [NoMethodError] Raised if the name doesn't match a known resource.
18
+ # @return [Resource] The resource matching the name.
19
+ def method_missing(name)
20
+ resource = @resources[name.to_s]
21
+ if resource.nil?
22
+ raise NoMethodError.new("undefined method `#{name}' for #{to_s}")
23
+ end
24
+ resource
25
+ end
26
+
27
+ # Get a simple human-readable representation of this client instance.
28
+ def inspect
29
+ url = URI.parse(@url)
30
+ unless url.password.nil?
31
+ url.password = 'REDACTED'
32
+ end
33
+ "#<Heroics::Client url=\"#{url.to_s}\">"
34
+ end
35
+ alias to_s inspect
36
+ end
37
+
38
+ # Create an HTTP client from a JSON schema.
39
+ #
40
+ # @param schema [Schema] The JSON schema to build an HTTP client for.
41
+ # @param url [String] The URL the generated client should use when making
42
+ # requests. Include the username and password to use with HTTP basic
43
+ # auth.
44
+ # @param options [Hash] Configuration for links. Possible keys include:
45
+ # - default_headers: Optionally, a set of headers to include in every
46
+ # request made by the client. Default is no custom headers.
47
+ # - cache: Optionally, a Moneta-compatible cache to store ETags. Default
48
+ # is no caching.
49
+ # @return [Client] A client with resources and links from the JSON schema.
50
+ def self.client_from_schema(schema, url, options={})
51
+ resources = {}
52
+ schema.resources.each do |resource_schema|
53
+ links = {}
54
+ resource_schema.links.each do |link_schema|
55
+ links[link_schema.name] = Link.new(url, link_schema, options)
56
+ end
57
+ resources[resource_schema.name] = Resource.new(links)
58
+ end
59
+ Client.new(resources, url)
60
+ end
61
+
62
+ # Create an HTTP client with OAuth credentials from a JSON schema.
63
+ #
64
+ # @param oauth_token [String] The OAuth token to pass using the `Bearer`
65
+ # authorization mechanism.
66
+ # @param schema [Schema] The JSON schema to build an HTTP client for.
67
+ # @param url [String] The URL the generated client should use when making
68
+ # requests.
69
+ # @param options [Hash] Configuration for links. Possible keys include:
70
+ # - default_headers: Optionally, a set of headers to include in every
71
+ # request made by the client. Default is no custom headers.
72
+ # - cache: Optionally, a Moneta-compatible cache to store ETags. Default
73
+ # is no caching.
74
+ # @return [Client] A client with resources and links from the JSON schema.
75
+ def self.oauth_client_from_schema(oauth_token, schema, url, options={})
76
+ authorization = "Bearer #{oauth_token}"
77
+ # Don't mutate user-supplied data.
78
+ options = Marshal.load(Marshal.dump(options))
79
+ if !options.has_key?(:default_headers)
80
+ options[:default_headers] = {}
81
+ end
82
+ options[:default_headers].merge!({"Authorization" => authorization})
83
+ client_from_schema(schema, url, options)
84
+ end
85
+
86
+ # Create an HTTP client with Token credentials from a JSON schema.
87
+ #
88
+ # @param oauth_token [String] The token to pass using the `Bearer`
89
+ # authorization mechanism.
90
+ # @param schema [Schema] The JSON schema to build an HTTP client for.
91
+ # @param url [String] The URL the generated client should use when making
92
+ # requests.
93
+ # @param options [Hash] Configuration for links. Possible keys include:
94
+ # - default_headers: Optionally, a set of headers to include in every
95
+ # request made by the client. Default is no custom headers.
96
+ # - cache: Optionally, a Moneta-compatible cache to store ETags. Default
97
+ # is no caching.
98
+ # @return [Client] A client with resources and links from the JSON schema.
99
+ def self.token_client_from_schema(token, schema, url, options={})
100
+ authorization = "Token token=#{token}"
101
+ # Don't mutate user-supplied data.
102
+ options = Marshal.load(Marshal.dump(options))
103
+ if !options.has_key?(:default_headers)
104
+ options[:default_headers] = {}
105
+ end
106
+ options[:default_headers].merge!({"Authorization" => authorization})
107
+ client_from_schema(schema, url, options)
108
+ end
109
+ end
@@ -0,0 +1,99 @@
1
+ module Heroics
2
+ # Generate a static client that uses Heroics under the hood. This is a good
3
+ # option if you want to ship a gem or generate API documentation using Yard.
4
+ #
5
+ # @param module_name [String] The name of the module, as rendered in a Ruby
6
+ # source file, to use for the generated client.
7
+ # @param schema [Schema] The schema instance to generate the client from.
8
+ # @param url [String] The URL for the API service.
9
+ # @param options [Hash] Configuration for links. Possible keys include:
10
+ # - default_headers: Optionally, a set of headers to include in every
11
+ # request made by the client. Default is no custom headers.
12
+ # - cache: Optionally, a Moneta-compatible cache to store ETags. Default
13
+ # is no caching.
14
+ def self.generate_client(module_name, schema, url, options)
15
+ filename = File.dirname(__FILE__) + '/views/client.erb'
16
+ eruby = Erubis::Eruby.new(File.read(filename))
17
+ context = build_context(module_name, schema, url, options)
18
+ eruby.evaluate(context)
19
+ end
20
+
21
+ private
22
+
23
+ # Process the schema to build up the context needed to render the source
24
+ # template.
25
+ def self.build_context(module_name, schema, url, options)
26
+ resources = []
27
+ schema.resources.each do |resource_schema|
28
+ links = []
29
+ resource_schema.links.each do |link_schema|
30
+ links << GeneratorLink.new(link_schema.name.gsub('-', '_'),
31
+ link_schema.description,
32
+ link_schema.parameter_details)
33
+ end
34
+ resources << GeneratorResource.new(resource_schema.name.gsub('-', '_'),
35
+ resource_schema.description,
36
+ links)
37
+ end
38
+
39
+ {
40
+ module_name: module_name,
41
+ url: url,
42
+ default_headers: options.fetch(:default_headers, {}),
43
+ cache: options.fetch(:cache, {}),
44
+ description: schema.description,
45
+ schema: MultiJson.dump(schema.schema, pretty:true),
46
+ resources: resources
47
+ }
48
+ end
49
+
50
+ # A representation of a resource for use when generating source code in the
51
+ # template.
52
+ class GeneratorResource
53
+ attr_reader :name, :description, :links
54
+
55
+ def initialize(name, description, links)
56
+ @name = name
57
+ @description = description
58
+ @links = links
59
+ end
60
+
61
+ # The name of the resource class in generated code.
62
+ def class_name
63
+ Heroics.camel_case(name)
64
+ end
65
+ end
66
+
67
+ # A representation of a link for use when generating source code in the
68
+ # template.
69
+ class GeneratorLink
70
+ attr_reader :name, :description, :parameters
71
+
72
+ def initialize(name, description, parameters)
73
+ @name = name
74
+ @description = description
75
+ @parameters = parameters
76
+ end
77
+
78
+ # List of parameters for the method signature
79
+ def signatures
80
+ @parameters.map { |info| info.signature }.join(', ')
81
+ end
82
+
83
+ # The list of parameters to render in generated source code for the method
84
+ # signature for the link.
85
+ def parameter_list
86
+ @parameters.map { |info| info.name }.join(', ')
87
+ end
88
+ end
89
+
90
+ # Convert a lower_case_name to CamelCase.
91
+ def self.camel_case(text)
92
+ return text if text !~ /_/ && text =~ /[A-Z]+.*/
93
+ text = text.split('_').map{ |element| element.capitalize }.join
94
+ [/^Ssl/, /^Http/, /^Xml/].each do |replace|
95
+ text.sub!(replace) { |match| match.upcase }
96
+ end
97
+ text
98
+ end
99
+ 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,120 @@
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
+ @root_url, @path_prefix = unpack_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
+ path = "#{@path_prefix}#{path}" unless @path_prefix == '/'
48
+ headers = @default_headers
49
+ if body
50
+ headers = headers.merge({'Content-Type' => @link_schema.content_type})
51
+ body = @link_schema.encode(body)
52
+ end
53
+ cache_key = "#{path}:#{headers.hash}"
54
+ if @link_schema.method == :get
55
+ etag = @cache["etag:#{cache_key}"]
56
+ headers = headers.merge({'If-None-Match' => etag}) if etag
57
+ end
58
+
59
+ connection = Excon.new(@root_url, thread_safe_sockets: true)
60
+ response = connection.request(method: @link_schema.method, path: path,
61
+ headers: headers, body: body,
62
+ expects: [200, 201, 202, 204, 206, 304])
63
+ content_type = response.headers['Content-Type']
64
+ if response.status == 304
65
+ MultiJson.load(@cache["data:#{cache_key}"])
66
+ elsif content_type && content_type =~ /application\/.*json/
67
+ etag = response.headers['ETag']
68
+ if etag
69
+ @cache["etag:#{cache_key}"] = etag
70
+ @cache["data:#{cache_key}"] = response.body
71
+ end
72
+ body = MultiJson.load(response.body)
73
+ if response.status == 206
74
+ next_range = response.headers['Next-Range']
75
+ Enumerator.new do |yielder|
76
+ while true do
77
+ # Yield the results we got in the body.
78
+ body.each do |item|
79
+ yielder << item
80
+ end
81
+
82
+ # Only make a request to get the next page if we have a valid
83
+ # next range.
84
+ break unless next_range
85
+ headers = headers.merge({'Range' => next_range})
86
+ response = connection.request(method: @link_schema.method,
87
+ path: path, headers: headers,
88
+ expects: [200, 201, 206])
89
+ body = MultiJson.load(response.body)
90
+ next_range = response.headers['Next-Range']
91
+ end
92
+ end
93
+ else
94
+ body
95
+ end
96
+ elsif !response.body.empty?
97
+ response.body
98
+ end
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
119
+ end
120
+ end
@@ -0,0 +1,19 @@
1
+ module Heroics
2
+ # Process a name to make it suitable for use as a Ruby method name.
3
+ #
4
+ # @param name [String] The name to process.
5
+ # @return [String] The new name with capitals converted to lowercase, and
6
+ # dashes and spaces converted to underscores.
7
+ def self.ruby_name(name)
8
+ name.downcase.gsub(/[- ]/, '_')
9
+ end
10
+
11
+ # Process a name to make it suitable for use as a pretty command name.
12
+ #
13
+ # @param name [String] The name to process.
14
+ # @return [String] The new name with capitals converted to lowercase, and
15
+ # underscores and spaces converted to dashes.
16
+ def self.pretty_name(name)
17
+ name.downcase.gsub(/[_ ]/, '-')
18
+ end
19
+ end
@@ -0,0 +1,30 @@
1
+ module Heroics
2
+ # A resource with methods mapped to API links.
3
+ class Resource
4
+ # Instantiate a resource.
5
+ #
6
+ # @param links [Hash<String,Link>] A hash that maps method names to links.
7
+ def initialize(links)
8
+ @links = links
9
+ end
10
+
11
+ private
12
+
13
+ # Find a link and invoke it.
14
+ #
15
+ # @param name [String] The name of the method to invoke.
16
+ # @param parameters [Array] The arguments to pass to the method. This
17
+ # should always be a `Hash` mapping parameter names to values.
18
+ # @raise [NoMethodError] Raised if the name doesn't match a known link.
19
+ # @return [String,Array,Hash] The response received from the server. JSON
20
+ # responses are automatically decoded into Ruby objects.
21
+ def method_missing(name, *parameters)
22
+ link = @links[name.to_s]
23
+ if link.nil?
24
+ address = "<#{self.class.name}:0x00#{(self.object_id << 1).to_s(16)}>"
25
+ raise NoMethodError.new("undefined method `#{name}' for ##{address}")
26
+ end
27
+ link.run(*parameters)
28
+ end
29
+ end
30
+ end