sheets_v4 0.3.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SheetsV4
4
+ module ValidateApiObjects
5
+ # Load the Google Discovery API description for the Sheets V4 API
6
+ #
7
+ # @example
8
+ # logger = Logger.new(STDOUT, :level => Logger::ERROR)
9
+ # schemas = SheetsV4::ValidateApiObjects::LoadSchemas.new(logger:).call
10
+ #
11
+ # @api private
12
+ #
13
+ class LoadSchemas
14
+ # Loads schemas for the Sheets V4 API object from the Google Discovery API
15
+ #
16
+ # By default, a nil logger is used. This means that nothing is logged.
17
+ #
18
+ # The schemas are only loaded once and cached.
19
+ #
20
+ # @example
21
+ # schema_loader = SheetsV4::ValidateApiObjects::LoadSchemas.new
22
+ #
23
+ # @param logger [Logger] the logger to use
24
+ #
25
+ def initialize(logger: Logger.new(nil))
26
+ @logger = logger
27
+ end
28
+
29
+ # The logger to use internally for logging errors
30
+ #
31
+ # @example
32
+ # logger = Logger.new(STDOUT, :level => Logger::INFO)
33
+ # validator = SheetsV4::ValidateApiObjects::LoadSchemas.new(logger)
34
+ # validator.logger == logger # => true
35
+ #
36
+ # @return [Logger]
37
+ #
38
+ attr_reader :logger
39
+
40
+ # A hash of schemas keyed by the schema name loaded from the Google Discovery API
41
+ #
42
+ # @example
43
+ # SheetsV4.api_object_schemas #=> { 'PersonSchema' => { 'type' => 'object', ... } ... }
44
+ #
45
+ # @return [Hash<String, Object>] a hash of schemas keyed by schema name
46
+ #
47
+ def call
48
+ self.class.schema_load_semaphore.synchronize { @call ||= load_api_object_schemas }
49
+ end
50
+
51
+ private
52
+
53
+ # Validate
54
+ # A mutex used to synchronize access to the schemas so they are only loaded
55
+ # once.
56
+ #
57
+ @schema_load_semaphore = Thread::Mutex.new
58
+
59
+ class << self
60
+ # A mutex used to synchronize access to the schemas so they are only loaded once
61
+ #
62
+ # @return [Thread::Mutex]
63
+ #
64
+ # @api private
65
+ #
66
+ attr_reader :schema_load_semaphore
67
+ end
68
+
69
+ # Load the schemas from the Google Discovery API
70
+ #
71
+ # @return [Hash<String, Object>] a hash of schemas keyed by schema name
72
+ #
73
+ # @api private
74
+ #
75
+ def load_api_object_schemas
76
+ source = 'https://sheets.googleapis.com/$discovery/rest?version=v4'
77
+ http_response = Net::HTTP.get_response(URI.parse(source))
78
+ raise_error(http_response) if http_response.code != '200'
79
+
80
+ data = http_response.body
81
+ JSON.parse(data)['schemas'].tap { |schemas| post_process_schemas(schemas) }
82
+ end
83
+
84
+ # Log an error and raise a RuntimeError based on the HTTP response code
85
+ # @param http_response [Net::HTTPResponse] the HTTP response
86
+ # @return [void]
87
+ # @raises [RuntimeError]
88
+ # @api private
89
+ def raise_error(http_response)
90
+ message = "HTTP Error '#{http_response.code}' loading schemas from '#{http_response.uri}'"
91
+ logger.error(message)
92
+ raise message
93
+ end
94
+
95
+ REF_KEY = '$ref'
96
+
97
+ # A visitor for the schema object tree that fixes up the tree as it goes
98
+ # @return [void]
99
+ # @api private
100
+ def schema_visitor(path:, object:)
101
+ return unless object.is_a? Hash
102
+
103
+ convert_schema_names_to_snake_case(path, object)
104
+ convert_schema_ids_to_snake_case(path, object)
105
+ add_unevaluated_properties(path, object)
106
+ convert_property_names_to_snake_case(path, object)
107
+ convert_ref_values_to_snake_case(path, object)
108
+ end
109
+
110
+ # Convert schema names to snake case
111
+ # @return [void]
112
+ # @api private
113
+ def convert_schema_names_to_snake_case(path, object)
114
+ object.transform_keys!(&:underscore) if path.empty?
115
+ end
116
+
117
+ # Convert schema IDs to snake case
118
+ # @return [void]
119
+ # @api private
120
+ def convert_schema_ids_to_snake_case(path, object)
121
+ object['id'] = object['id'].underscore if object.key?('id') && path.size == 1
122
+ end
123
+
124
+ # Add 'unevaluatedProperties: false' to all schemas
125
+ # @return [void]
126
+ # @api private
127
+ def add_unevaluated_properties(path, object)
128
+ object['unevaluatedProperties'] = false if path.size == 1
129
+ end
130
+
131
+ # Convert object property names to snake case
132
+ # @return [void]
133
+ # @api private
134
+ def convert_property_names_to_snake_case(path, object)
135
+ object.transform_keys!(&:underscore) if path[-1] == 'properties'
136
+ end
137
+
138
+ # Convert reference values to snake case
139
+ # @return [void]
140
+ # @api private
141
+ def convert_ref_values_to_snake_case(_path, object)
142
+ object[REF_KEY] = object[REF_KEY].underscore if object.key?(REF_KEY)
143
+ end
144
+
145
+ # Traverse the schema object tree and apply the schema visitor to each node
146
+ # @return [void]
147
+ # @api private
148
+ def post_process_schemas(schemas)
149
+ SheetsV4::ValidateApiObjects::TraverseObjectTree.call(
150
+ object: schemas, visitor: ->(path:, object:) { schema_visitor(path:, object:) }
151
+ )
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SheetsV4
4
+ module ValidateApiObjects
5
+ # Resolve a JSON schema reference to a Google Sheets API schema
6
+ #
7
+ # This class uses the Google Discovery 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 API and returning the schema object (as a Hash).
10
+ #
11
+ # This means that `{ "$ref": "cell_data" }` is resolved by returning
12
+ # `SheetsV4::ValidateApiObjects::LoadSchemas.new(logger:).call['cell_data']`.
13
+ #
14
+ # An RuntimeError is raised if `SheetsV4::ValidateApiObjects::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 = SheetsV4::ValidateApiObjects::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 logger [Logger] the logger to use
37
+ #
38
+ # @api private
39
+ #
40
+ def initialize(logger: Logger.new(nil))
41
+ @logger = logger
42
+ end
43
+
44
+ # The logger to use internally
45
+ #
46
+ # Currently, only debug messages are logged.
47
+ #
48
+ # @return [Logger]
49
+ #
50
+ # @api private
51
+ #
52
+ attr_reader :logger
53
+
54
+ # Resolve a JSON schema reference
55
+ #
56
+ # @param ref [URI] the reference to resolve usually in the form "json-schemer://schema/{name}"
57
+ #
58
+ # @return [Hash] the schema object as a hash
59
+ #
60
+ # @api private
61
+ #
62
+ def call(ref)
63
+ schema_name = ref.path[1..]
64
+ logger.debug { "Reading schema #{schema_name}" }
65
+ schemas = SheetsV4::ValidateApiObjects::LoadSchemas.new(logger:).call
66
+ schemas[schema_name].tap do |schema_object|
67
+ raise "Schema for #{ref} not found" unless schema_object
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SheetsV4
4
+ module ValidateApiObjects
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
+ # In the examples below assume the elided code is the following:
16
+ #
17
+ # ```Ruby
18
+ # visitor = -> (path:, object:) { puts "path: #{path}, object: #{obj}" }
19
+ # SheetsV4::ValidateApiObjects::TraverseObjectTree.call(object:, visitor:)
20
+ # ```
21
+ #
22
+ # @example Given a simple object (not very exciting)
23
+ # object = 1
24
+ # ...
25
+ # #=> path: [], object: 1
26
+ #
27
+ # @example Given an array
28
+ # object = [1, 2, 3]
29
+ # ...
30
+ # #=> path: [], object: [1, 2, 3]
31
+ # #=> path: [0], object: 1
32
+ # #=> path: [1], object: 2
33
+ # #=> path: [2], object: 3
34
+ #
35
+ # @example Given a hash
36
+ # object = { name: 'James', age: 42 }
37
+ # ...
38
+ # #=> path: [], object: { name: 'James', age: 42 }
39
+ # #=> path: [:name], object: James
40
+ # #=> path: [:age], object: 42
41
+ #
42
+ # @example Given an array of hashes
43
+ # object = [{ name: 'James', age: 42 }, { name: 'Jane', age: 43 }]
44
+ # ...
45
+ # #=> path: [], object: [{ name: 'James', age: 42 }, { name: 'Jane', age: 43 }]
46
+ # #=> path: [0], object: { name: 'James', age: 42 }
47
+ # #=> path: [0, :name], object: James
48
+ # #=> path: [0, :age], object: 42
49
+ # #=> path: [1], object: { name: 'Jane', age: 43 }
50
+ # #=> path: [1, :name], object: Jane
51
+ # #=> path: [1, :age], object: 43
52
+ #
53
+ # @example Given a hash of hashes
54
+ # object = { person1: { name: 'James', age: 42 }, person2: { name: 'Jane', age: 43 } }
55
+ # ...
56
+ # #=> path: [], object: { person1: { name: 'James', age: 42 }, person2: { name: 'Jane', age: 43 } }
57
+ # #=> path: [:person1], object: { name: 'James', age: 42 }
58
+ # #=> path: [:person1, :name], object: James
59
+ # #=> path: [:person1, :age], object: 42
60
+ # #=> path: [:person2], object: { name: 'Jane', age: 43 }
61
+ # #=> path: [:person2, :name], object: Jane
62
+ # #=> path: [:person2, :age], object: 43
63
+ #
64
+ # @param path [Array] the path to the object
65
+ # @param object [Object] the object to visit
66
+ # @param visitor [#call] the visitor to call for each object
67
+ #
68
+ # @return [void]
69
+ #
70
+ # @api public
71
+ #
72
+ def self.call(object:, visitor:, path: [])
73
+ visitor&.call(path:, object:)
74
+ if object.is_a? Hash
75
+ object.each { |k, obj| call(path: (path + [k]), object: obj, visitor:) }
76
+ elsif object.is_a? Array
77
+ object.each_with_index { |obj, k| call(path: (path + [k]), object: obj, visitor:) }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/inflector'
4
+ require 'json_schemer'
5
+
6
+ module SheetsV4
7
+ module ValidateApiObjects
8
+ # Validate objects against a Google Sheets API request object schema
9
+ #
10
+ # @api public
11
+ #
12
+ class Validate
13
+ # Create a new validator
14
+ #
15
+ # By default, a nil logger is used. This means that no messages are logged.
16
+ #
17
+ # @example
18
+ # validator = SheetsV4::ValidateApiObjects::Validator.new
19
+ #
20
+ # @param logger [Logger] the logger to use
21
+ #
22
+ def initialize(logger: Logger.new(nil))
23
+ @logger = logger
24
+ end
25
+
26
+ # The logger to use internally
27
+ #
28
+ # Validation errors are logged at the error level. Other messages are logged
29
+ # at the debug level.
30
+ #
31
+ # @example
32
+ # logger = Logger.new(STDOUT, :level => Logger::INFO)
33
+ # validator = SheetsV4::ValidateApiObjects::Validator.new(logger)
34
+ # validator.logger == logger # => true
35
+ # validator.logger.debug { "Debug message" }
36
+ #
37
+ # @return [Logger]
38
+ #
39
+ attr_reader :logger
40
+
41
+ # Validate the object using the JSON schema named schema_name
42
+ #
43
+ # @example
44
+ # schema_name = 'batch_update_spreadsheet_request'
45
+ # object = { 'requests' => [] }
46
+ # validator = SheetsV4::ValidateApiObjects::Validator.new
47
+ # validator.call(schema_name:, object:)
48
+ #
49
+ # @param schema_name [String] the name of the schema to validate against
50
+ # @param object [Object] the object to validate
51
+ #
52
+ # @raise [RuntimeError] if the object does not conform to the schema
53
+ #
54
+ # @return [void]
55
+ #
56
+ def call(schema_name:, object:)
57
+ logger.debug { "Validating #{object} against #{schema_name}" }
58
+
59
+ schema = { '$ref' => schema_name }
60
+ schemer = JSONSchemer.schema(schema, ref_resolver:)
61
+ errors = schemer.validate(object)
62
+ raise_error!(schema_name, object, errors) if errors.any?
63
+
64
+ logger.debug { "Object #{object} conforms to #{schema_name}" }
65
+ end
66
+
67
+ private
68
+
69
+ # The resolver to use to resolve JSON schema references
70
+ # @return [ResolveSchemaRef]
71
+ # @api private
72
+ def ref_resolver = @ref_resolver ||= SheetsV4::ValidateApiObjects::ResolveSchemaRef.new(logger:)
73
+
74
+ # Raise an error when the object does not conform to the schema
75
+ # @return [void]
76
+ # @raise [RuntimeError]
77
+ # @api private
78
+ def raise_error!(schema_name, object, errors)
79
+ error = errors.first['error']
80
+ error_message = "Object #{object} does not conform to #{schema_name}: #{error}"
81
+ logger.error(error_message)
82
+ raise error_message
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SheetsV4
4
+ # Validate API Objects against the Google Discovery API
5
+ #
6
+ # @example
7
+ # logger = Logger.new(STDOUT, :level => Logger::ERROR)
8
+ # schema_name = 'batch_update_spreadsheet_request'
9
+ # object = { 'requests' => [] }
10
+ # SheetsV4::ValidateApiObjects::Validator.new(logger:).call(schema_name:, object:)
11
+ #
12
+ # @api public
13
+ #
14
+ module ValidateApiObjects; end
15
+ end
16
+
17
+ require_relative 'validate_api_objects/load_schemas'
18
+ require_relative 'validate_api_objects/resolve_schema_ref'
19
+ require_relative 'validate_api_objects/traverse_object_tree'
20
+ require_relative 'validate_api_objects/validate'
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SheetsV4
4
4
  # The version of this gem
5
- VERSION = '0.3.0'
5
+ VERSION = '0.5.0'
6
6
  end