komoju 0.0.4 → 0.0.7

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