sheets_v4 0.3.0 → 0.5.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,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