scorpio 0.0.4 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,191 @@
1
+ require "json-schema"
2
+
3
+ # apply the changes from https://github.com/ruby-json-schema/json-schema/pull/382
4
+
5
+ # json-schema/pointer.rb
6
+ require 'addressable/uri'
7
+
8
+ module JSON
9
+ class Schema
10
+ # a JSON Pointer, as described by RFC 6901 https://tools.ietf.org/html/rfc6901
11
+ class Pointer
12
+ class Error < JSON::Schema::SchemaError
13
+ end
14
+ class PointerSyntaxError < Error
15
+ end
16
+ class ReferenceError < Error
17
+ end
18
+
19
+ # parse a fragment to an array of reference tokens
20
+ #
21
+ # #/foo/bar
22
+ #
23
+ # => ['foo', 'bar']
24
+ #
25
+ # #/foo%20bar
26
+ #
27
+ # => ['foo bar']
28
+ def self.parse_fragment(fragment)
29
+ fragment = Addressable::URI.unescape(fragment)
30
+ match = fragment.match(/\A#/)
31
+ if match
32
+ parse_pointer(match.post_match)
33
+ else
34
+ raise(PointerSyntaxError, "Invalid fragment syntax in #{fragment.inspect}: fragment must begin with #")
35
+ end
36
+ end
37
+
38
+ # parse a pointer to an array of reference tokens
39
+ #
40
+ # /foo
41
+ #
42
+ # => ['foo']
43
+ #
44
+ # /foo~0bar/baz~1qux
45
+ #
46
+ # => ['foo~bar', 'baz/qux']
47
+ def self.parse_pointer(pointer_string)
48
+ tokens = pointer_string.split('/', -1).map! do |piece|
49
+ piece.gsub('~1', '/').gsub('~0', '~')
50
+ end
51
+ if tokens[0] == ''
52
+ tokens[1..-1]
53
+ elsif tokens.empty?
54
+ tokens
55
+ else
56
+ raise(PointerSyntaxError, "Invalid pointer syntax in #{pointer_string.inspect}: pointer must begin with /")
57
+ end
58
+ end
59
+
60
+ # initializes a JSON::Schema::Pointer from the given representation.
61
+ #
62
+ # type may be one of:
63
+ #
64
+ # - :fragment - the representation is a fragment containing a pointer (starting with #)
65
+ # - :pointer - the representation is a pointer (starting with /)
66
+ # - :reference_tokens - the representation is an array of tokens referencing a path in a document
67
+ def initialize(type, representation)
68
+ @type = type
69
+ if type == :reference_tokens
70
+ reference_tokens = representation
71
+ elsif type == :fragment
72
+ reference_tokens = self.class.parse_fragment(representation)
73
+ elsif type == :pointer
74
+ reference_tokens = self.class.parse_pointer(representation)
75
+ else
76
+ raise ArgumentError, "invalid initialization type: #{type.inspect} with representation #{representation.inspect}"
77
+ end
78
+ @reference_tokens = reference_tokens.map(&:freeze).freeze
79
+ end
80
+
81
+ attr_reader :reference_tokens
82
+
83
+ # takes a root json document and evaluates this pointer through the document, returning the value
84
+ # pointed to by this pointer.
85
+ def evaluate(document)
86
+ reference_tokens.inject(document) do |value, token|
87
+ if value.is_a?(Array)
88
+ if token.is_a?(String) && token =~ /\A\d|[1-9]\d+\z/
89
+ token = token.to_i
90
+ end
91
+ unless token.is_a?(Integer)
92
+ raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} is not an integer and cannot be resolved in array #{value.inspect}")
93
+ end
94
+ unless (0...value.size).include?(token)
95
+ raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} is not a valid index of #{value.inspect}")
96
+ end
97
+ elsif value.is_a?(Hash)
98
+ unless value.key?(token)
99
+ raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} is not a valid key of #{value.inspect}")
100
+ end
101
+ else
102
+ raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} cannot be resolved in #{value.inspect}")
103
+ end
104
+ value[token]
105
+ end
106
+ end
107
+
108
+ # the pointer string representation of this Pointer
109
+ def pointer
110
+ reference_tokens.map { |t| '/' + t.to_s.gsub('~', '~0').gsub('/', '~1') }.join('')
111
+ end
112
+
113
+ # the fragment string representation of this Pointer
114
+ def fragment
115
+ '#' + Addressable::URI.escape(pointer)
116
+ end
117
+
118
+ def to_s
119
+ "#<#{self.class.name} #{@type} = #{representation_s}>"
120
+ end
121
+
122
+ private
123
+
124
+ def representation_s
125
+ if @type == :fragment
126
+ fragment
127
+ elsif @type == :pointer
128
+ pointer
129
+ else
130
+ reference_tokens.inspect
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # json-schema/validator.rb
138
+
139
+ module JSON
140
+ class Validator
141
+ def initialize(schema_data, data, opts={})
142
+ @options = @@default_opts.clone.merge(opts)
143
+ @errors = []
144
+
145
+ validator = self.class.validator_for_name(@options[:version])
146
+ @options[:version] = validator
147
+ @options[:schema_reader] ||= self.class.schema_reader
148
+
149
+ @validation_options = @options[:record_errors] ? {:record_errors => true} : {}
150
+ @validation_options[:insert_defaults] = true if @options[:insert_defaults]
151
+ @validation_options[:strict] = true if @options[:strict] == true
152
+ @validation_options[:clear_cache] = true if !@@cache_schemas || @options[:clear_cache]
153
+
154
+ @@mutex.synchronize { @base_schema = initialize_schema(schema_data) }
155
+ @original_data = data
156
+ @data = initialize_data(data)
157
+ @@mutex.synchronize { build_schemas(@base_schema) }
158
+
159
+ # If the :fragment option is set, try and validate against the fragment
160
+ if opts[:fragment]
161
+ @base_schema = schema_from_fragment(@base_schema, opts[:fragment])
162
+ end
163
+
164
+ # validate the schema, if requested
165
+ if @options[:validate_schema]
166
+ if @base_schema.schema["$schema"]
167
+ base_validator = self.class.validator_for_name(@base_schema.schema["$schema"])
168
+ end
169
+ metaschema = base_validator ? base_validator.metaschema : validator.metaschema
170
+ # Don't clear the cache during metaschema validation!
171
+ self.class.validate!(metaschema, @base_schema.schema, {:clear_cache => false})
172
+ end
173
+ end
174
+
175
+ def schema_from_fragment(base_schema, fragment)
176
+ schema_uri = base_schema.uri
177
+
178
+ pointer = JSON::Schema::Pointer.new(:fragment, fragment)
179
+
180
+ base_schema = JSON::Schema.new(pointer.evaluate(base_schema.schema), schema_uri, @options[:version])
181
+
182
+ if @options[:list]
183
+ base_schema.to_array_schema
184
+ elsif base_schema.is_a?(Hash)
185
+ JSON::Schema.new(base_schema, schema_uri, @options[:version])
186
+ else
187
+ base_schema
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,5 @@
1
+ module Scorpio
2
+ module JSON
3
+ autoload :Node, 'scorpio/json/node'
4
+ end
5
+ end
@@ -0,0 +1,214 @@
1
+ require 'scorpio/typelike_modules'
2
+
3
+ module Scorpio
4
+ module JSON
5
+ class Node
6
+ def self.new_by_type(document, path)
7
+ node = Node.new(document, path)
8
+ content = node.content
9
+ if content.is_a?(Hash)
10
+ HashNode.new(document, path)
11
+ elsif content.is_a?(Array)
12
+ ArrayNode.new(document, path)
13
+ else
14
+ node
15
+ end
16
+ end
17
+
18
+ def initialize(document, path)
19
+ raise(ArgumentError, "path must be an array. got: #{path.pretty_inspect} (#{path.class})") unless path.is_a?(Array)
20
+ define_singleton_method(:document) { document }
21
+ @path = path.dup.freeze
22
+ @pointer = ::JSON::Schema::Pointer.new(:reference_tokens, path)
23
+ end
24
+
25
+ attr_reader :path
26
+ attr_reader :document
27
+ attr_reader :pointer
28
+
29
+ def content
30
+ pointer.evaluate(document)
31
+ end
32
+
33
+ def [](k)
34
+ node = self
35
+ content = node.content
36
+ if content.is_a?(Hash) && !content.key?(k)
37
+ node = node.deref
38
+ content = node.content
39
+ end
40
+ begin
41
+ el = content[k]
42
+ rescue TypeError => e
43
+ raise(e.class, e.message + "\nsubscripting from #{content.pretty_inspect} (#{content.class}): #{k.pretty_inspect} (#{k.class})", e.backtrace)
44
+ end
45
+ if el.is_a?(Hash) || el.is_a?(Array)
46
+ self.class.new_by_type(node.document, node.path + [k])
47
+ else
48
+ el
49
+ end
50
+ end
51
+
52
+ def deref
53
+ content = self.content
54
+
55
+ return self unless content.is_a?(Hash) && content['$ref'].is_a?(String)
56
+
57
+ if content['$ref'][/\A#/]
58
+ return self.class.new_by_type(document, ::JSON::Schema::Pointer.parse_fragment(content['$ref'])).deref
59
+ end
60
+
61
+ # HAX for how google does refs and ids
62
+ if document_node['schemas'].respond_to?(:to_hash)
63
+ if document_node['schemas'][content['$ref']]
64
+ return document_node['schemas'][content['$ref']]
65
+ end
66
+ _, deref_by_id = document_node['schemas'].detect { |_k, schema| schema['id'] == content['$ref'] }
67
+ if deref_by_id
68
+ return deref_by_id
69
+ end
70
+ end
71
+
72
+ #raise(NotImplementedError, "cannot dereference #{content['$ref']}") # TODO
73
+ return self
74
+ end
75
+
76
+ def document_node
77
+ Node.new_by_type(document, [])
78
+ end
79
+
80
+ def pointer_path
81
+ pointer.pointer
82
+ end
83
+ def fragment
84
+ pointer.fragment
85
+ end
86
+
87
+ def object_group_text
88
+ "fragment=#{fragment.inspect}"
89
+ end
90
+ def inspect
91
+ "\#<#{self.class.name} #{object_group_text} #{content.inspect}>"
92
+ end
93
+ def pretty_print(q)
94
+ q.instance_exec(self) do |obj|
95
+ text "\#<#{obj.class.name} #{object_group_text}"
96
+ group_sub {
97
+ nest(2) {
98
+ breakable ' '
99
+ pp obj.content
100
+ }
101
+ }
102
+ breakable ''
103
+ text '>'
104
+ end
105
+ end
106
+
107
+ def fingerprint
108
+ {class: self.class, document: document, path: path}
109
+ end
110
+ include FingerprintHash
111
+ end
112
+
113
+ class ArrayNode < Node
114
+ def each
115
+ return to_enum(__method__) { content.size } unless block_given?
116
+ content.each_index { |i| yield self[i] }
117
+ self
118
+ end
119
+ include Enumerable
120
+
121
+ def to_ary
122
+ to_a
123
+ end
124
+
125
+ include Arraylike
126
+
127
+ # array methods - define only those which do not modify the array.
128
+
129
+ # methods that don't look at the value; can skip the overhead of #[] (invoked by #to_a)
130
+ index_methods = %w(each_index empty? length size)
131
+ index_methods.each do |method_name|
132
+ define_method(method_name) { |*a, &b| content.public_send(method_name, *a, &b) }
133
+ end
134
+
135
+ # methods which use index and value.
136
+ # flatten is omitted. flatten should not exist.
137
+ array_methods = %w(& | * + - <=> abbrev assoc at bsearch bsearch_index combination compact count cycle dig fetch index first include? join last pack permutation rassoc repeated_combination reject reverse reverse_each rindex rotate sample select shelljoin shuffle slice sort take take_while transpose uniq values_at zip)
138
+ array_methods.each do |method_name|
139
+ define_method(method_name) { |*a, &b| to_a.public_send(method_name, *a, &b) }
140
+ end
141
+ end
142
+
143
+ class HashNode < Node
144
+ def each
145
+ return to_enum(__method__) { content.size } unless block_given?
146
+ content.each_key { |k| yield k, self[k] }
147
+ self
148
+ end
149
+ include Enumerable
150
+
151
+ def to_hash
152
+ inject({}) { |h, (k, v)| h[k] = v; h }
153
+ end
154
+
155
+ include Hashlike
156
+
157
+ # hash methods - define only those which do not modify the hash.
158
+
159
+ # methods that don't look at the value; can skip the overhead of #[] (invoked by #to_hash)
160
+ key_methods = %w(each_key empty? include? has_key? key key? keys length member? size)
161
+ key_methods.each do |method_name|
162
+ define_method(method_name) { |*a, &b| content.public_send(method_name, *a, &b) }
163
+ end
164
+
165
+ # methods which use key and value
166
+ hash_methods = %w(any? compact dig each_pair each_value fetch fetch_values has_value? invert rassoc reject select to_h transform_values value? values values_at)
167
+ hash_methods.each do |method_name|
168
+ define_method(method_name) { |*a, &b| to_hash.public_send(method_name, *a, &b) }
169
+ end
170
+
171
+ # methods that return a modified copy
172
+ def merge(other)
173
+ # we need to preserve the rest of the document, but modify the content at our path.
174
+ #
175
+ # this is actually a bit tricky. we can't modify the original document, obviously.
176
+ # we could do a deep copy, but that's expensive. instead, we make a copy of each array
177
+ # or hash in the path above this node. this node's content is merged with `other`, and
178
+ # that is recursively merged up to the document root. the recursion is done with a
179
+ # y combinator, for no other reason than that was a fun way to implement it.
180
+ merged_document = ycomb do |rec|
181
+ proc do |subdocument, subpath|
182
+ if subpath == []
183
+ subdocument.merge(other.is_a?(JSON::Node) ? other.content : other)
184
+ else
185
+ car = subpath[0]
186
+ cdr = subpath[1..-1]
187
+ if subdocument.is_a?(Array)
188
+ if car.is_a?(String) && car =~ /\A\d+\z/
189
+ car = car.to_i
190
+ end
191
+ unless car.is_a?(Integer)
192
+ raise(TypeError, "bad subscript #{car.pretty_inspect} with remaining subpath: #{cdr.inspect} for array: #{subdocument.pretty_inspect}")
193
+ end
194
+ end
195
+ car_object = rec.call(subdocument[car], cdr)
196
+ if car_object == subdocument[car]
197
+ subdocument
198
+ elsif subdocument.is_a?(Hash)
199
+ subdocument.merge({car => car_object})
200
+ elsif subdocument.is_a?(Array)
201
+ subdocument.dup.tap do |arr|
202
+ arr[car] = car_object
203
+ end
204
+ else
205
+ raise(TypeError, "bad subscript: #{car.pretty_inspect} with remaining subpath: #{cdr.inspect} for content: #{subdocument.pretty_inspect}")
206
+ end
207
+ end
208
+ end
209
+ end.call(document, path)
210
+ self.class.new(merged_document, path)
211
+ end
212
+ end
213
+ end
214
+ end
@@ -1,5 +1,4 @@
1
1
  require 'addressable/template'
2
- require 'json-schema'
3
2
  require 'faraday_middleware'
4
3
 
5
4
  module Scorpio
@@ -12,18 +11,27 @@ module Scorpio
12
11
  class << self
13
12
  def define_inheritable_accessor(accessor, options = {})
14
13
  if options[:default_getter]
14
+ # the value before the field is set (overwritten) is the result of the default_getter proc
15
15
  define_singleton_method(accessor, &options[:default_getter])
16
16
  else
17
+ # the value before the field is set (overwritten) is the default_value (which is nil if not specified)
17
18
  default_value = options[:default_value]
18
19
  define_singleton_method(accessor) { default_value }
19
20
  end
21
+ # field setter method. redefines the getter, replacing the method with one that returns the
22
+ # setter's argument (that being inherited to the scope of the define_method(accessor) block
20
23
  define_singleton_method(:"#{accessor}=") do |value|
24
+ # the setter operates on the singleton class of the receiver (self)
21
25
  singleton_class.instance_exec(value, self) do |value_, klass|
26
+ # remove a previous getter. NameError is raised if a getter is not defined on this class;
27
+ # this may be ignored.
22
28
  begin
23
29
  remove_method(accessor)
24
30
  rescue NameError
25
31
  end
32
+ # getter method
26
33
  define_method(accessor) { value_ }
34
+ # invoke on_set callback defined on the class
27
35
  if options[:on_set]
28
36
  klass.instance_exec(&options[:on_set])
29
37
  end
@@ -34,46 +42,58 @@ module Scorpio
34
42
  end
35
43
  end
36
44
  end
37
- define_inheritable_accessor(:api_description_class)
38
- define_inheritable_accessor(:api_description, on_set: proc { self.api_description_class = self })
45
+ # the class on which the openapi document is defined. subclasses use the openapi document set on this class
46
+ # (except in the unlikely event it is overwritten by a subclass)
47
+ define_inheritable_accessor(:openapi_document_class)
48
+ # the openapi document
49
+ define_inheritable_accessor(:openapi_document, on_set: proc { self.openapi_document_class = self })
39
50
  define_inheritable_accessor(:resource_name, update_methods: true)
40
- define_inheritable_accessor(:schema_keys, default_value: [], update_methods: true, on_set: proc do
41
- schema_keys.each do |key|
42
- api_description_class.models_by_schema_id = api_description_class.models_by_schema_id.merge(schemas_by_key[key]['id'] => self)
43
- api_description_class.models_by_schema_key = api_description_class.models_by_schema_key.merge(key => self)
51
+ define_inheritable_accessor(:definition_keys, default_value: [], update_methods: true, on_set: proc do
52
+ definition_keys.each do |key|
53
+ schema_as_key = schemas_by_key[key]
54
+ schema_as_key = schema_as_key.object if schema_as_key.is_a?(Scorpio::OpenAPI::Schema)
55
+ schema_as_key = schema_as_key.content if schema_as_key.is_a?(Scorpio::JSON::Node)
56
+
57
+ openapi_document_class.models_by_schema = openapi_document_class.models_by_schema.merge(schema_as_key => self)
44
58
  end
45
59
  end)
46
60
  define_inheritable_accessor(:schemas_by_key, default_value: {})
61
+ define_inheritable_accessor(:schemas_by_path)
47
62
  define_inheritable_accessor(:schemas_by_id, default_value: {})
48
- define_inheritable_accessor(:models_by_schema_id, default_value: {})
49
- define_inheritable_accessor(:models_by_schema_key, default_value: {})
63
+ define_inheritable_accessor(:models_by_schema, default_value: {})
50
64
  define_inheritable_accessor(:base_url)
51
65
 
52
66
  define_inheritable_accessor(:faraday_request_middleware, default_value: [])
53
67
  define_inheritable_accessor(:faraday_adapter, default_getter: proc { Faraday.default_adapter })
54
68
  define_inheritable_accessor(:faraday_response_middleware, default_value: [])
55
69
  class << self
56
- def api_description_schema
57
- @api_description_schema ||= begin
58
- rest = YAML.load_file(Pathname.new(__FILE__).join('../../../getRest.yml'))
59
- rest['schemas'].each do |name, schema_hash|
60
- # URI hax because google doesn't put a URI in the id field properly
61
- schema = JSON::Schema.new(schema_hash, Addressable::URI.parse(''))
62
- JSON::Validator.add_schema(schema)
70
+ def set_openapi_document(openapi_document)
71
+ if openapi_document.is_a?(Hash)
72
+ openapi_document = OpenAPI::Document.new(openapi_document)
73
+ end
74
+ openapi_document.paths.each do |path, path_item|
75
+ path_item.each do |http_method, operation|
76
+ next if http_method == 'parameters' # parameters is not an operation. TOOD maybe just select the keys that are http methods?
77
+ unless operation.is_a?(Scorpio::OpenAPI::Operation)
78
+ raise("bad operation at #{operation.fragment}: #{operation.pretty_inspect}")
79
+ end
80
+ operation.path = path
81
+ operation.http_method = http_method
63
82
  end
64
- rest['schemas']['RestDescription']
65
83
  end
66
- end
67
84
 
68
- def set_api_description(api_description)
69
- JSON::Validator.validate!(api_description_schema, api_description)
70
- self.api_description = api_description
71
- (api_description['schemas'] || {}).each do |schema_key, schema|
72
- unless schema['id']
73
- raise ArgumentError, "schema #{schema_key} did not contain an id"
85
+ openapi_document.validate!
86
+ self.schemas_by_path = {}
87
+ self.schemas_by_key = {}
88
+ self.schemas_by_id = {}
89
+ self.openapi_document = openapi_document
90
+ (openapi_document.definitions || {}).each do |schema_key, schema|
91
+ if schema['id']
92
+ # this isn't actually allowed by openapi's definition. whatever.
93
+ self.schemas_by_id = self.schemas_by_id.merge(schema['id'] => schema)
74
94
  end
75
- schemas_by_id[schema['id']] = schema
76
- schemas_by_key[schema_key] = schema
95
+ self.schemas_by_path = self.schemas_by_path.merge(schema.object.fragment => schema)
96
+ self.schemas_by_key = self.schemas_by_key.merge(schema_key => schema)
77
97
  end
78
98
  update_dynamic_methods
79
99
  end
@@ -84,9 +104,9 @@ module Scorpio
84
104
  end
85
105
 
86
106
  def all_schema_properties
87
- schemas_by_key.select { |k, _| schema_keys.include?(k) }.map do |schema_key, schema|
107
+ schemas_by_key.select { |k, _| definition_keys.include?(k) }.map do |schema_key, schema|
88
108
  unless schema['type'] == 'object'
89
- 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
109
+ raise "definition key #{schema_key} for #{self} is not of type object - type must be object for Scorpio Model to represent this schema" # TODO class
90
110
  end
91
111
  schema['properties'].keys
92
112
  end.inject([], &:|)
@@ -107,49 +127,87 @@ module Scorpio
107
127
  end
108
128
  end
109
129
 
130
+ def operation_for_resource_class?(operation)
131
+ return false unless resource_name
132
+
133
+ return true if operation['x-resource'] == self.resource_name
134
+
135
+ return true if operation.operationId =~ /\A#{Regexp.escape(resource_name)}\.(\w+)\z/
136
+
137
+ request_schema = operation.body_parameter['schema'] if operation.body_parameter
138
+ if request_schema && schemas_by_key.any? { |key, as| as == request_schema && definition_keys.include?(key) }
139
+ return true
140
+ end
141
+
142
+ return false
143
+ end
144
+
145
+ def operation_for_resource_instance?(operation)
146
+ return false unless operation_for_resource_class?(operation)
147
+
148
+ request_schema = operation.body_parameter['schema'] if operation.body_parameter
149
+
150
+ # define an instance method if the request schema is for this model
151
+ request_resource_is_self = request_schema &&
152
+ schemas_by_key.any? { |key, as| as == request_schema && definition_keys.include?(key) }
153
+
154
+ # also define an instance method depending on certain attributes the request description
155
+ # might have in common with the model's schema attributes
156
+ request_attributes = []
157
+ # if the path has attributes in common with model schema attributes, we'll define on
158
+ # instance method
159
+ request_attributes |= Addressable::Template.new(operation.path).variables
160
+ # TODO if the method request schema has attributes in common with the model schema attributes,
161
+ # should we define an instance method?
162
+ #request_attributes |= request_schema && request_schema['type'] == 'object' && request_schema['properties'] ?
163
+ # request_schema['properties'].keys : []
164
+ # TODO if the method parameters have attributes in common with the model schema attributes,
165
+ # should we define an instance method?
166
+ #request_attributes |= method_desc['parameters'] ? method_desc['parameters'].keys : []
167
+
168
+ schema_attributes = definition_keys.map do |schema_key|
169
+ schema = schemas_by_key[schema_key]
170
+ schema['type'] == 'object' && schema['properties'] ? schema['properties'].keys : []
171
+ end.inject([], &:|)
172
+
173
+ return request_resource_is_self || (request_attributes & schema_attributes).any?
174
+ end
175
+
176
+ def method_names_by_operation
177
+ @method_names_by_operation ||= Hash.new do |h, operation|
178
+ h[operation] = begin
179
+ raise(ArgumentError, operation.pretty_inspect) unless operation.is_a?(Scorpio::OpenAPI::Operation)
180
+ if operation['x-resource-method']
181
+ method_name = operation['x-resource-method']
182
+ elsif resource_name && operation.operationId =~ /\A#{Regexp.escape(resource_name)}\.(\w+)\z/
183
+ method_name = $1
184
+ else
185
+ method_name = operation.operationId || raise("no operationId on operation: #{operation.pretty_inspect}")
186
+ end
187
+ method_name = '_' + method_name unless method_name[/\A[a-zA-Z_]/]
188
+ method_name.gsub(/[^\w]/, '_')
189
+ end
190
+ end
191
+ end
192
+
110
193
  def update_class_and_instance_api_methods
111
- if self.resource_name && api_description
112
- resource_api_methods = ((api_description['resources'] || {})[resource_name] || {})['methods'] || {}
113
- resource_api_methods.each do |method_name, method_desc|
194
+ openapi_document.paths.each do |path, path_item|
195
+ path_item.each do |http_method, operation|
196
+ next if http_method == 'parameters' # parameters is not an operation. TOOD maybe just select the keys that are http methods?
197
+ operation.path = path
198
+ operation.http_method = http_method
199
+ method_name = method_names_by_operation[operation]
114
200
  # class method
115
- unless respond_to?(method_name)
201
+ if operation_for_resource_class?(operation) && !respond_to?(method_name)
116
202
  define_singleton_method(method_name) do |call_params = nil|
117
- call_api_method(method_name, call_params: call_params)
203
+ call_operation(operation, call_params: call_params)
118
204
  end
119
205
  end
120
206
 
121
207
  # instance method
122
- unless method_defined?(method_name)
123
- request_schema = deref_schema(method_desc['request'])
124
-
125
- # define an instance method if the request schema is for this model
126
- request_resource_is_self = request_schema &&
127
- request_schema['id'] &&
128
- schemas_by_key.any? { |key, as| as['id'] == request_schema['id'] && schema_keys.include?(key) }
129
-
130
- # also define an instance method depending on certain attributes the request description
131
- # might have in common with the model's schema attributes
132
- request_attributes = []
133
- # if the path has attributes in common with model schema attributes, we'll define on
134
- # instance method
135
- request_attributes |= Addressable::Template.new(method_desc['path']).variables
136
- # TODO if the method request schema has attributes in common with the model schema attributes,
137
- # should we define an instance method?
138
- #request_attributes |= request_schema && request_schema['type'] == 'object' && request_schema['properties'] ?
139
- # request_schema['properties'].keys : []
140
- # TODO if the method parameters have attributes in common with the model schema attributes,
141
- # should we define an instance method?
142
- #request_attributes |= method_desc['parameters'] ? method_desc['parameters'].keys : []
143
-
144
- schema_attributes = schema_keys.map do |schema_key|
145
- schema = schemas_by_key[schema_key]
146
- schema['type'] == 'object' && schema['properties'] ? schema['properties'].keys : []
147
- end.inject([], &:|)
148
-
149
- if request_resource_is_self || (request_attributes & schema_attributes).any?
150
- define_method(method_name) do |call_params = nil|
151
- call_api_method(method_name, call_params: call_params)
152
- end
208
+ if operation_for_resource_instance?(operation) && !method_defined?(method_name)
209
+ define_method(method_name) do |call_params = nil|
210
+ call_operation(operation, call_params: call_params)
153
211
  end
154
212
  end
155
213
  end
@@ -157,6 +215,8 @@ module Scorpio
157
215
  end
158
216
 
159
217
  def deref_schema(schema)
218
+ schema = schema.object if schema.is_a?(Scorpio::SchemaObjectBase)
219
+ schema = schema.deref if schema.is_a?(Scorpio::JSON::Node)
160
220
  schema && schemas_by_id[schema['$ref']] || schema
161
221
  end
162
222
 
@@ -188,22 +248,21 @@ module Scorpio
188
248
  end
189
249
  end
190
250
 
191
- def call_api_method(method_name, call_params: nil, model_attributes: nil)
251
+ def call_operation(operation, call_params: nil, model_attributes: nil)
192
252
  call_params = Scorpio.stringify_symbol_keys(call_params) if call_params.is_a?(Hash)
193
253
  model_attributes = Scorpio.stringify_symbol_keys(model_attributes || {})
194
- method_desc = api_description['resources'][self.resource_name]['methods'][method_name]
195
- http_method = method_desc['httpMethod'].downcase.to_sym
196
- path_template = Addressable::Template.new(method_desc['path'])
254
+ http_method = operation.http_method.downcase.to_sym
255
+ path_template = Addressable::Template.new(operation.path)
197
256
  template_params = model_attributes
198
257
  template_params = template_params.merge(call_params) if call_params.is_a?(Hash)
199
258
  missing_variables = path_template.variables - template_params.keys
200
259
  if missing_variables.any?
201
- raise(ArgumentError, "path #{method_desc['path']} for method #{method_name} requires attributes " +
260
+ raise(ArgumentError, "path #{operation.path} for operation #{operation.operationId} requires attributes " +
202
261
  "which were missing: #{missing_variables.inspect}")
203
262
  end
204
263
  empty_variables = path_template.variables.select { |v| template_params[v].to_s.empty? }
205
264
  if empty_variables.any?
206
- raise(ArgumentError, "path #{method_desc['path']} for method #{method_name} requires attributes " +
265
+ raise(ArgumentError, "path #{operation.path} for operation #{operation.operationId} requires attributes " +
207
266
  "which were empty: #{empty_variables.inspect}")
208
267
  end
209
268
  path = path_template.expand(template_params)
@@ -215,8 +274,7 @@ module Scorpio
215
274
  other_params.reject! { |k, _| path_template.variables.include?(k) }
216
275
  end
217
276
 
218
- method_desc = (((api_description['resources'] || {})[resource_name] || {})['methods'] || {})[method_name]
219
- request_schema = deref_schema(method_desc['request'])
277
+ request_schema = operation.body_parameter && deref_schema(operation.body_parameter['schema'])
220
278
  if request_schema
221
279
  # TODO deal with model_attributes / call_params better in nested whatever
222
280
  if call_params.nil?
@@ -253,13 +311,21 @@ module Scorpio
253
311
  HTTPError
254
312
  end
255
313
  if error_class
256
- message = "Error calling #{method_name} on #{self}:\n" + (response.env[:raw_body] || response.env.body)
314
+ message = "Error calling operation #{operation.operationId} on #{self}:\n" + (response.env[:raw_body] || response.env.body)
257
315
  raise error_class.new(message).tap { |e| e.response = response }
258
316
  end
259
317
 
260
- response_schema = method_desc['response']
261
- source = {'method_name' => method_name, 'call_params' => call_params, 'url' => url.to_s}
262
- response_object_to_instances(response.body, response_schema, 'persisted' => true, 'source' => source)
318
+ if operation.responses
319
+ _, operation_response = operation.responses.detect { |k, v| k.to_s == response.status.to_s }
320
+ operation_response ||= operation.responses['default']
321
+ response_schema = operation_response.schema if operation_response
322
+ end
323
+ initialize_options = {
324
+ 'persisted' => true,
325
+ 'source' => {'operationId' => operation.operationId, 'call_params' => call_params, 'url' => url.to_s},
326
+ 'response' => response,
327
+ }
328
+ response_object_to_instances(response.body, response_schema, initialize_options)
263
329
  end
264
330
 
265
331
  def request_body_for_schema(object, schema)
@@ -333,7 +399,7 @@ module Scorpio
333
399
  end
334
400
 
335
401
  def request_schema_fail(object, schema)
336
- raise(RequestSchemaFailure, "object does not conform to schema.\nobject = #{object.inspect}\nschema = #{JSON.pretty_generate(schema, quirks_mode: true)}")
402
+ raise(RequestSchemaFailure, "object does not conform to schema.\nobject = #{object.pretty_inspect}\nschema = #{::JSON.pretty_generate(schema, quirks_mode: true)}")
337
403
  end
338
404
 
339
405
  def response_object_to_instances(object, schema, initialize_options = {})
@@ -351,7 +417,10 @@ module Scorpio
351
417
  schema['additionalProperties']
352
418
  {key => response_object_to_instances(value, schema_for_value, initialize_options)}
353
419
  end.inject(object.class.new, &:update)
354
- model = models_by_schema_id[schema['id']]
420
+ schema_as_key = schema
421
+ schema_as_key = schema_as_key.object if schema_as_key.is_a?(Scorpio::OpenAPI::Schema)
422
+ schema_as_key = schema_as_key.content if schema_as_key.is_a?(Scorpio::JSON::Node)
423
+ model = models_by_schema[schema_as_key]
355
424
  if model
356
425
  model.new(out, initialize_options)
357
426
  else
@@ -391,24 +460,26 @@ module Scorpio
391
460
  @attributes[key] = value
392
461
  end
393
462
 
394
- def ==(other)
395
- @attributes == other.instance_eval { @attributes }
463
+ def call_api_method(method_name, call_params: nil)
464
+ operation = self.class.method_names_by_operation.invert[method_name] || raise(ArgumentError)
465
+ call_operation(operation, call_params: call_params)
396
466
  end
397
467
 
398
- def call_api_method(method_name, call_params: nil)
399
- response = self.class.call_api_method(method_name, call_params: call_params, model_attributes: self.attributes)
468
+ def call_operation(operation, call_params: nil)
469
+ response = self.class.call_operation(operation, call_params: call_params, model_attributes: self.attributes)
400
470
 
401
471
  # if we're making a POST or PUT and the request schema is this resource, we'll assume that
402
472
  # the request is persisting this resource
403
- api_method = self.class.api_description['resources'][self.class.resource_name]['methods'][method_name]
404
- request_schema = self.class.deref_schema(api_method['request'])
473
+ request_schema = operation.body_parameter && self.class.deref_schema(operation.body_parameter['schema'])
405
474
  request_resource_is_self = request_schema &&
406
475
  request_schema['id'] &&
407
- self.class.schemas_by_key.any? { |key, as| as['id'] == request_schema['id'] && self.class.schema_keys.include?(key) }
408
- response_schema = self.class.deref_schema(api_method['response'])
476
+ self.class.schemas_by_key.any? { |key, as| (as['id'] ? as['id'] == request_schema['id'] : as == request_schema) && self.class.definition_keys.include?(key) }
477
+ if @options['response'] && @options['response'].status && operation.responses
478
+ _, response_schema = operation.responses.detect { |k, v| k.to_s == @options['response'].status.to_s }
479
+ end
480
+ response_schema = self.class.deref_schema(response_schema)
409
481
  response_resource_is_self = response_schema &&
410
- response_schema['id'] &&
411
- self.class.schemas_by_key.any? { |key, as| as['id'] == response_schema['id'] && self.class.schema_keys.include?(key) }
482
+ self.class.schemas_by_key.any? { |key, as| (as['id'] ? as['id'] == response_schema['id'] : as == response_schema) && self.class.definition_keys.include?(key) }
412
483
  if request_resource_is_self && %w(PUT POST).include?(api_method['httpMethod'])
413
484
  @persisted = true
414
485
 
@@ -425,10 +496,26 @@ module Scorpio
425
496
  @attributes
426
497
  end
427
498
 
428
- alias eql? ==
499
+ def inspect
500
+ "\#<#{self.class.name} #{attributes.inspect}>"
501
+ end
502
+ def pretty_print(q)
503
+ q.instance_exec(self) do |obj|
504
+ text "\#<#{obj.class.name}"
505
+ group_sub {
506
+ nest(2) {
507
+ breakable ' '
508
+ pp obj.attributes
509
+ }
510
+ }
511
+ breakable ''
512
+ text '>'
513
+ end
514
+ end
429
515
 
430
- def hash
431
- @attributes.hash
516
+ def fingerprint
517
+ {class: self.class, attributes: @attributes}
432
518
  end
519
+ include FingerprintHash
433
520
  end
434
521
  end