komoju 0.0.0 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -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