discovery_v1 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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/inflector'
5
+
6
+ module DiscoveryV1
7
+ module Validation
8
+ # Load the Google Discovery V1 API description for the Discovery V1 API
9
+ #
10
+ # @example
11
+ # logger = Logger.new(STDOUT, :level => Logger::ERROR)
12
+ # schemas = DiscoveryV1::Validation::LoadSchemas.new(logger:).call
13
+ #
14
+ # @api private
15
+ #
16
+ class LoadSchemas
17
+ # Loads schemas for the Discovery V1 API object from the Google Discovery V1 API
18
+ #
19
+ # By default, a nil logger is used. This means that nothing is logged.
20
+ #
21
+ # The schemas are only loaded once and cached.
22
+ #
23
+ # @example
24
+ # schema_loader = DiscoveryV1::Validation::LoadSchemas.new
25
+ #
26
+ # @param rest_description [Google::Apis::DiscoveryV1::RestDescription]
27
+ # the api description to load schemas from
28
+ # @param logger [Logger] the logger to use
29
+ #
30
+ def initialize(rest_description:, logger: Logger.new(nil))
31
+ @rest_description = rest_description
32
+ @logger = logger
33
+ end
34
+
35
+ # The Google Discovery V1 API description to load schemas from
36
+ #
37
+ # @example
38
+ # rest_description = DiscoveryV1.discovery_service.get_rest_description('sheets', 'v4')
39
+ # loader = DiscoveryV1::Validation::LoadSchemas.new(rest_description:)
40
+ # loader.rest_description == rest_description # => true
41
+ #
42
+ # @return [Google::Apis::DiscoveryV1::RestDescription]
43
+ #
44
+ attr_reader :rest_description
45
+
46
+ # The logger to use internally for logging errors
47
+ #
48
+ # @example
49
+ # logger = Logger.new(STDOUT, :level => Logger::INFO)
50
+ # schema_loader = DiscoveryV1::Validation::LoadSchemas.new(logger)
51
+ # schema_loader.logger == logger # => true
52
+ #
53
+ # @return [Logger]
54
+ #
55
+ attr_reader :logger
56
+
57
+ # A hash of schemas keyed by schema name loaded from the Google Discovery V1 API
58
+ #
59
+ # @example
60
+ # DiscoveryV1.api_object_schemas #=> { 'PersonSchema' => { 'type' => 'object', ... } ... }
61
+ #
62
+ # @return [Hash<String, Object>] a hash of schemas keyed by schema name
63
+ #
64
+ def call
65
+ self.class.load_schemas_mutex.synchronize do
66
+ self.class.schemas(rest_description:) ||
67
+ self.class.memoize_schemas(rest_description:, schemas: load_api_schemas)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # A mutex used to synchronize access to the schemas so they are only loaded
74
+ # once
75
+ #
76
+ # @return [Thread::Mutex]
77
+ #
78
+ @load_schemas_mutex = Thread::Mutex.new
79
+
80
+ # Memoization cache that stores the schemas for each rest_description
81
+ #
82
+ # The cache is a hash where:
83
+ # * The key is the canonical name of the rest_description
84
+ # * The value is a hash of schemas keyed by schema name
85
+ #
86
+ # @return [Hash<String, Object] a hash of schemas keyed by schema name
87
+ #
88
+ # @api private
89
+ #
90
+ @schemas_cache = {}
91
+
92
+ class << self
93
+ # A mutex used to synchronize access to the schemas so they are only loaded once
94
+ #
95
+ # @return [Thread::Mutex]
96
+ #
97
+ # @api private
98
+ #
99
+ attr_reader :load_schemas_mutex
100
+
101
+ # The memoized schemas for the given rest_description or nil
102
+ #
103
+ # @param rest_description [Google::Apis::DiscoveryV1::RestDescription] the
104
+ # rest_description to get the schemas for
105
+ #
106
+ # @return [Hash<String, Object>, nil] a hash of schemas keyed by schema name
107
+ #
108
+ # @api private
109
+ #
110
+ def schemas(rest_description:)
111
+ @schemas_cache[rest_description.canonical_name]
112
+ end
113
+
114
+ # Memoize the schemas for the given rest_description returning the given schemas
115
+ #
116
+ # @param rest_description [Google::Apis::DiscoveryV1::RestDescription] the
117
+ # rest_description to memoize the schemas for
118
+ # @param schemas [Hash<String, Object>] a hash of schemas keyed by schema name
119
+ #
120
+ # @return [Hash<String, Object>] the given schemas
121
+ #
122
+ # @api private
123
+ #
124
+ def memoize_schemas(rest_description:, schemas:)
125
+ @schemas_cache[rest_description.canonical_name] = schemas
126
+ end
127
+
128
+ # Clear the memoization cache (intended for testing)
129
+ #
130
+ # @return [void]
131
+ #
132
+ # @api private
133
+ #
134
+ def clear_schemas_cache
135
+ @schemas_cache = {}
136
+ end
137
+ end
138
+
139
+ # Load the schemas from the Google Discovery V1 API
140
+ #
141
+ # @return [Hash<String, Object>] a hash of schemas keyed by schema name
142
+ #
143
+ # @api private
144
+ #
145
+ def load_api_schemas
146
+ source = "https://#{rest_description.name}.googleapis.com/$discovery/rest?version=#{rest_description.version}"
147
+ http_response = Net::HTTP.get_response(URI.parse(source))
148
+ raise_error(http_response) if http_response.code != '200'
149
+
150
+ data = http_response.body
151
+ JSON.parse(data)['schemas'].tap { |schemas| post_process_schemas(schemas) }
152
+ end
153
+
154
+ # Log an error and raise a RuntimeError based on the HTTP response code
155
+ # @param http_response [Net::HTTPResponse] the HTTP response
156
+ # @return [void]
157
+ # @raise [RuntimeError]
158
+ # @api private
159
+ def raise_error(http_response)
160
+ message = "HTTP Error '#{http_response.code}' loading schemas from '#{http_response.uri}'"
161
+ logger.error(message)
162
+ raise message
163
+ end
164
+
165
+ REF_KEY = '$ref'
166
+
167
+ # A visitor for the schema object tree that fixes up the tree as it goes
168
+ # @return [void]
169
+ # @api private
170
+ def schema_visitor(path:, object:)
171
+ return unless object.is_a? Hash
172
+
173
+ convert_schema_names_to_snake_case(path, object)
174
+ convert_schema_ids_to_snake_case(path, object)
175
+ add_unevaluated_properties(path, object)
176
+ convert_property_names_to_snake_case(path, object)
177
+ convert_ref_values_to_snake_case(path, object)
178
+ end
179
+
180
+ # Convert schema names to snake case
181
+ # @return [void]
182
+ # @api private
183
+ def convert_schema_names_to_snake_case(path, object)
184
+ object.transform_keys!(&:underscore) if path.empty?
185
+ end
186
+
187
+ # Convert schema IDs to snake case
188
+ # @return [void]
189
+ # @api private
190
+ def convert_schema_ids_to_snake_case(path, object)
191
+ object['id'] = object['id'].underscore if object.key?('id') && path.size == 1
192
+ end
193
+
194
+ # Add 'unevaluatedProperties: false' to all schemas
195
+ # @return [void]
196
+ # @api private
197
+ def add_unevaluated_properties(path, object)
198
+ object['unevaluatedProperties'] = false if path.size == 1
199
+ end
200
+
201
+ # Convert object property names to snake case
202
+ # @return [void]
203
+ # @api private
204
+ def convert_property_names_to_snake_case(path, object)
205
+ object.transform_keys!(&:underscore) if path[-1] == 'properties'
206
+ end
207
+
208
+ # Convert reference values to snake case
209
+ # @return [void]
210
+ # @api private
211
+ def convert_ref_values_to_snake_case(path, object)
212
+ object[REF_KEY] = object[REF_KEY].underscore if object.key?(REF_KEY) && path[-1] != 'properties'
213
+ end
214
+
215
+ # Traverse the schema object tree and apply the schema visitor to each node
216
+ # @return [void]
217
+ # @api private
218
+ def post_process_schemas(schemas)
219
+ DiscoveryV1::Validation::TraverseObjectTree.call(
220
+ object: schemas, visitor: ->(path:, object:) { schema_visitor(path:, object:) }
221
+ )
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscoveryV1
4
+ module Validation
5
+ # Resolve a JSON schema reference to a Google Discovery V1 API schema
6
+ #
7
+ # This class uses the Google Discovery V1 API to get the schemas. Any schema reference
8
+ # in the form `{ "$ref": "schema_name" }` will be resolved by looking up the schema
9
+ # name in the Google Discovery V1 API and returning the schema object (as a Hash).
10
+ #
11
+ # This means that `{ "$ref": "cell_data" }` is resolved by returning
12
+ # `DiscoveryV1::Validation::LoadSchemas.new(logger:).call['cell_data']`.
13
+ #
14
+ # An RuntimeError is raised if `DiscoveryV1::Validation::LoadSchemas.new.call`
15
+ # does not have a key matching the schema name.
16
+ #
17
+ # @example
18
+ # logger = Logger.new(STDOUT, level: Logger::INFO)
19
+ # ref_resolver = DiscoveryV1::Validation::ResolveSchemaRef.new(logger:)
20
+ # people_schema = { 'type' => 'array', 'items' => { '$ref' => 'person' } }
21
+ # json_validator = JSONSchemer.schema(people_schema, ref_resolver:)
22
+ # people_json = [{ 'name' => { 'first' => 'John', 'last' => 'Doe' } }]
23
+ #
24
+ # # Trying to validate people_json using json_validator as follows:
25
+ #
26
+ # json_validator.validate(people_json)
27
+ #
28
+ # # will try to load the referenced schema for 'person'. json_validator will
29
+ # # do this by calling `ref_resolver.call(URI.parse('json-schemer://schema/person'))`
30
+ #
31
+ # @api private
32
+ #
33
+ class ResolveSchemaRef
34
+ # Create a new schema resolver
35
+ #
36
+ # @param rest_description [Google::Apis::DiscoveryV1::RestDescription] the api description to load schemas from
37
+ # @param logger [Logger] the logger to use
38
+ #
39
+ # @api private
40
+ #
41
+ def initialize(rest_description:, logger: Logger.new(nil))
42
+ @rest_description = rest_description
43
+ @logger = logger
44
+ end
45
+
46
+ # The Google Discovery V1 API description to load schemas from
47
+ #
48
+ # @example
49
+ # rest_description = DiscoveryV1.discovery_service.get_rest_description('sheets', 'v4')
50
+ # resolver = DiscoveryV1::Validation::ResolveSchemaRef.new(rest_description:)
51
+ # resolver.rest_description == rest_description # => true
52
+ #
53
+ # @return [Google::Apis::DiscoveryV1::RestDescription]
54
+ #
55
+ attr_reader :rest_description
56
+
57
+ # The logger to use internally
58
+ #
59
+ # Currently, only debug messages are logged.
60
+ #
61
+ # @return [Logger]
62
+ #
63
+ # @api private
64
+ #
65
+ attr_reader :logger
66
+
67
+ # Resolve a JSON schema reference
68
+ #
69
+ # @param ref [URI] the reference to resolve usually in the form "json-schemer://schema/[name]"
70
+ #
71
+ # @return [Hash] the schema object as a hash
72
+ #
73
+ # @api private
74
+ #
75
+ def call(ref)
76
+ schema_name = ref.path[1..]
77
+ logger.debug { "Reading schema #{schema_name}" }
78
+ schemas = DiscoveryV1::Validation::LoadSchemas.new(rest_description:, logger:).call
79
+ schemas[schema_name].tap do |schema_object|
80
+ raise "Schema for #{ref} not found" unless schema_object
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscoveryV1
4
+ module Validation
5
+ # Visit all objects in arbitrarily nested object tree of hashes and/or arrays
6
+ #
7
+ # @api public
8
+ #
9
+ class TraverseObjectTree
10
+ # Visit all objects in arbitrarily nested object tree of hashes and/or arrays
11
+ #
12
+ # For each object, the visitor is called with the path to the object and the object
13
+ # itself.
14
+ #
15
+ # @example
16
+ # # In the examples below assume the elided code is the following:
17
+ # visitor = -> (path:, object:) { puts "path: #{path}, object: #{obj}" }
18
+ # DiscoveryV1::Validation::TraverseObjectTree.call(object:, visitor:)
19
+ #
20
+ # @example Given a simple object (not very exciting)
21
+ # object = 1
22
+ # ...
23
+ # #=> path: [], object: 1
24
+ #
25
+ # @example Given an array
26
+ # object = [1, 2, 3]
27
+ # ...
28
+ # #=> path: [], object: [1, 2, 3]
29
+ # #=> path: [0], object: 1
30
+ # #=> path: [1], object: 2
31
+ # #=> path: [2], object: 3
32
+ #
33
+ # @example Given a hash
34
+ # object = { name: 'James', age: 42 }
35
+ # ...
36
+ # #=> path: [], object: { name: 'James', age: 42 }
37
+ # #=> path: [:name], object: James
38
+ # #=> path: [:age], object: 42
39
+ #
40
+ # @example Given an array of hashes
41
+ # object = [{ name: 'James', age: 42 }, { name: 'Jane', age: 43 }]
42
+ # ...
43
+ # #=> path: [], object: [{ name: 'James', age: 42 }, { name: 'Jane', age: 43 }]
44
+ # #=> path: [0], object: { name: 'James', age: 42 }
45
+ # #=> path: [0, :name], object: James
46
+ # #=> path: [0, :age], object: 42
47
+ # #=> path: [1], object: { name: 'Jane', age: 43 }
48
+ # #=> path: [1, :name], object: Jane
49
+ # #=> path: [1, :age], object: 43
50
+ #
51
+ # @example Given a hash of hashes
52
+ # object = { person1: { name: 'James', age: 42 }, person2: { name: 'Jane', age: 43 } }
53
+ # ...
54
+ # #=> path: [], object: { person1: { name: 'James', age: 42 }, person2: { name: 'Jane', age: 43 } }
55
+ # #=> path: [:person1], object: { name: 'James', age: 42 }
56
+ # #=> path: [:person1, :name], object: James
57
+ # #=> path: [:person1, :age], object: 42
58
+ # #=> path: [:person2], object: { name: 'Jane', age: 43 }
59
+ # #=> path: [:person2, :name], object: Jane
60
+ # #=> path: [:person2, :age], object: 43
61
+ #
62
+ # @param path [Array] the path to the object
63
+ # @param object [Object] the object to visit
64
+ # @param visitor [#call] the visitor to call for each object
65
+ #
66
+ # @return [void]
67
+ #
68
+ # @api public
69
+ #
70
+ def self.call(object:, visitor:, path: [])
71
+ visitor&.call(path:, object:)
72
+ if object.is_a? Hash
73
+ object.each { |k, obj| call(path: (path + [k]), object: obj, visitor:) }
74
+ elsif object.is_a? Array
75
+ object.each_with_index { |obj, k| call(path: (path + [k]), object: obj, visitor:) }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json_schemer'
4
+
5
+ module DiscoveryV1
6
+ module Validation
7
+ # Validate objects against a Google Discovery V1 API request object schema
8
+ #
9
+ # @api public
10
+ #
11
+ class ValidateObject
12
+ # Create a new api object validator
13
+ #
14
+ # By default, a nil logger is used. This means that no messages are logged.
15
+ #
16
+ # @example
17
+ # validator = DiscoveryV1::Validation::ValidateObject.new
18
+ #
19
+ # @param logger [Logger] the logger to use
20
+ #
21
+ def initialize(rest_description:, logger: Logger.new(nil))
22
+ @rest_description = rest_description
23
+ @logger = logger
24
+ end
25
+
26
+ # The Google Discovery V1 API description containing schemas to use for validation
27
+ #
28
+ # @example
29
+ # rest_description = DiscoveryV1.discovery_service.get_rest_description('sheets', 'v4')
30
+ # validator = DiscoveryV1::Validation::ValidateObject.new(rest_description:)
31
+ # validator.rest_description == rest_description # => true
32
+ #
33
+ # @return [Google::Apis::DiscoveryV1::RestDescription]
34
+ #
35
+ attr_reader :rest_description
36
+
37
+ # The logger to use internally
38
+ #
39
+ # Validation errors are logged at the error level. Other messages are logged
40
+ # at the debug level.
41
+ #
42
+ # @example
43
+ # logger = Logger.new(STDOUT, :level => Logger::INFO)
44
+ # validator = DiscoveryV1::Validation::ValidateObject.new(logger)
45
+ # validator.logger == logger # => true
46
+ # validator.logger.debug { "Debug message" }
47
+ #
48
+ # @return [Logger]
49
+ #
50
+ attr_reader :logger
51
+
52
+ # Validate the object using the JSON schema named schema_name
53
+ #
54
+ # @example
55
+ # schema_name = 'batch_update_spreadsheet_request'
56
+ # object = { 'requests' => [] }
57
+ # validator = DiscoveryV1::Validation::ValidateObject.new
58
+ # validator.call(schema_name:, object:)
59
+ #
60
+ # @param schema_name [String] the name of the schema to validate against
61
+ # @param object [Object] the object to validate
62
+ #
63
+ # @raise [RuntimeError] if the object does not conform to the schema
64
+ #
65
+ # @return [void]
66
+ #
67
+ def call(schema_name:, object:)
68
+ logger.debug { "Validating #{object} against #{schema_name}" }
69
+
70
+ schema = { '$ref' => schema_name }
71
+ schemer = JSONSchemer.schema(schema, ref_resolver:)
72
+ errors = schemer.validate(object)
73
+ raise_error!(schema_name, object, errors) if errors.any?
74
+
75
+ logger.debug { "Object #{object} conforms to #{schema_name}" }
76
+ end
77
+
78
+ private
79
+
80
+ # The resolver to use to resolve JSON schema references
81
+ # @return [ResolveSchemaRef]
82
+ # @api private
83
+ def ref_resolver
84
+ @ref_resolver ||= DiscoveryV1::Validation::ResolveSchemaRef.new(rest_description:, logger:)
85
+ end
86
+
87
+ # Raise an error when the object does not conform to the schema
88
+ # @return [void]
89
+ # @raise [RuntimeError]
90
+ # @api private
91
+ def raise_error!(schema_name, _object, errors)
92
+ error = errors.first['error']
93
+ error_message = "Object does not conform to #{schema_name}: #{error}"
94
+ logger.error(error_message)
95
+ raise error_message
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscoveryV1
4
+ # Validate API Objects against the Google Discovery V1 API
5
+ #
6
+ # @example
7
+ # discovery_service = DiscoveryV1.discovery_service
8
+ # rest_description = discovery_service.get_rest_description('sheets', 'v4')
9
+ # schema_name = 'batch_update_spreadsheet_request'
10
+ # object = { 'requests' => [] }
11
+ # DiscoveryV1::Validation::ValidateObject.new(rest_description:).call(schema_name:, object:)
12
+ #
13
+ # @api public
14
+ #
15
+ module Validation; end
16
+ end
17
+
18
+ require_relative 'validation/load_schemas'
19
+ require_relative 'validation/resolve_schema_ref'
20
+ require_relative 'validation/traverse_object_tree'
21
+ require_relative 'validation/validate_object'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DiscoveryV1
4
+ # The version of this gem
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google/apis/discovery_v1'
4
+
5
+ require_relative 'discovery_v1/version'
6
+ require_relative 'discovery_v1/validation'
7
+
8
+ # Unofficial helpers for the Google Discovery V1 API
9
+ #
10
+ # @api public
11
+ #
12
+ module DiscoveryV1
13
+ class << self
14
+ # Create a new Google::Apis::DiscoveryV1::DiscoveryService object
15
+ #
16
+ # A credential is not needed to use the DiscoveryService.
17
+ #
18
+ # @example
19
+ # DiscoveryV1.discovery_service
20
+ #
21
+ # @return [Google::Apis::DiscoveryV1::DiscoveryService] a new DiscoveryService instance
22
+ #
23
+ def discovery_service
24
+ Google::Apis::DiscoveryV1::DiscoveryService.new
25
+ end
26
+
27
+ # @!group Validation
28
+
29
+ # Validate the object using the named JSON schema
30
+ #
31
+ # The JSON schemas are loaded from the Google Disocvery API. The schemas names are
32
+ # returned by `DiscoveryV1.api_object_schema_names`.
33
+ #
34
+ # @example
35
+ # schema_name = 'batch_update_spreadsheet_request'
36
+ # object = { 'requests' => [] }
37
+ # DiscoveryV1.validate_api_object(schema_name:, object:)
38
+ #
39
+ # @param rest_description [Google::Apis::DiscoveryV1::RestDescription] the Google Discovery V1 API rest description
40
+ # @param schema_name [String] the name of the schema to validate against
41
+ # @param object [Object] the object to validate
42
+ # @param logger [Logger] the logger to use for logging error, info, and debug message
43
+ #
44
+ # @raise [RuntimeError] if the object does not conform to the schema
45
+ #
46
+ # @return [void]
47
+ #
48
+ def validate_object(rest_description:, schema_name:, object:, logger: Logger.new(nil))
49
+ DiscoveryV1::Validation::ValidateObject.new(rest_description:, logger:).call(schema_name:, object:)
50
+ end
51
+
52
+ # List the names of the schemas available to use in the Google Discovery V1 API
53
+ #
54
+ # @example List the name of the schemas available
55
+ # rest_description = DiscoveryV1.discovery_service.get_rest_api('sheets', 'v4')
56
+ # DiscoveryV1.api_object_schema_names #=> ["add_banding_request", "add_banding_response", ...]
57
+ #
58
+ # @param rest_description [Google::Apis::DiscoveryV1::RestDescription] the Google Discovery V1 API rest description
59
+ # @param logger [Logger] the logger to use for logging error, info, and debug message
60
+ #
61
+ # @return [Array<String>] the names of the schemas available
62
+ #
63
+ def object_schema_names(rest_description:, logger: Logger.new(nil))
64
+ DiscoveryV1::Validation::LoadSchemas.new(rest_description:, logger:).call.keys.sort
65
+ end
66
+
67
+ # @!endgroup
68
+ end
69
+ end
@@ -0,0 +1,4 @@
1
+ module DiscoveryV1
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end