scorpio 0.0.4 → 0.1.0

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,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