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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +37 -14
- data/bin/documents_to_yml.rb +26 -0
- data/documents/swagger.io/v2/schema.json +1607 -0
- data/documents/swagger.io/v2/schema.yml +1079 -0
- data/documents/www.googleapis.com/discovery/v1/apis/discovery/v1/rest +684 -0
- data/{getRest.yml → documents/www.googleapis.com/discovery/v1/apis/discovery/v1/rest.yml} +34 -72
- data/lib/scorpio.rb +30 -1
- data/lib/scorpio/google_api_document.rb +232 -0
- data/lib/scorpio/json-schema-fragments.rb +191 -0
- data/lib/scorpio/json.rb +5 -0
- data/lib/scorpio/json/node.rb +214 -0
- data/lib/scorpio/model.rb +178 -91
- data/lib/scorpio/openapi.rb +81 -0
- data/lib/scorpio/schema.rb +133 -0
- data/lib/scorpio/schema_object_base.rb +227 -0
- data/lib/scorpio/typelike_modules.rb +52 -0
- data/lib/scorpio/version.rb +1 -1
- data/scorpio.gemspec +2 -1
- metadata +20 -8
@@ -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
|
data/lib/scorpio/json.rb
ADDED
@@ -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
|
data/lib/scorpio/model.rb
CHANGED
@@ -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
|
-
|
38
|
-
|
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(:
|
41
|
-
|
42
|
-
|
43
|
-
|
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(:
|
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
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
69
|
-
|
70
|
-
self.
|
71
|
-
|
72
|
-
|
73
|
-
|
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
|
-
|
76
|
-
schemas_by_key
|
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, _|
|
107
|
+
schemas_by_key.select { |k, _| definition_keys.include?(k) }.map do |schema_key, schema|
|
88
108
|
unless schema['type'] == 'object'
|
89
|
-
raise "
|
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
|
-
|
112
|
-
|
113
|
-
|
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
|
-
|
201
|
+
if operation_for_resource_class?(operation) && !respond_to?(method_name)
|
116
202
|
define_singleton_method(method_name) do |call_params = nil|
|
117
|
-
|
203
|
+
call_operation(operation, call_params: call_params)
|
118
204
|
end
|
119
205
|
end
|
120
206
|
|
121
207
|
# instance method
|
122
|
-
|
123
|
-
|
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
|
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
|
-
|
195
|
-
|
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 #{
|
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 #{
|
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
|
-
|
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 #{
|
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
|
-
|
261
|
-
|
262
|
-
|
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.
|
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
|
-
|
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
|
395
|
-
|
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
|
399
|
-
response = self.class.
|
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
|
-
|
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.
|
408
|
-
|
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
|
-
|
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
|
431
|
-
@attributes
|
516
|
+
def fingerprint
|
517
|
+
{class: self.class, attributes: @attributes}
|
432
518
|
end
|
519
|
+
include FingerprintHash
|
433
520
|
end
|
434
521
|
end
|