komoju 0.0.4 → 0.0.7

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.
@@ -1,67 +0,0 @@
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
@@ -1,6 +0,0 @@
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
@@ -1,120 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,30 +0,0 @@
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
@@ -1,444 +0,0 @@
1
- module Heroics
2
- # A wrapper around a bare JSON schema to make it easier to use.
3
- class Schema
4
- attr_reader :schema
5
-
6
- # Instantiate a schema.
7
- #
8
- # @param schema [Hash] The bare JSON schema to wrap.
9
- def initialize(schema)
10
- @schema = schema
11
- @resources = {}
12
- @schema['properties'].each do |key, value|
13
- @resources[key] = ResourceSchema.new(@schema, key)
14
- end
15
- end
16
-
17
- # A description of the API.
18
- def description
19
- @schema['description']
20
- end
21
-
22
- # Get a schema for a named resource.
23
- #
24
- # @param name [String] The name of the resource.
25
- # @raise [SchemaError] Raised if an unknown resource name is provided.
26
- def resource(name)
27
- if @schema['definitions'].has_key?(name)
28
- ResourceSchema.new(@schema, name)
29
- else
30
- raise SchemaError.new("Unknown resource '#{name}'.")
31
- end
32
- end
33
-
34
- # The resource schema children that are part of this schema.
35
- #
36
- # @return [Array<ResourceSchema>] The resource schema children.
37
- def resources
38
- @resources.values
39
- end
40
-
41
- # Get a simple human-readable representation of this client instance.
42
- def inspect
43
- "#<Heroics::Schema description=\"#{@schema['description']}\">"
44
- end
45
- alias to_s inspect
46
- end
47
-
48
- # A wrapper around a bare resource element in a JSON schema to make it
49
- # easier to use.
50
- class ResourceSchema
51
- attr_reader :name
52
-
53
- # Instantiate a resource schema.
54
- #
55
- # @param schema [Hash] The bare JSON schema to wrap.
56
- # @param name [String] The name of the resource to identify in the schema.
57
- def initialize(schema, name)
58
- @schema = schema
59
- @name = name
60
- link_schema = schema['definitions'][name]['links'] || []
61
-
62
- @links = Hash[link_schema.each_with_index.map do |link, link_index|
63
- link_name = Heroics.ruby_name(link['title'])
64
- [link_name, LinkSchema.new(schema, name, link_index)]
65
- end]
66
- end
67
-
68
- # A description of the resource.
69
- def description
70
- @schema['definitions'][name]['description']
71
- end
72
-
73
- # Get a schema for a named link.
74
- #
75
- # @param name [String] The name of the link.
76
- # @raise [SchemaError] Raised if an unknown link name is provided.
77
- def link(name)
78
- schema = @links[name]
79
- raise SchemaError.new("Unknown link '#{name}'.") unless schema
80
- schema
81
- end
82
-
83
- # The link schema children that are part of this resource schema.
84
- #
85
- # @return [Array<LinkSchema>] The link schema children.
86
- def links
87
- @links.values
88
- end
89
- end
90
-
91
- # A wrapper around a bare link element for a resource in a JSON schema to
92
- # make it easier to use.
93
- class LinkSchema
94
- attr_reader :name, :resource_name, :description
95
-
96
- # Instantiate a link schema.
97
- #
98
- # @param schema [Hash] The bare JSON schema to wrap.
99
- # @param resource_name [String] The name of the resource to identify in
100
- # the schema.
101
- # @param link_index [Fixnum] The index of the link in the resource schema.
102
- def initialize(schema, resource_name, link_index)
103
- @schema = schema
104
- @resource_name = resource_name
105
- @link_index = link_index
106
- @name = Heroics.ruby_name(link_schema['title'])
107
- @description = link_schema['description']
108
- end
109
-
110
- # Get the resource name in pretty form.
111
- #
112
- # @return [String] The pretty resource name.
113
- def pretty_resource_name
114
- Heroics.pretty_name(resource_name)
115
- end
116
-
117
- # Get the link name in pretty form.
118
- #
119
- # @return [String] The pretty link name.
120
- def pretty_name
121
- Heroics.pretty_name(name)
122
- end
123
-
124
- # Get the HTTP method for this link.
125
- #
126
- # @return [Symbol] The HTTP method.
127
- def method
128
- link_schema['method'].downcase.to_sym
129
- end
130
-
131
- # Get the Content-Type for this link.
132
- #
133
- # @return [String] The Content-Type value
134
- def content_type
135
- link_schema['encType'] || 'application/json'
136
- end
137
-
138
- def encode(body)
139
- case content_type
140
- when 'application/x-www-form-urlencoded'
141
- URI.encode_www_form(body)
142
- when /application\/.*json/
143
- MultiJson.dump(body)
144
- end
145
- end
146
-
147
- # Get the names of the parameters this link expects.
148
- #
149
- # @return [Array<String>] The parameters.
150
- def parameters
151
- parameter_names = link_schema['href'].scan(PARAMETER_REGEX)
152
- resolve_parameters(parameter_names)
153
- end
154
-
155
- # Get the names and descriptions of the parameters this link expects.
156
- #
157
- # @return [Hash<String, String>] A list of hashes with `name` and
158
- # `description` key/value pairs describing parameters.
159
- def parameter_details
160
- parameter_names = link_schema['href'].scan(PARAMETER_REGEX)
161
- parameters = resolve_parameter_details(parameter_names)
162
- parameters << CollectionOptions.new if requires_collection_options?
163
- parameters << BodyParameter.new if requires_request_body?
164
- parameters
165
- end
166
-
167
- # Get an example request body.
168
- #
169
- # @return [Hash] A sample request body.
170
- def example_body
171
- if body_schema = link_schema['schema']
172
- definitions = @schema['definitions'][@resource_name]['definitions']
173
- Hash[body_schema['properties'].keys.map do |property|
174
- # FIXME This is wrong! -jkakar
175
- if definitions.has_key?(property)
176
- example = definitions[property]['example']
177
- else
178
- example = ''
179
- end
180
- [property, example]
181
- end]
182
- end
183
- end
184
-
185
- # Inject parameters into the link href and return the body, if it exists.
186
- #
187
- # @param parameters [Array] The list of parameters to inject into the
188
- # path.
189
- # @raise [ArgumentError] Raised if either too many or too few parameters
190
- # were provided.
191
- # @return [String,Object] A path and request body pair. The body value is
192
- # nil if a payload wasn't included in the list of parameters.
193
- def format_path(parameters)
194
- path = link_schema['href']
195
- parameter_size = path.scan(PARAMETER_REGEX).size
196
- too_few_parameters = parameter_size > parameters.size
197
- # FIXME We should use the schema to detect when a request body is
198
- # permitted and do the calculation correctly here. -jkakar
199
- too_many_parameters = parameter_size < (parameters.size - 1)
200
- if too_few_parameters || too_many_parameters
201
- raise ArgumentError.new("wrong number of arguments " +
202
- "(#{parameters.size} for #{parameter_size})")
203
- end
204
-
205
- (0..parameter_size).each do |i|
206
- path = path.sub(PARAMETER_REGEX, format_parameter(parameters[i]))
207
- end
208
- body = parameters.slice(parameter_size)
209
- return path, body
210
- end
211
-
212
- private
213
-
214
- # Match parameters in definition strings.
215
- PARAMETER_REGEX = /\{\([%\/a-zA-Z0-9_-]*\)\}/
216
-
217
- # Get the raw link schema.
218
- #
219
- # @param [Hash] The raw link schema.
220
- def link_schema
221
- @schema['definitions'][@resource_name]['links'][@link_index]
222
- end
223
-
224
- def requires_collection_options?
225
- return link_schema['rel'] == 'instances'
226
- end
227
-
228
- def requires_request_body?
229
- return link_schema.has_key?('schema')
230
- end
231
-
232
- # Get the names of the parameters this link expects.
233
- #
234
- # @param parameters [Array] The names of the parameter definitions to
235
- # convert to parameter names.
236
- # @return [Array<String>] The parameters.
237
- def resolve_parameters(parameters)
238
- properties = @schema['definitions'][@resource_name]['properties']
239
- return [''] if properties.nil?
240
- definitions = Hash[properties.each_pair.map do |key, value|
241
- [value['$ref'], key]
242
- end]
243
- parameters.map do |parameter|
244
- definition_name = URI.unescape(parameter[2..-3])
245
- if definitions.has_key?(definition_name)
246
- definitions[definition_name]
247
- else
248
- definition_name = definition_name.split('/')[-1]
249
- resource_definitions = @schema[
250
- 'definitions'][@resource_name]['definitions'][definition_name]
251
- if resource_definitions.has_key?('anyOf')
252
- resource_definitions['anyOf'].map do |property|
253
- definitions[property['$ref']]
254
- end.join('|')
255
- else
256
- resource_definitions['oneOf'].map do |property|
257
- definitions[property['$ref']]
258
- end.join('|')
259
- end
260
- end
261
- end
262
- end
263
-
264
- # Get the parameters this link expects.
265
- #
266
- # @param parameters [Array] The names of the parameter definitions to
267
- # convert to parameter names.
268
- # @return [Array<Parameter|ParameterChoice>] A list of parameter instances
269
- # that represent parameters to be injected into the link URL.
270
- def resolve_parameter_details(parameters)
271
- parameters.map do |parameter|
272
- # URI decode parameters and strip the leading '{(' and trailing ')}'.
273
- parameter = URI.unescape(parameter[2..-3])
274
-
275
- # Split the path into components and discard the leading '#' that
276
- # represents the root of the schema.
277
- path = parameter.split('/')[1..-1]
278
- info = lookup_parameter(path, @schema)
279
- # The reference can be one of several values.
280
- resource_name = path[1].gsub('-', '_')
281
- if info.has_key?('anyOf')
282
- ParameterChoice.new(resource_name,
283
- unpack_multiple_parameters(info['anyOf']))
284
- elsif info.has_key?('oneOf')
285
- ParameterChoice.new(resource_name,
286
- unpack_multiple_parameters(info['oneOf']))
287
- else
288
- name = path[-1]
289
- Parameter.new(resource_name, name, info['description'])
290
- end
291
- end
292
- end
293
-
294
- # Unpack an 'anyOf' or 'oneOf' multi-parameter blob.
295
- #
296
- # @param parameters [Array<Hash>] An array of hashes containing '$ref'
297
- # keys and definition values.
298
- # @return [Array<Parameter>] An array of parameters extracted from the
299
- # blob.
300
- def unpack_multiple_parameters(parameters)
301
- parameters.map do |info|
302
- parameter = info['$ref']
303
- path = parameter.split('/')[1..-1]
304
- info = lookup_parameter(path, @schema)
305
- resource_name = path.size > 2 ? path[1].gsub('-', '_') : nil
306
- name = path[-1]
307
- Parameter.new(resource_name, name, info['description'])
308
- end
309
- end
310
-
311
- # Recursively walk the object hierarchy in the schema to resolve a given
312
- # path. This is used to find property information related to definitions
313
- # in link hrefs.
314
- #
315
- # @param path [Array<String>] An array of paths to walk, such as
316
- # ['definitions', 'resource', 'definitions', 'property'].
317
- # @param schema [Hash] The schema to walk.
318
- def lookup_parameter(path, schema)
319
- key = path[0]
320
- remaining = path[1..-1]
321
- if remaining.empty?
322
- return schema[key]
323
- else
324
- lookup_parameter(remaining, schema[key])
325
- end
326
- end
327
-
328
- # Convert a path parameter to a format suitable for use in a path.
329
- #
330
- # @param [Fixnum,String,TrueClass,FalseClass,Time] The parameter to format.
331
- # @return [String] The formatted parameter.
332
- def format_parameter(parameter)
333
- formatted_parameter = parameter.instance_of?(Time) ? iso_format(parameter) : parameter.to_s
334
- URI.escape formatted_parameter
335
- end
336
-
337
- # Convert a time to an ISO 8601 combined data and time format.
338
- #
339
- # @param time [Time] The time to convert to ISO 8601 format.
340
- # @return [String] An ISO 8601 date in `YYYY-MM-DDTHH:MM:SSZ` format.
341
- def iso_format(time)
342
- time.getutc.strftime('%Y-%m-%dT%H:%M:%SZ')
343
- end
344
- end
345
-
346
- # Download a JSON schema from a URL.
347
- #
348
- # @param url [String] The URL for the schema.
349
- # @param options [Hash] Configuration for links. Possible keys include:
350
- # - default_headers: Optionally, a set of headers to include in every
351
- # request made by the client. Default is no custom headers.
352
- # @return [Schema] The downloaded JSON schema.
353
- def self.download_schema(url, options={})
354
- default_headers = options.fetch(:default_headers, {})
355
- response = Excon.get(url, headers: default_headers, expects: [200, 201])
356
- Schema.new(MultiJson.load(response.body))
357
- end
358
-
359
- # The base parameter class
360
- class BaseParameter
361
- attr_reader :resource_name, :name, :description
362
-
363
- # This is the used for generating the function signature
364
- def signature
365
- [@resource_name, @name].compact.join("_")
366
- end
367
-
368
- # A pretty representation of a parameter instance
369
- def inspect
370
- "#{self.class}(name=#{@name}, description=#{@description})"
371
- end
372
- end
373
-
374
- # A representation of a parameter.
375
- class Parameter < BaseParameter
376
- def initialize(resource_name, name, description)
377
- @resource_name = resource_name
378
- @name = name
379
- @description = description
380
- end
381
-
382
- # The name of the parameter, with the resource included, suitable for use
383
- # in a function signature.
384
- def name
385
- [@resource_name, @name].compact.join("_")
386
- end
387
- end
388
-
389
- # A representation of a body parameter.
390
- class BodyParameter < BaseParameter
391
- def initialize
392
- @name = 'body'
393
- @description = 'the object to pass as the request payload'
394
- end
395
- end
396
-
397
- # Additional options to pass with a request
398
- class CollectionOptions < BaseParameter
399
- def initialize
400
- @name = 'collection_options'
401
- @description = 'additional collection options to pass with the request'
402
- end
403
-
404
- def signature
405
- "#{@name} = {}"
406
- end
407
- end
408
-
409
- # A representation of a set of parameters.
410
- class ParameterChoice < BaseParameter
411
- attr_reader :parameters
412
-
413
- def initialize(resource_name, parameters)
414
- @resource_name = resource_name
415
- @parameters = parameters
416
- end
417
-
418
- # A name created by merging individual parameter descriptions, suitable
419
- # for use in a function signature.
420
- def name
421
- @parameters.map do |parameter|
422
- if parameter.resource_name
423
- parameter.name
424
- else
425
- "#{@resource_name}_#{parameter.name}"
426
- end
427
- end.join('_or_')
428
- end
429
-
430
- def signature
431
- name
432
- end
433
-
434
- # A description created by merging individual parameter descriptions.
435
- def description
436
- @parameters.map { |parameter| parameter.description }.join(' or ')
437
- end
438
-
439
- # A pretty representation of this instance.
440
- def inspect
441
- "ParameterChoice(parameters=#{@parameters})"
442
- end
443
- end
444
- end