scorpio 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.
@@ -0,0 +1,65 @@
1
+ require "scorpio/version"
2
+
3
+ module Scorpio
4
+ proc { |v| define_singleton_method(:error_classes_by_status) { v } }.call({})
5
+ class Error < StandardError; end
6
+ class HTTPError < Error
7
+ define_singleton_method(:status) { |status| Scorpio.error_classes_by_status[status] = self }
8
+ attr_accessor :response
9
+ end
10
+ class ClientError < HTTPError; end
11
+ class ServerError < HTTPError; end
12
+
13
+ class BadRequest400Error < ClientError; status(400); end
14
+ class Unauthorized401Error < ClientError; status(401); end
15
+ class PaymentRequired402Error < ClientError; status(402); end
16
+ class Forbidden403Error < ClientError; status(403); end
17
+ class NotFound404Error < ClientError; status(404); end
18
+ class MethodNotAllowed405Error < ClientError; status(405); end
19
+ class NotAcceptable406Error < ClientError; status(406); end
20
+ class ProxyAuthenticationRequired407Error < ClientError; status(407); end
21
+ class RequestTimeout408Error < ClientError; status(408); end
22
+ class Conflict409Error < ClientError; status(409); end
23
+ class Gone410Error < ClientError; status(410); end
24
+ class LengthRequired411Error < ClientError; status(411); end
25
+ class PreconditionFailed412Error < ClientError; status(412); end
26
+ class PayloadTooLarge413Error < ClientError; status(413); end
27
+ class URITooLong414Error < ClientError; status(414); end
28
+ class UnsupportedMediaType415Error < ClientError; status(415); end
29
+ class RangeNotSatisfiable416Error < ClientError; status(416); end
30
+ class ExpectationFailed417Error < ClientError; status(417); end
31
+ class ImaTeapot418Error < ClientError; status(418); end
32
+ class MisdirectedRequest421Error < ClientError; status(421); end
33
+ class UnprocessableEntity422Error < ClientError; status(422); end
34
+ class Locked423Error < ClientError; status(423); end
35
+ class FailedDependency424Error < ClientError; status(424); end
36
+ class UpgradeRequired426Error < ClientError; status(426); end
37
+ class PreconditionRequired428Error < ClientError; status(428); end
38
+ class TooManyRequests429Error < ClientError; status(429); end
39
+ class RequestHeaderFieldsTooLarge431Error < ClientError; status(431); end
40
+ class UnavailableForLegalReasons451Error < ClientError; status(451); end
41
+
42
+ class InternalServerError500Error < ServerError; status(500); end
43
+ class NotImplemented501Error < ServerError; status(501); end
44
+ class BadGateway502Error < ServerError; status(502); end
45
+ class ServiceUnavailable503Error < ServerError; status(503); end
46
+ class GatewayTimeout504Error < ServerError; status(504); end
47
+ class HTTPVersionNotSupported505Error < ServerError; status(505); end
48
+ class VariantAlsoNegotiates506Error < ServerError; status(506); end
49
+ class InsufficientStorage507Error < ServerError; status(507); end
50
+ class LoopDetected508Error < ServerError; status(508); end
51
+ class NotExtended510Error < ServerError; status(510); end
52
+ class NetworkAuthenticationRequired511Error < ServerError; status(511); end
53
+ error_classes_by_status.freeze
54
+
55
+ autoload :Model, 'scorpio/model'
56
+
57
+ class << self
58
+ def stringify_symbol_keys(hash)
59
+ unless hash.is_a?(Hash)
60
+ raise ArgumentError, "expected argument to be a Hash; got #{hash.class}: #{hash.inspect}"
61
+ end
62
+ hash.map { |k,v| {k.is_a?(Symbol) ? k.to_s : k => v} }.inject({}, &:update)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,415 @@
1
+ require 'addressable/template'
2
+ require 'json-schema'
3
+
4
+ module Scorpio
5
+ # see also Faraday::Env::MethodsWithBodies
6
+ METHODS_WITH_BODIES = %w(post put patch options)
7
+
8
+ class Model
9
+ class << self
10
+ def define_inheritable_accessor(accessor, options = {})
11
+ if options[:default_getter]
12
+ define_singleton_method(accessor, &options[:default_getter])
13
+ else
14
+ default_value = options[:default_value]
15
+ define_singleton_method(accessor) { default_value }
16
+ end
17
+ define_singleton_method(:"#{accessor}=") do |value|
18
+ singleton_class.instance_exec(value, self) do |value_, klass|
19
+ begin
20
+ remove_method(accessor)
21
+ rescue NameError
22
+ end
23
+ define_method(accessor) { value_ }
24
+ if options[:on_set]
25
+ klass.instance_exec(&options[:on_set])
26
+ end
27
+ end
28
+ if options[:update_methods]
29
+ update_dynamic_methods
30
+ end
31
+ end
32
+ end
33
+ end
34
+ define_inheritable_accessor(:api_description_class)
35
+ define_inheritable_accessor(:api_description, on_set: proc { self.api_description_class = self })
36
+ define_inheritable_accessor(:resource_name, update_methods: true)
37
+ define_inheritable_accessor(:schema_keys, default_value: [], update_methods: true, on_set: proc do
38
+ schema_keys.each do |key|
39
+ api_description_class.models_by_schema_id = api_description_class.models_by_schema_id.merge(schemas_by_key[key]['id'] => self)
40
+ api_description_class.models_by_schema_key = api_description_class.models_by_schema_key.merge(key => self)
41
+ end
42
+ end)
43
+ define_inheritable_accessor(:schemas_by_key, default_value: {})
44
+ define_inheritable_accessor(:schemas_by_id, default_value: {})
45
+ define_inheritable_accessor(:models_by_schema_id, default_value: {})
46
+ define_inheritable_accessor(:models_by_schema_key, default_value: {})
47
+ define_inheritable_accessor(:base_url)
48
+
49
+ define_inheritable_accessor(:faraday_request_middleware, default_value: [])
50
+ define_inheritable_accessor(:faraday_adapter, default_getter: proc { Faraday.default_adapter })
51
+ define_inheritable_accessor(:faraday_response_middleware, default_value: [])
52
+ class << self
53
+ def api_description_schema
54
+ @api_description_schema ||= begin
55
+ rest = YAML.load_file(Pathname.new(__FILE__).join('../../../getRest.yml'))
56
+ rest['schemas'].each do |name, schema_hash|
57
+ # URI hax because google doesn't put a URI in the id field properly
58
+ schema = JSON::Schema.new(schema_hash, Addressable::URI.parse(''))
59
+ JSON::Validator.add_schema(schema)
60
+ end
61
+ rest['schemas']['RestDescription']
62
+ end
63
+ end
64
+
65
+ def set_api_description(api_description)
66
+ JSON::Validator.validate!(api_description_schema, api_description)
67
+ self.api_description = api_description
68
+ (api_description['schemas'] || {}).each do |schema_key, schema|
69
+ unless schema['id']
70
+ raise ArgumentError, "schema #{schema_key} did not contain an id"
71
+ end
72
+ schemas_by_id[schema['id']] = schema
73
+ schemas_by_key[schema_key] = schema
74
+ end
75
+ update_dynamic_methods
76
+ end
77
+
78
+ def update_dynamic_methods
79
+ update_class_and_instance_api_methods
80
+ update_instance_accessors
81
+ end
82
+
83
+ def all_schema_properties
84
+ schemas_by_key.select { |k, _| schema_keys.include?(k) }.map do |schema_key, schema|
85
+ unless schema['type'] == 'object'
86
+ raise "schema key #{schema_key} for #{self} is not of type object - type must be object for Scorpio Model to represent this schema" # TODO class
87
+ end
88
+ schema['properties'].keys
89
+ end.inject([], &:|)
90
+ end
91
+
92
+ def update_instance_accessors
93
+ all_schema_properties.each do |property_name|
94
+ unless method_defined?(property_name)
95
+ define_method(property_name) do
96
+ self[property_name]
97
+ end
98
+ end
99
+ unless method_defined?(:"#{property_name}=")
100
+ define_method(:"#{property_name}=") do |value|
101
+ self[property_name] = value
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ def update_class_and_instance_api_methods
108
+ if self.resource_name && api_description
109
+ resource_api_methods = ((api_description['resources'] || {})[resource_name] || {})['methods'] || {}
110
+ resource_api_methods.each do |method_name, method_desc|
111
+ # class method
112
+ unless respond_to?(method_name)
113
+ define_singleton_method(method_name) do |call_params = nil|
114
+ call_api_method(method_name, call_params: call_params)
115
+ end
116
+ end
117
+
118
+ # instance method
119
+ unless method_defined?(method_name)
120
+ request_schema = deref_schema(method_desc['request'])
121
+
122
+ # define an instance method if the request schema is for this model
123
+ request_resource_is_self = request_schema &&
124
+ request_schema['id'] &&
125
+ schemas_by_key.any? { |key, as| as['id'] == request_schema['id'] && schema_keys.include?(key) }
126
+
127
+ # also define an instance method depending on certain attributes the request description
128
+ # might have in common with the model's schema attributes
129
+ request_attributes = []
130
+ # if the path has attributes in common with model schema attributes, we'll define on
131
+ # instance method
132
+ request_attributes |= Addressable::Template.new(method_desc['path']).variables
133
+ # TODO if the method request schema has attributes in common with the model schema attributes,
134
+ # should we define an instance method?
135
+ #request_attributes |= request_schema && request_schema['type'] == 'object' && request_schema['properties'] ?
136
+ # request_schema['properties'].keys : []
137
+ # TODO if the method parameters have attributes in common with the model schema attributes,
138
+ # should we define an instance method?
139
+ #request_attributes |= method_desc['parameters'] ? method_desc['parameters'].keys : []
140
+
141
+ schema_attributes = schema_keys.map do |schema_key|
142
+ schema = schemas_by_key[schema_key]
143
+ schema['type'] == 'object' && schema['properties'] ? schema['properties'].keys : []
144
+ end.inject([], &:|)
145
+
146
+ if request_resource_is_self || (request_attributes & schema_attributes).any?
147
+ define_method(method_name) do |call_params = nil|
148
+ call_api_method(method_name, call_params: call_params)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ def deref_schema(schema)
157
+ schema && schemas_by_id[schema['$ref']] || schema
158
+ end
159
+
160
+ MODULES_FOR_JSON_SCHEMA_TYPES = {
161
+ 'object' => [Hash],
162
+ 'array' => [Array, Set],
163
+ 'string' => [String],
164
+ 'integer' => [Integer],
165
+ 'number' => [Numeric],
166
+ 'boolean' => [TrueClass, FalseClass],
167
+ 'null' => [NilClass],
168
+ }
169
+
170
+ def connection
171
+ Faraday.new do |c|
172
+ unless faraday_request_middleware.any? { |m| [*m].first == :json }
173
+ c.request :json
174
+ end
175
+ faraday_request_middleware.each do |m|
176
+ c.request(*m)
177
+ end
178
+ c.adapter(*faraday_adapter)
179
+ faraday_response_middleware.each do |m|
180
+ c.response(*m)
181
+ end
182
+ unless faraday_response_middleware.any? { |m| [*m].first == :json }
183
+ c.response :json, :content_type => /\bjson$/, :preserve_raw => true
184
+ end
185
+ end
186
+ end
187
+
188
+ def call_api_method(method_name, call_params: nil, model_attributes: nil)
189
+ call_params = Scorpio.stringify_symbol_keys(call_params || {})
190
+ model_attributes = Scorpio.stringify_symbol_keys(model_attributes || {})
191
+ method_desc = api_description['resources'][self.resource_name]['methods'][method_name]
192
+ http_method = method_desc['httpMethod'].downcase.to_sym
193
+ path_template = Addressable::Template.new(method_desc['path'])
194
+ template_params = model_attributes.merge(call_params)
195
+ missing_variables = path_template.variables - call_params.keys - model_attributes.keys
196
+ if missing_variables.any?
197
+ raise(ArgumentError, "path #{method_desc['path']} for method #{method_name} requires attributes " +
198
+ "which were missing: #{missing_variables.inspect}")
199
+ end
200
+ empty_variables = path_template.variables.select { |v| template_params[v].to_s.empty? }
201
+ if empty_variables.any?
202
+ raise(ArgumentError, "path #{method_desc['path']} for method #{method_name} requires attributes " +
203
+ "which were empty: #{empty_variables.inspect}")
204
+ end
205
+ path = path_template.expand(template_params)
206
+ url = Addressable::URI.parse(base_url) + path
207
+ # assume that call_params must be included somewhere. model_attributes are a source of required things
208
+ # but not required to be here.
209
+ other_params = call_params.reject { |k, _| path_template.variables.include?(k) }
210
+
211
+ method_desc = (((api_description['resources'] || {})[resource_name] || {})['methods'] || {})[method_name]
212
+ request_schema = deref_schema(method_desc['request'])
213
+ if request_schema
214
+ # TODO deal with model_attributes / call_params better in nested whatever
215
+ body = request_body_for_schema(model_attributes.merge(call_params), request_schema)
216
+ body.update(call_params)
217
+ else
218
+ if other_params.any?
219
+ if METHODS_WITH_BODIES.any? { |m| m == http_method.downcase }
220
+ body = other_params
221
+ else
222
+ # TODO pay more attention to 'parameters' api method attribute
223
+ url.query_values = other_params
224
+ end
225
+ end
226
+ end
227
+
228
+ response = connection.run_request(http_method, url, body, nil).tap do |response|
229
+ error_class = Scorpio.error_classes_by_status[response.status]
230
+ error_class ||= if (400..499).include?(response.status)
231
+ ClientError
232
+ elsif (500..599).include?(response.status)
233
+ ServerError
234
+ elsif !response.success?
235
+ HTTPError
236
+ end
237
+ if error_class
238
+ message = "Error calling #{method_name} on #{self}:\n" + (response.env[:raw_body] || response.env.body)
239
+ raise error_class.new(message).tap { |e| e.response = response }
240
+ end
241
+ end
242
+ response_schema = method_desc['response']
243
+ response_object_to_instances(response.body, response_schema, 'persisted' => true)
244
+ end
245
+
246
+ def request_body_for_schema(object, schema)
247
+ schema = deref_schema(schema)
248
+ if object.is_a?(Scorpio::Model)
249
+ # TODO request_schema_fail unless schema is for given model type
250
+ request_body_for_schema(object.represent_for_schema(schema), schema)
251
+ else
252
+ if object.is_a?(Hash)
253
+ object.map do |key, value|
254
+ if schema
255
+ if schema['type'] == 'object'
256
+ # TODO code dup with response_object_to_instances
257
+ if schema['properties'] && schema['properties'][key]
258
+ subschema = schema['properties'][key]
259
+ include_pair = true
260
+ else
261
+ if schema['patternProperties']
262
+ _, pattern_schema = schema['patternProperties'].detect do |pattern, _|
263
+ key =~ Regexp.new(pattern) # TODO map pattern to ruby syntax
264
+ end
265
+ end
266
+ if pattern_schema
267
+ subschema = pattern_schema
268
+ include_pair = true
269
+ else
270
+ if schema['additionalProperties'] == false
271
+ include_pair = false
272
+ elsif schema['additionalProperties'] == nil
273
+ # TODO decide on this (can combine with `else` if treating nil same as schema present)
274
+ include_pair = true
275
+ subschema = nil
276
+ else
277
+ include_pair = true
278
+ subschema = schema['additionalProperties']
279
+ end
280
+ end
281
+ end
282
+ elsif schema['type']
283
+ request_schema_fail(object, schema)
284
+ else
285
+ # TODO not sure
286
+ include_pair = true
287
+ subschema = nil
288
+ end
289
+ end
290
+ if include_pair
291
+ {key => request_body_for_schema(value, subschema)}
292
+ else
293
+ {}
294
+ end
295
+ end.inject({}, &:update)
296
+ elsif object.is_a?(Array) || object.is_a?(Set)
297
+ object.map do |el|
298
+ if schema
299
+ if schema['type'] == 'array'
300
+ # TODO index based subschema or whatever else works for array
301
+ subschema = schema['items']
302
+ else
303
+ request_schema_fail(object, schema)
304
+ end
305
+ end
306
+ request_body_for_schema(el, subschema)
307
+ end
308
+ else
309
+ # TODO maybe raise on anything not jsonifiable
310
+ # TODO check conformance to schema, request_schema_fail if not
311
+ object
312
+ end
313
+ end
314
+ end
315
+
316
+ def request_schema_fail(object, schema)
317
+ raise("object does not conform to schema.\nobject = #{object.inspect}\nschema = #{JSON.pretty_generate(schema, quirks_mode: true)}")
318
+ end
319
+
320
+ def response_object_to_instances(object, schema, initialize_options = {})
321
+ schema = deref_schema(schema)
322
+ if schema
323
+ if schema['type'] == 'object' && MODULES_FOR_JSON_SCHEMA_TYPES['object'].any? { |m| object.is_a?(m) }
324
+ out = object.map do |key, value|
325
+ schema_for_value = schema['properties'] && schema['properties'][key] ||
326
+ if schema['patternProperties']
327
+ _, pattern_schema = schema['patternProperties'].detect do |pattern, _|
328
+ key =~ Regexp.new(pattern)
329
+ end
330
+ pattern_schema
331
+ end ||
332
+ schema['additionalProperties']
333
+ {key => response_object_to_instances(value, schema_for_value)}
334
+ end.inject(object.class.new, &:update)
335
+ model = models_by_schema_id[schema['id']]
336
+ if model
337
+ model.new(out, initialize_options)
338
+ else
339
+ out
340
+ end
341
+ elsif schema['type'] == 'array' && MODULES_FOR_JSON_SCHEMA_TYPES['array'].any? { |m| object.is_a?(m) }
342
+ object.map do |element|
343
+ response_object_to_instances(element, schema['items'])
344
+ end
345
+ else
346
+ object
347
+ end
348
+ else
349
+ object
350
+ end
351
+ end
352
+ end
353
+
354
+ def initialize(attributes = {}, options = {})
355
+ @attributes = Scorpio.stringify_symbol_keys(attributes)
356
+ @options = Scorpio.stringify_symbol_keys(options)
357
+ @persisted = !!@options['persisted']
358
+ end
359
+
360
+ attr_reader :attributes
361
+ attr_reader :options
362
+
363
+ def persisted?
364
+ @persisted
365
+ end
366
+
367
+ def [](key)
368
+ @attributes[key]
369
+ end
370
+
371
+ def []=(key, value)
372
+ @attributes[key] = value
373
+ end
374
+
375
+ def ==(other)
376
+ @attributes == other.instance_eval { @attributes }
377
+ end
378
+
379
+ def call_api_method(method_name, call_params: nil)
380
+ response = self.class.call_api_method(method_name, call_params: call_params, model_attributes: self.attributes)
381
+
382
+ # if we're making a POST or PUT and the request schema is this resource, we'll assume that
383
+ # the request is persisting this resource
384
+ api_method = self.class.api_description['resources'][self.class.resource_name]['methods'][method_name]
385
+ request_schema = self.class.deref_schema(api_method['request'])
386
+ request_resource_is_self = request_schema &&
387
+ request_schema['id'] &&
388
+ self.class.schemas_by_key.any? { |key, as| as['id'] == request_schema['id'] && self.class.schema_keys.include?(key) }
389
+ response_schema = self.class.deref_schema(api_method['response'])
390
+ response_resource_is_self = response_schema &&
391
+ response_schema['id'] &&
392
+ self.class.schemas_by_key.any? { |key, as| as['id'] == response_schema['id'] && self.class.schema_keys.include?(key) }
393
+ if request_resource_is_self && %w(PUT POST).include?(api_method['httpMethod'])
394
+ @persisted = true
395
+
396
+ if response_resource_is_self
397
+ @attributes = response.attributes
398
+ end
399
+ end
400
+
401
+ response
402
+ end
403
+
404
+ # TODO
405
+ def represent_for_schema(schema)
406
+ @attributes
407
+ end
408
+
409
+ alias eql? ==
410
+
411
+ def hash
412
+ @attributes.hash
413
+ end
414
+ end
415
+ end