vert 0.2.1
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 +7 -0
- data/lib/vert.rb +369 -0
- metadata +72 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: cc6d3bfb12ff30eaad91ca1aaa5d1cb3b22e9f57
|
4
|
+
data.tar.gz: 6ab62d9c55248d41c3fa360714b585e54d87924a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 516fe980cc3e286f8f08fed032e79dd2f0b2a98c9770bee92e416e3f93cd82295aa2b9ac98a4e72d918950b5da8282b5ff3e6c4a91558605b10e51a77b0524a4
|
7
|
+
data.tar.gz: 97164fdcc94904f226b4a3a2a33bc1756a62cb7992bbfb352ebe5a2560e29b4eef3ed09415b2fabb1956f6f8410321b75045df12f3082263dff58e4a78b266dc
|
data/lib/vert.rb
ADDED
@@ -0,0 +1,369 @@
|
|
1
|
+
require 'oj'
|
2
|
+
require 'avro'
|
3
|
+
|
4
|
+
module Vert
|
5
|
+
|
6
|
+
extend self
|
7
|
+
|
8
|
+
InputError = Class.new(StandardError)
|
9
|
+
ValidationError = Class.new(StandardError)
|
10
|
+
|
11
|
+
#error messages for hash validation
|
12
|
+
NOT_A_HASH_ERROR = "Not a hash."
|
13
|
+
NOT_A_HASH_ERROR_KEY = :not_a_hash
|
14
|
+
|
15
|
+
EMPTY_ERROR = "The hash is empty."
|
16
|
+
EMPTY_ERROR_KEY = :empty
|
17
|
+
|
18
|
+
ABSENT_KEY_ERROR = "The data does not contain the following key/s"
|
19
|
+
ABSENT_KEY_ERROR_KEY = :absent_key
|
20
|
+
|
21
|
+
ARRAY_TYPE_ERROR = "The following key/s do not have Array type values."
|
22
|
+
ARRAY_TYPE_ERROR_KEY = :array_type
|
23
|
+
|
24
|
+
HASH_TYPE_ERROR = "The following key/s do not have Hash type values."
|
25
|
+
HASH_TYPE_ERROR_KEY = :hash_type
|
26
|
+
|
27
|
+
ARRAY_EMPTY_ERROR = "The following array key/s are empty."
|
28
|
+
ARRAY_EMPTY_ERROR_KEY = :array_empty
|
29
|
+
|
30
|
+
HASH_EMPTY_ERROR = "The following hash key/s are empty."
|
31
|
+
HASH_EMPTY_ERROR_KEY = :hash_empty
|
32
|
+
|
33
|
+
#error messages for json validation
|
34
|
+
NOT_A_STRING_ERROR = "Not a JSON string."
|
35
|
+
NOT_A_STRING_ERROR_KEY = :not_a_string
|
36
|
+
|
37
|
+
EMPTY_JSON_ERROR = "The JSON string is empty."
|
38
|
+
EMPTY_JSON_ERROR_KEY = :empty_json
|
39
|
+
|
40
|
+
EMPTY_JSON_OBJECT_ERROR = "The JSON object is empty."
|
41
|
+
EMPTY_JSON_OBJECT_ERROR_KEY = :empty_json_object
|
42
|
+
|
43
|
+
MALFORMED_JSON_ERROR = "The JSON string is malformed."
|
44
|
+
MALFORMED_JSON_ERROR_KEY = :malformed_json
|
45
|
+
|
46
|
+
#error messages for avro verification
|
47
|
+
INVALID_AVRO_SCHEMA_ERROR = "The avro schema is invalid."
|
48
|
+
INVALID_AVRO_SCHEMA_ERROR_KEY = :invalid_avro_schema
|
49
|
+
|
50
|
+
INVALID_AVRO_DATUM_ERROR = "The JSON provided is not an instance of the schema:"
|
51
|
+
INVALID_AVRO_DATUM_ERROR_KEY = :invalid_avro_datum
|
52
|
+
|
53
|
+
#Enums
|
54
|
+
TYPE_ENUM = {array_keys: Array, hash_keys: Hash}
|
55
|
+
OPTIONS_HASH_ENUM = [:keys, :custom_errors]
|
56
|
+
OPTIONS_JSON_HASH_ENUM = [:schema, :custom_errors]
|
57
|
+
KEYS_ENUM = [:value_keys, :array_keys, :hash_keys]
|
58
|
+
ERROR_KEY_ENUM = {
|
59
|
+
NOT_A_HASH_ERROR_KEY => NOT_A_HASH_ERROR,
|
60
|
+
EMPTY_ERROR_KEY => EMPTY_ERROR,
|
61
|
+
ABSENT_KEY_ERROR_KEY => ABSENT_KEY_ERROR,
|
62
|
+
ARRAY_TYPE_ERROR_KEY => ARRAY_TYPE_ERROR,
|
63
|
+
HASH_TYPE_ERROR_KEY => HASH_TYPE_ERROR,
|
64
|
+
ARRAY_EMPTY_ERROR_KEY => ARRAY_EMPTY_ERROR,
|
65
|
+
HASH_EMPTY_ERROR_KEY => HASH_EMPTY_ERROR,
|
66
|
+
NOT_A_STRING_ERROR_KEY => NOT_A_STRING_ERROR,
|
67
|
+
EMPTY_JSON_ERROR_KEY => EMPTY_JSON_ERROR,
|
68
|
+
EMPTY_JSON_OBJECT_ERROR_KEY => EMPTY_JSON_OBJECT_ERROR,
|
69
|
+
MALFORMED_JSON_ERROR_KEY => MALFORMED_JSON_ERROR,
|
70
|
+
INVALID_AVRO_SCHEMA_ERROR_KEY => INVALID_AVRO_SCHEMA_ERROR,
|
71
|
+
INVALID_AVRO_DATUM_ERROR_KEY => INVALID_AVRO_DATUM_ERROR
|
72
|
+
}
|
73
|
+
|
74
|
+
#input validation
|
75
|
+
OPTIONS_HASH_EMPTY = "The options hash must contain keys"
|
76
|
+
OPTIONS_NOT_A_HASH = "The options parameter must be a hash"
|
77
|
+
OPTIONS_HASH_MISSING_VALID_KEYS = "The options hash contains no valid keys. The valid symbol keys are - "
|
78
|
+
KEYS_HASH_MISSING_VALID_KEYS = "The options hash contains no valid keys for the :keys hash. The valid symbol keys are - "
|
79
|
+
CUSTOM_ERRORS_HASH_MISSING_VALID_KEYS = "The options hash contains no valid keys for the :custom_errors hash. The valid symbol keys are - "
|
80
|
+
SCHEMA_NOT_A_STRING = "The options hash contains a :schema value which is not a string."
|
81
|
+
SCHEMA_NOT_JSON = "The options hash contains a :schema value which is not a JSON string"
|
82
|
+
|
83
|
+
def validate(hash, options = nil)
|
84
|
+
unless options.nil?
|
85
|
+
check_options_format(options)
|
86
|
+
check_options(options)
|
87
|
+
end
|
88
|
+
test_validations(hash, options)
|
89
|
+
rescue InputError => exception
|
90
|
+
build_error_output(exception)
|
91
|
+
end
|
92
|
+
|
93
|
+
def validate?(hash, options = nil)
|
94
|
+
check_options(options) unless options.nil?
|
95
|
+
test_validations(hash, options).nil? ? true : false
|
96
|
+
rescue InputError => exception
|
97
|
+
false
|
98
|
+
end
|
99
|
+
|
100
|
+
def validate_json?(json, options = nil)
|
101
|
+
check_json_options(options) unless options.nil?
|
102
|
+
validate_json(json, options).nil? ? true : false
|
103
|
+
rescue InputError => exception
|
104
|
+
false
|
105
|
+
end
|
106
|
+
|
107
|
+
def validate_json(json, options = nil)
|
108
|
+
unless options.nil?
|
109
|
+
check_options_format(options)
|
110
|
+
check_json_options(options)
|
111
|
+
end
|
112
|
+
test_validations_json(json, options)
|
113
|
+
rescue InputError => exception
|
114
|
+
build_error_output(exception)
|
115
|
+
end
|
116
|
+
|
117
|
+
def get_error_keys
|
118
|
+
pp ERROR_KEY_ENUM
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
def check_options(options)
|
124
|
+
raise_when_all_keys_missing(options, OPTIONS_HASH_MISSING_VALID_KEYS, OPTIONS_HASH_ENUM)
|
125
|
+
if options.keys.include?(:keys)
|
126
|
+
raise_when_all_keys_missing(options[:keys], KEYS_HASH_MISSING_VALID_KEYS, KEYS_ENUM)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def raise_when_all_keys_missing(hash, message, key_enum_array)
|
131
|
+
test_result = any_keys_present?(hash, key_enum_array)
|
132
|
+
raise InputError, "#{message}#{key_enum_array*", "}" unless test_result
|
133
|
+
end
|
134
|
+
|
135
|
+
def any_keys_present?(hash, key_array)
|
136
|
+
test_result = key_array.inject(false) do |memo, entry|
|
137
|
+
memo || hash.keys.include?(entry)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def check_options_format(options)
|
142
|
+
raise InputError, OPTIONS_NOT_A_HASH unless options.is_a?(Hash)
|
143
|
+
raise InputError, OPTIONS_HASH_EMPTY if options.empty?
|
144
|
+
if options.keys.include?(:custom_errors)
|
145
|
+
raise_when_all_keys_missing(options[:custom_errors], CUSTOM_ERRORS_HASH_MISSING_VALID_KEYS, ERROR_KEY_ENUM.keys)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def test_validations(hash, options)
|
150
|
+
test_for_default_hash_errors(hash, options)
|
151
|
+
if options_key_types_present?(options, :value_keys)
|
152
|
+
raise_when_keys_absent(hash, options, :value_keys)
|
153
|
+
elsif options_key_types_present?(options, :array_keys)
|
154
|
+
test_for_array_key_errors(hash, options)
|
155
|
+
elsif options_key_types_present?(options, :hash_keys)
|
156
|
+
test_for_hash_key_errors(hash, options)
|
157
|
+
end
|
158
|
+
rescue ValidationError => exception
|
159
|
+
build_error_output(exception)
|
160
|
+
else
|
161
|
+
nil
|
162
|
+
end
|
163
|
+
|
164
|
+
def check_json_options(options)
|
165
|
+
raise_when_all_keys_missing(options, OPTIONS_HASH_MISSING_VALID_KEYS, OPTIONS_JSON_HASH_ENUM)
|
166
|
+
if options.keys.include?(:schema)
|
167
|
+
test_options = {custom_errors: {not_a_string: SCHEMA_NOT_A_STRING, malformed_json: SCHEMA_NOT_JSON}}
|
168
|
+
unless Vert.validate_json?(options[:schema], test_options)
|
169
|
+
test = Vert.validate_json(options[:schema], test_options)
|
170
|
+
raise InputError, test if test.include?(SCHEMA_NOT_JSON)
|
171
|
+
raise InputError, test if test.include?(SCHEMA_NOT_A_STRING)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def test_validations_json(json, options)
|
177
|
+
test_for_default_json_errors(json, options)
|
178
|
+
if options_schema_present?(options)
|
179
|
+
validate_with_avro(json, options)
|
180
|
+
end
|
181
|
+
rescue ValidationError => exception
|
182
|
+
build_error_output(exception)
|
183
|
+
else
|
184
|
+
nil
|
185
|
+
end
|
186
|
+
|
187
|
+
def test_for_default_hash_errors(hash, options)
|
188
|
+
raise_when_not_hash(hash, options)
|
189
|
+
raise_when_empty(hash, options)
|
190
|
+
end
|
191
|
+
|
192
|
+
def raise_when_not_hash(hash, options)
|
193
|
+
raise_custom_error(hash, options, NOT_A_HASH_ERROR_KEY) {|hash| !hash.is_a?(Hash)}
|
194
|
+
end
|
195
|
+
|
196
|
+
def raise_custom_error(data, options, error_key)
|
197
|
+
test = yield data
|
198
|
+
message = options_errors_present?(options, error_key) ? get_options_custom_error(options, error_key) : ERROR_KEY_ENUM[error_key]
|
199
|
+
raise ValidationError, message if test
|
200
|
+
end
|
201
|
+
|
202
|
+
def options_errors_present?(options, error_key)
|
203
|
+
if options.nil?
|
204
|
+
false
|
205
|
+
elsif options.include?(:custom_errors)
|
206
|
+
options[:custom_errors].include?(error_key) ? true : false
|
207
|
+
else
|
208
|
+
false
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
def get_options_custom_error(options, error_key)
|
213
|
+
options[:custom_errors][error_key]
|
214
|
+
end
|
215
|
+
|
216
|
+
def raise_when_empty(hash, options)
|
217
|
+
raise_custom_error(hash, options, EMPTY_ERROR_KEY) {|hash| hash.empty?}
|
218
|
+
end
|
219
|
+
|
220
|
+
def options_key_types_present?(options, key_type)
|
221
|
+
if options.nil?
|
222
|
+
false
|
223
|
+
elsif options.include?(:keys)
|
224
|
+
options[:keys].include?(key_type) ? true : false
|
225
|
+
else
|
226
|
+
false
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
def test_for_array_key_errors(hash, options)
|
231
|
+
test_for_collection_key_errors(hash, options, :array_keys)
|
232
|
+
end
|
233
|
+
|
234
|
+
def test_for_hash_key_errors(hash, options)
|
235
|
+
test_for_collection_key_errors(hash, options, :hash_keys)
|
236
|
+
end
|
237
|
+
|
238
|
+
def test_for_collection_key_errors(hash, options, key_type)
|
239
|
+
raise_when_keys_absent(hash, options, key_type)
|
240
|
+
raise_when_keys_do_not_match_type(hash, options, key_type)
|
241
|
+
raise_when_collection_keys_empty(hash, options, key_type)
|
242
|
+
end
|
243
|
+
|
244
|
+
def raise_when_keys_absent(hash, options, key_type)
|
245
|
+
missing_keys = get_missing_keys(hash, options, key_type)
|
246
|
+
raise_error(nil, options, build_missing_key_error(options, missing_keys)) {!missing_keys.empty?}
|
247
|
+
end
|
248
|
+
|
249
|
+
def get_missing_keys(hash, options, key_type)
|
250
|
+
get_options_keys_array(options, key_type) - hash.keys
|
251
|
+
end
|
252
|
+
|
253
|
+
def raise_error(data, options, message)
|
254
|
+
test = yield data
|
255
|
+
raise ValidationError, message if test
|
256
|
+
end
|
257
|
+
|
258
|
+
def build_missing_key_error(options, missing_keys_array)
|
259
|
+
"#{build_error_message(options, ABSENT_KEY_ERROR_KEY)} Missing keys:- #{missing_keys_array*", "}"
|
260
|
+
end
|
261
|
+
|
262
|
+
def build_error_message(options, error_key)
|
263
|
+
options_errors_present?(options, error_key) ? get_options_custom_error(options, error_key) : ERROR_KEY_ENUM[error_key]
|
264
|
+
end
|
265
|
+
|
266
|
+
def test_criteria_met?(key_array)
|
267
|
+
key_array.empty?
|
268
|
+
end
|
269
|
+
|
270
|
+
def raise_when_keys_do_not_match_type(hash, options, key_type)
|
271
|
+
non_matched_type_keys = get_non_matched_type_keys(hash, options, key_type)
|
272
|
+
raise_error(nil, options, build_non_matched_type_error_message(options, non_matched_type_keys, key_type)) {!test_criteria_met?(non_matched_type_keys)}
|
273
|
+
end
|
274
|
+
|
275
|
+
def get_non_matched_type_keys(hash, options, key_type)
|
276
|
+
missing_keys = get_missing_keys(hash, options, key_type)
|
277
|
+
non_matched_type_keys = (get_options_keys_array(options, key_type) - missing_keys).find_all do
|
278
|
+
|key| hash[key].is_a?(TYPE_ENUM[key_type]) == false
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
def get_options_keys_array(options, key_type)
|
283
|
+
options[:keys][key_type]
|
284
|
+
end
|
285
|
+
|
286
|
+
def build_non_matched_type_error_message(options, non_matched_type_keys_array, key_type)
|
287
|
+
case key_type
|
288
|
+
when :array_keys
|
289
|
+
"#{build_error_message(options, ARRAY_TYPE_ERROR_KEY)} Not Array type keys:- #{non_matched_type_keys_array*","}"
|
290
|
+
when :hash_keys
|
291
|
+
"#{build_error_message(options, HASH_TYPE_ERROR_KEY)} Not Hash type keys:- #{non_matched_type_keys_array*","}"
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
def raise_when_collection_keys_empty(hash, options, key_type)
|
296
|
+
if get_non_matched_type_keys(hash, options, key_type).empty?
|
297
|
+
empty_collection_keys = get_empty_collection_keys(hash, options, key_type)
|
298
|
+
raise_error(nil, options, build_empty_collection_error_message(options, empty_collection_keys, key_type)) {!test_criteria_met?(empty_collection_keys)}
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
def get_empty_collection_keys(hash, options, key_type)
|
303
|
+
get_options_keys_array(options, key_type).find_all do |item|
|
304
|
+
hash[item].empty?
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def build_empty_collection_error_message(options, empty_collection_keys_array, key_type)
|
309
|
+
case key_type
|
310
|
+
when :array_keys
|
311
|
+
"#{build_error_message(options, ARRAY_EMPTY_ERROR_KEY)} Empty array keys:- #{empty_collection_keys_array*","}"
|
312
|
+
when :hash_keys
|
313
|
+
"#{build_error_message(options, HASH_EMPTY_ERROR_KEY)} Empty hash keys:- #{empty_collection_keys_array*","}"
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
def build_error_output(custom_exception)
|
318
|
+
custom_exception.message
|
319
|
+
end
|
320
|
+
|
321
|
+
def test_for_default_json_errors(json, options)
|
322
|
+
raise_when_not_string(json, options)
|
323
|
+
hash = try_parse_json(json, options)
|
324
|
+
end
|
325
|
+
|
326
|
+
def options_schema_present?(options)
|
327
|
+
if options.nil?
|
328
|
+
false
|
329
|
+
elsif options.include?(:schema)
|
330
|
+
true
|
331
|
+
else
|
332
|
+
false
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
def get_options_schema(options)
|
337
|
+
options[:schema]
|
338
|
+
end
|
339
|
+
|
340
|
+
def raise_when_not_string(json, options)
|
341
|
+
raise_custom_error(json, options, NOT_A_STRING_ERROR_KEY){|json| !json.is_a?(String)}
|
342
|
+
end
|
343
|
+
|
344
|
+
def try_parse_json(json, options)
|
345
|
+
hash = Oj.load(json)
|
346
|
+
raise_custom_error(hash, options, EMPTY_JSON_ERROR_KEY){|hash| hash.nil?}
|
347
|
+
raise_custom_error(hash, options, EMPTY_JSON_OBJECT_ERROR_KEY){|hash| hash.empty?}
|
348
|
+
rescue Oj::ParseError => exception
|
349
|
+
detail = "#{exception.message.gsub( /\[.+\]/, "").rstrip}."
|
350
|
+
raise ValidationError, "#{build_error_message(options, MALFORMED_JSON_ERROR_KEY)}. #{detail}"
|
351
|
+
else
|
352
|
+
hash
|
353
|
+
end
|
354
|
+
|
355
|
+
def validate_with_avro(json, options)
|
356
|
+
schema_object = parse_avro_schema(options)
|
357
|
+
message = build_error_message(options, INVALID_AVRO_DATUM_ERROR_KEY)
|
358
|
+
raise_error(json, options, message){|json| !Avro::Schema.validate(schema_object, Oj.load(json))}
|
359
|
+
end
|
360
|
+
|
361
|
+
def parse_avro_schema(options)
|
362
|
+
schema = Avro::Schema.parse(get_options_schema(options))
|
363
|
+
rescue Avro::SchemaParseError => exception
|
364
|
+
raise ValidationError, "#{build_error_message(options, INVALID_AVRO_SCHEMA_ERROR_KEY)}. #{exception.to_s}"
|
365
|
+
else
|
366
|
+
schema
|
367
|
+
end
|
368
|
+
|
369
|
+
end
|
metadata
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: vert
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Eskimo Bear
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-08-23 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: avro
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.7.5
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 1.7.5
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: oj
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 2.10.2
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 2.10.2
|
41
|
+
description: Vert is a library for verifying and validating data.
|
42
|
+
email: dev@eskimobear.com
|
43
|
+
executables: []
|
44
|
+
extensions: []
|
45
|
+
extra_rdoc_files: []
|
46
|
+
files:
|
47
|
+
- lib/vert.rb
|
48
|
+
homepage: https://github.com/EskimoBear/Vert/
|
49
|
+
licenses:
|
50
|
+
- MIT
|
51
|
+
metadata: {}
|
52
|
+
post_install_message:
|
53
|
+
rdoc_options: []
|
54
|
+
require_paths:
|
55
|
+
- lib
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '0'
|
61
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
62
|
+
requirements:
|
63
|
+
- - ">="
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
requirements: []
|
67
|
+
rubyforge_project:
|
68
|
+
rubygems_version: 2.2.2
|
69
|
+
signing_key:
|
70
|
+
specification_version: 4
|
71
|
+
summary: Keep your data clean
|
72
|
+
test_files: []
|