scorpio 0.0.1

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