mirror of
https://github.com/fog/fog.git
synced 2022-11-09 13:51:43 -05:00
Merge pull request #1490 from fog/1477_formats_nils
[tests] Changes to format testing helper
This commit is contained in:
commit
35ae326523
7 changed files with 427 additions and 96 deletions
154
lib/fog/schema/data_validator.rb
Normal file
154
lib/fog/schema/data_validator.rb
Normal file
|
@ -0,0 +1,154 @@
|
|||
module Fog
|
||||
module Schema
|
||||
# This validates a data object against a Ruby based schema to see
|
||||
# if they match.
|
||||
#
|
||||
# * An object matches the schema if +==+ or +===+ returns +true+
|
||||
# * Hashes match if all the key's values match the classes given
|
||||
# in the schema as well. This can be configured in the options
|
||||
# * Arrays match when every element in the data matches the case
|
||||
# given in the schema.
|
||||
#
|
||||
# The schema and validation are very simple and probably not
|
||||
# suitable for some cases.
|
||||
#
|
||||
# The following classes can be used to check for special behaviour
|
||||
#
|
||||
# * Fog::Boolean - value may be +true+ or +false+
|
||||
# * Fog::Nullable::Boolean - value may be +true+, +false+ or +nil+
|
||||
# * Fog::Nullable::Integer - value may be an Integer or +nil+
|
||||
# * Fog::Nullable::String
|
||||
# * Fog::Nullable::Time
|
||||
# * Fog::Nullable::Float
|
||||
# * Fog::Nullable::Hash
|
||||
# * Fog::Nullable::Array
|
||||
#
|
||||
# All the "nullable" objects will pass if the value is of the class
|
||||
# or if it is +nil+. This allows you to match APIs that may include
|
||||
# keys when the value is not available in some cases but will
|
||||
# always be a String. Such as an password that is only displayed
|
||||
# on the reset action.
|
||||
#
|
||||
# The keys for "nullable" resources should always be present but
|
||||
# original matcher had a bug that allowed these to also appear to
|
||||
# work as optional keys/values.
|
||||
#
|
||||
# If you need the original behaviour, data with a missing key is
|
||||
# still valid, then you may pass the +:allow_optional_rules+
|
||||
# option to the #validate method.
|
||||
#
|
||||
# That is not recommended because you are describing a schema
|
||||
# with optional keys in a format that does not support it.
|
||||
#
|
||||
# Setting +:allow_extra_keys+ as +true+ allows the data to
|
||||
# contain keys not declared by the schema and still pass. This
|
||||
# is useful if new attributes appear in the API in a backwards
|
||||
# compatible manner and can be ignored.
|
||||
#
|
||||
# This is the behaviour you would have seen with +strict+ being
|
||||
# +false+ in the original test helper.
|
||||
#
|
||||
# @example Schema example
|
||||
# {
|
||||
# "id" => String,
|
||||
# "ram" => Integer,
|
||||
# "disks" => [
|
||||
# "size" => Float
|
||||
# ],
|
||||
# "dns_name" => Fog::Nullable::String,
|
||||
# "active" => Fog::Boolean,
|
||||
# "created" => DateTime
|
||||
# }
|
||||
#
|
||||
class DataValidator
|
||||
|
||||
def initialize
|
||||
@message = nil
|
||||
end
|
||||
|
||||
# Checks if the data structure matches the schema passed in and
|
||||
# returns +true+ if it fits.
|
||||
#
|
||||
# @param [Object] data Hash or Array to check
|
||||
# @param [Object] schema Schema pattern to check against
|
||||
# @param [Boolean] options
|
||||
# @option options [Boolean] :allow_extra_keys
|
||||
# If +true+ does not fail if extra keys are in the data
|
||||
# that are not in the schema.
|
||||
# @option options [Boolean] :allow_optional_rules
|
||||
# If +true+ does not fail if extra keys are in the schema
|
||||
# that do not match the data. Not recommended!
|
||||
#
|
||||
# @return [Boolean] Did the data fit the schema?
|
||||
def validate(data, schema, options = {})
|
||||
valid = validate_value(schema, data, options)
|
||||
|
||||
unless valid
|
||||
@message = "#{data.inspect} does not match #{schema.inspect}"
|
||||
end
|
||||
valid
|
||||
end
|
||||
|
||||
# This returns the last message set by the validator
|
||||
#
|
||||
# @return [String]
|
||||
def message
|
||||
@message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# This contains a slightly modified version of the Hashidator gem
|
||||
# but unfortunately the gem does not cope with Array schemas.
|
||||
#
|
||||
# @see https://github.com/vangberg/hashidator/blob/master/lib/hashidator.rb
|
||||
#
|
||||
def validate_value(validator, value, options)
|
||||
Fog::Logger.write :debug, "[yellow][DEBUG] #{value.inspect} against #{validator.inspect}[/]\n"
|
||||
|
||||
case validator
|
||||
when Array
|
||||
return false if value.is_a?(Hash)
|
||||
value.respond_to?(:all?) && value.all? {|x| validate_value(validator[0], x, options)}
|
||||
when Symbol
|
||||
value.respond_to? validator
|
||||
when Hash
|
||||
return false if value.is_a?(Array)
|
||||
|
||||
# When being strict values not specified in the schema are fails
|
||||
unless options[:allow_extra_keys]
|
||||
if value.respond_to?(:empty?)
|
||||
# Validator is empty but values are not
|
||||
return false if !value.empty? && validator.empty?
|
||||
end
|
||||
end
|
||||
|
||||
unless options[:allow_optional_rules]
|
||||
if value.respond_to?(:empty?)
|
||||
# Validator has rules left but no more values
|
||||
return false if value.empty? && !validator.empty?
|
||||
end
|
||||
end
|
||||
|
||||
validator.all? do |key, sub_validator|
|
||||
Fog::Logger.write :debug, "[blue][DEBUG] #{key.inspect} against #{sub_validator.inspect}[/]\n"
|
||||
validate_value(sub_validator, value[key], options)
|
||||
end
|
||||
else
|
||||
result = validator == value
|
||||
result = validator === value unless result
|
||||
# Repeat unless we have a Boolean already
|
||||
unless (result.is_a?(TrueClass) || result.is_a?(FalseClass))
|
||||
result = validate_value(result, value, options)
|
||||
end
|
||||
if result
|
||||
Fog::Logger.write :debug, "[green][DEBUG] Validation passed: #{value.inspect} against #{validator.inspect}[/]\n"
|
||||
else
|
||||
Fog::Logger.write :debug, "[red][DEBUG] Validation failed: #{value.inspect} against #{validator.inspect}[/]\n"
|
||||
end
|
||||
result
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,7 +2,7 @@ Shindo.tests('Fog::Compute[:aws] | image requests', ['aws']) do
|
|||
@describe_images_format = {
|
||||
'imagesSet' => [{
|
||||
'architecture' => String,
|
||||
'blockDeviceMapping' => [],
|
||||
'blockDeviceMapping' => [Fog::Nullable::Hash],
|
||||
'description' => Fog::Nullable::String,
|
||||
'hypervisor' => String,
|
||||
'imageId' => String,
|
||||
|
@ -135,4 +135,4 @@ Shindo.tests('Fog::Compute[:aws] | image requests', ['aws']) do
|
|||
Fog::Compute[:aws].modify_image_attribute('ami-00000000', { 'Add.UserId' => ['123456789012'] }).body
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -15,7 +15,7 @@ Shindo.tests('Fog::Compute[:aws] | security group requests', ['aws']) do
|
|||
'fromPort' => Fog::Nullable::Integer,
|
||||
'groups' => [{ 'groupName' => Fog::Nullable::String, 'userId' => String, 'groupId' => String }],
|
||||
'ipProtocol' => String,
|
||||
'ipRanges' => [],
|
||||
'ipRanges' => [Fog::Nullable::Hash],
|
||||
'toPort' => Fog::Nullable::Integer,
|
||||
}],
|
||||
'ipPermissionsEgress' => [],
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
require "fog/schema/data_validator"
|
||||
|
||||
# format related hackery
|
||||
# allows both true.is_a?(Fog::Boolean) and false.is_a?(Fog::Boolean)
|
||||
# allows both nil.is_a?(Fog::Nullable::String) and ''.is_a?(Fog::Nullable::String)
|
||||
|
@ -25,62 +27,73 @@ end
|
|||
module Shindo
|
||||
class Tests
|
||||
|
||||
def formats(format, strict=true)
|
||||
raise ArgumentError, 'format is nil' unless format
|
||||
# Generates a Shindo test that compares a hash schema to the result
|
||||
# of the passed in block returning true if they match.
|
||||
#
|
||||
# The schema that is passed in is a Hash or Array of hashes that
|
||||
# have Classes in place of values. When checking the schema the
|
||||
# value should match the Class.
|
||||
#
|
||||
# Strict mode will fail if the data has additional keys. Setting
|
||||
# +strict+ to +false+ will allow additional keys to appear.
|
||||
#
|
||||
# @param [Hash] schema A Hash schema
|
||||
# @param [Hash] options Options to change validation rules
|
||||
# @option options [Boolean] :allow_extra_keys
|
||||
# If +true+ does not fail when keys are in the data that are
|
||||
# not specified in the schema. This allows new values to
|
||||
# appear in API output without breaking the check.
|
||||
# @option options [Boolean] :allow_optional_rules
|
||||
# If +true+ does not fail if extra keys are in the schema
|
||||
# that do not match the data. Not recommended!
|
||||
# @yield Data to check with schema
|
||||
#
|
||||
# @example Using in a test
|
||||
# Shindo.tests("comparing welcome data against schema") do
|
||||
# data = {:welcome => "Hello" }
|
||||
# data_matches_schema(:welcome => String) { data }
|
||||
# end
|
||||
#
|
||||
# comparing welcome data against schema
|
||||
# + data matches schema
|
||||
#
|
||||
# @example Example schema
|
||||
# {
|
||||
# "id" => String,
|
||||
# "ram" => Integer,
|
||||
# "disks" => [
|
||||
# {
|
||||
# "size" => Float
|
||||
# }
|
||||
# ],
|
||||
# "dns_name" => Fog::Nullable::String,
|
||||
# "active" => Fog::Boolean,
|
||||
# "created" => DateTime
|
||||
# }
|
||||
#
|
||||
# @return [Boolean]
|
||||
def data_matches_schema(schema, options = {})
|
||||
test('data matches schema') do
|
||||
validator = Fog::Schema::DataValidator.new
|
||||
valid = validator.validate(yield, schema, options)
|
||||
@message = validator.message unless valid
|
||||
valid
|
||||
end
|
||||
end
|
||||
|
||||
# @deprecated #formats is deprecated. Use #data_matches_schema instead
|
||||
def formats(format, strict = true)
|
||||
test('has proper format') do
|
||||
formats_kernel(instance_eval(&Proc.new), format, true, strict)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def formats_kernel(original_data, original_format, original = true, strict = true)
|
||||
valid = true
|
||||
data = original_data.dup
|
||||
format = original_format.dup
|
||||
if format.is_a?(Array)
|
||||
data = {:element => data}
|
||||
format = {:element => format}
|
||||
end
|
||||
for key, value in format
|
||||
datum = data.delete(key)
|
||||
format.delete(key)
|
||||
case value
|
||||
when Array
|
||||
p("#{key.inspect} not Array: #{datum.inspect}") unless datum.is_a?(Array)
|
||||
valid &&= datum.is_a?(Array)
|
||||
if datum.is_a?(Array) && !value.empty?
|
||||
for element in datum
|
||||
type = value.first
|
||||
if type.is_a?(Hash)
|
||||
valid &&= formats_kernel({:element => element}, {:element => type}, false, strict)
|
||||
else
|
||||
valid &&= element.is_a?(type)
|
||||
end
|
||||
end
|
||||
end
|
||||
when Hash
|
||||
p("#{key.inspect} not Hash: #{datum.inspect}") unless datum.is_a?(Hash)
|
||||
valid &&= datum.is_a?(Hash)
|
||||
valid &&= formats_kernel(datum, value, false, strict)
|
||||
if strict
|
||||
options = {:allow_extra_keys => false, :allow_optional_rules => true}
|
||||
else
|
||||
p "#{key.inspect} not #{value.inspect}: #{datum.inspect}" unless datum.is_a?(value)
|
||||
valid &&= datum.is_a?(value)
|
||||
options = {:allow_extra_keys => true, :allow_optional_rules => true}
|
||||
end
|
||||
validator = Fog::Schema::DataValidator.new
|
||||
valid = validator.validate(yield, format, options)
|
||||
@message = validator.message unless valid
|
||||
valid
|
||||
end
|
||||
p data unless data.empty?
|
||||
p format unless format.empty?
|
||||
if strict
|
||||
valid &&= data.empty? && format.empty?
|
||||
else
|
||||
valid &&= format.empty?
|
||||
end
|
||||
if !valid && original
|
||||
@message = "#{original_data.inspect} does not match #{original_format.inspect}"
|
||||
end
|
||||
valid
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,60 +1,111 @@
|
|||
Shindo.tests('test_helper', 'meta') do
|
||||
|
||||
tests('#formats_kernel') do
|
||||
|
||||
tests('returns true') do
|
||||
|
||||
test('when format of value matches') do
|
||||
formats_kernel({:a => :b}, {:a => Symbol})
|
||||
end
|
||||
|
||||
test('when format of nested array elements matches') do
|
||||
formats_kernel({:a => [:b, :c]}, {:a => [Symbol]})
|
||||
end
|
||||
|
||||
test('when format of nested hash matches') do
|
||||
formats_kernel({:a => {:b => :c}}, {:a => {:b => Symbol}})
|
||||
end
|
||||
|
||||
test('when format of an array') do
|
||||
formats_kernel([{:a => :b}], [{:a => Symbol}])
|
||||
end
|
||||
|
||||
test('non strict extra data') do
|
||||
formats_kernel({:a => :b, :b => :c}, {:a => Symbol}, true, false)
|
||||
end
|
||||
tests('comparing welcome data against schema') do
|
||||
data = {:welcome => "Hello" }
|
||||
data_matches_schema(:welcome => String) { data }
|
||||
end
|
||||
|
||||
tests('#data_matches_schema') do
|
||||
tests('when value matches schema expectation') do
|
||||
data_matches_schema({"key" => String}) { {"key" => "Value"} }
|
||||
end
|
||||
|
||||
tests('returns false') do
|
||||
tests('when values within an array all match schema expectation') do
|
||||
data_matches_schema({"key" => [Integer]}) { {"key" => [1, 2]} }
|
||||
end
|
||||
|
||||
test('when format of value does not match') do
|
||||
!formats_kernel({:a => :b}, {:a => String})
|
||||
end
|
||||
tests('when nested values match schema expectation') do
|
||||
data_matches_schema({"key" => {:nested_key => String}}) { {"key" => {:nested_key => "Value"}} }
|
||||
end
|
||||
|
||||
test('when not all keys are checked') do
|
||||
!formats_kernel({:a => :b}, {})
|
||||
end
|
||||
tests('when collection of values all match schema expectation') do
|
||||
data_matches_schema([{"key" => String}]) { [{"key" => "Value"}, {"key" => "Value"}] }
|
||||
end
|
||||
|
||||
test('when some keys do not appear') do
|
||||
!formats_kernel({}, {:a => String})
|
||||
end
|
||||
tests('when collection is empty although schema covers optional members') do
|
||||
data_matches_schema([{"key" => String}], {:allow_optional_rules => true}) { [] }
|
||||
end
|
||||
|
||||
test('when an array is expected but another data type is found') do
|
||||
!formats_kernel({:a => 'not an array'}, {:a => []})
|
||||
end
|
||||
tests('when additional keys are passed and not strict') do
|
||||
data_matches_schema({"key" => String}, {:allow_extra_keys => true}) { {"key" => "Value", :extra => "Bonus"} }
|
||||
end
|
||||
|
||||
test('when a hash is expected but another data type is found') do
|
||||
!formats_kernel({:a => 'not a hash'}, {:a => {}}, true, false)
|
||||
end
|
||||
tests('when value is nil and schema expects NilClass') do
|
||||
data_matches_schema({"key" => NilClass}) { {"key" => nil} }
|
||||
end
|
||||
|
||||
tests('when value and schema match as hashes') do
|
||||
data_matches_schema({}) { {} }
|
||||
end
|
||||
|
||||
test('non strict extra data') do
|
||||
!formats_kernel({:a => :b, :b => :c}, {:z => Symbol}, true, false)
|
||||
end
|
||||
tests('when value and schema match as arrays') do
|
||||
data_matches_schema([]) { [] }
|
||||
end
|
||||
|
||||
tests('when value is a Time') do
|
||||
data_matches_schema({"time" => Time}) { {"time" => Time.now} }
|
||||
end
|
||||
|
||||
tests('when key is missing but value should be NilClass (#1477)') do
|
||||
data_matches_schema({"key" => NilClass}, {:allow_optional_rules => true}) { {} }
|
||||
end
|
||||
|
||||
tests('when key is missing but value is nullable (#1477)') do
|
||||
data_matches_schema({"key" => Fog::Nullable::String}, {:allow_optional_rules => true}) { {} }
|
||||
end
|
||||
end
|
||||
|
||||
tests('#formats backwards compatible changes') do
|
||||
|
||||
tests('when value matches schema expectation') do
|
||||
formats({"key" => String}) { {"key" => "Value"} }
|
||||
end
|
||||
|
||||
tests('when values within an array all match schema expectation') do
|
||||
formats({"key" => [Integer]}) { {"key" => [1, 2]} }
|
||||
end
|
||||
|
||||
tests('when nested values match schema expectation') do
|
||||
formats({"key" => {:nested_key => String}}) { {"key" => {:nested_key => "Value"}} }
|
||||
end
|
||||
|
||||
tests('when collection of values all match schema expectation') do
|
||||
formats([{"key" => String}]) { [{"key" => "Value"}, {"key" => "Value"}] }
|
||||
end
|
||||
|
||||
tests('when collection is empty although schema covers optional members') do
|
||||
formats([{"key" => String}]) { [] }
|
||||
end
|
||||
|
||||
tests('when additional keys are passed and not strict') do
|
||||
formats({"key" => String}, false) { {"key" => "Value", :extra => "Bonus"} }
|
||||
end
|
||||
|
||||
tests('when value is nil and schema expects NilClass') do
|
||||
formats({"key" => NilClass}) { {"key" => nil} }
|
||||
end
|
||||
|
||||
tests('when value and schema match as hashes') do
|
||||
formats({}) { {} }
|
||||
end
|
||||
|
||||
tests('when value and schema match as arrays') do
|
||||
formats([]) { [] }
|
||||
end
|
||||
|
||||
tests('when value is a Time') do
|
||||
formats({"time" => Time}) { {"time" => Time.now} }
|
||||
end
|
||||
|
||||
tests('when key is missing but value should be NilClass (#1477)') do
|
||||
formats({"key" => NilClass}) { {} }
|
||||
end
|
||||
|
||||
tests('when key is missing but value is nullable (#1477)') do
|
||||
formats({"key" => Fog::Nullable::String}) { {} }
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
|
|
107
tests/helpers/schema_validator_tests.rb
Normal file
107
tests/helpers/schema_validator_tests.rb
Normal file
|
@ -0,0 +1,107 @@
|
|||
Shindo.tests('Fog::Schema::DataValidator', 'meta') do
|
||||
|
||||
validator = Fog::Schema::DataValidator.new
|
||||
|
||||
tests('#validate') do
|
||||
|
||||
tests('returns true') do
|
||||
|
||||
returns(true, 'when value matches schema expectation') do
|
||||
validator.validate({"key" => "Value"}, {"key" => String})
|
||||
end
|
||||
|
||||
returns(true, 'when values within an array all match schema expectation') do
|
||||
validator.validate({"key" => [1, 2]}, {"key" => [Integer]})
|
||||
end
|
||||
|
||||
returns(true, 'when nested values match schema expectation') do
|
||||
validator.validate({"key" => {:nested_key => "Value"}}, {"key" => {:nested_key => String}})
|
||||
end
|
||||
|
||||
returns(true, 'when collection of values all match schema expectation') do
|
||||
validator.validate([{"key" => "Value"}, {"key" => "Value"}], [{"key" => String}])
|
||||
end
|
||||
|
||||
returns(true, 'when collection is empty although schema covers optional members') do
|
||||
validator.validate([], [{"key" => String}])
|
||||
end
|
||||
|
||||
returns(true, 'when additional keys are passed and not strict') do
|
||||
validator.validate({"key" => "Value", :extra => "Bonus"}, {"key" => String}, {:allow_extra_keys => true})
|
||||
end
|
||||
|
||||
returns(true, 'when value is nil and schema expects NilClass') do
|
||||
validator.validate({"key" => nil}, {"key" => NilClass})
|
||||
end
|
||||
|
||||
returns(true, 'when value and schema match as hashes') do
|
||||
validator.validate({}, {})
|
||||
end
|
||||
|
||||
returns(true, 'when value and schema match as arrays') do
|
||||
validator.validate([], [])
|
||||
end
|
||||
|
||||
returns(true, 'when value is a Time') do
|
||||
validator.validate({"time" => Time.now}, {"time" => Time})
|
||||
end
|
||||
|
||||
returns(true, 'when key is missing but value should be NilClass (#1477)') do
|
||||
validator.validate({}, {"key" => NilClass}, {:allow_optional_rules => true})
|
||||
end
|
||||
|
||||
returns(true, 'when key is missing but value is nullable (#1477)') do
|
||||
validator.validate({}, {"key" => Fog::Nullable::String}, {:allow_optional_rules => true})
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
tests('returns false') do
|
||||
|
||||
returns(false, 'when value does not match schema expectation') do
|
||||
validator.validate({"key" => nil}, {"key" => String})
|
||||
end
|
||||
|
||||
returns(false, 'when key formats do not match') do
|
||||
validator.validate({"key" => "Value"}, {:key => String})
|
||||
end
|
||||
|
||||
returns(false, 'when additional keys are passed and strict') do
|
||||
validator.validate({"key" => "Missing"}, {})
|
||||
end
|
||||
|
||||
returns(false, 'when some keys do not appear') do
|
||||
validator.validate({}, {"key" => String})
|
||||
end
|
||||
|
||||
returns(false, 'when collection contains a member that does not match schema') do
|
||||
validator.validate([{"key" => "Value"}, {"key" => 5}], [{"key" => String}])
|
||||
end
|
||||
|
||||
returns(false, 'when collection has multiple schema patterns') do
|
||||
validator.validate([{"key" => "Value"}], [{"key" => Integer}, {"key" => String}])
|
||||
end
|
||||
|
||||
returns(false, 'when hash and array are compared') do
|
||||
validator.validate({}, [])
|
||||
end
|
||||
|
||||
returns(false, 'when array and hash are compared') do
|
||||
validator.validate([], {})
|
||||
end
|
||||
|
||||
returns(false, 'when a hash is expected but another data type is found') do
|
||||
validator.validate({"key" => {:nested_key => []}}, {"key" => {:nested_key => {}}})
|
||||
end
|
||||
|
||||
returns(false, 'when key is missing but value should be NilClass (#1477)') do
|
||||
validator.validate({}, {"key" => NilClass}, {:allow_optional_rules => false})
|
||||
end
|
||||
|
||||
returns(false, 'when key is missing but value is nullable (#1477)') do
|
||||
validator.validate({}, {"key" => Fog::Nullable::String}, {:allow_optional_rules => false})
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
|
@ -27,7 +27,13 @@ Shindo.tests('Fog::Identity[:openstack] | role requests', ['openstack']) do
|
|||
Fog::Identity[:openstack].list_roles_for_user_on_tenant(@tenant['id'], @user['id']).body['roles']
|
||||
end
|
||||
|
||||
tests("#delete_user_role with tenant").returns("") do
|
||||
Fog::Identity[:openstack].delete_user_role(@tenant['id'], @user['id'], @role['id']).body
|
||||
end
|
||||
|
||||
tests("#delete_user_role with tenant").formats(@role_format) do
|
||||
# FIXME - Response (under mocks) is empty String which does not match schema
|
||||
pending
|
||||
Fog::Identity[:openstack].delete_user_role(@tenant['id'], @user['id'], @role['id']).body
|
||||
end
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue