fix: validate template_params for WhatsApp (#6881)
- Add JsonSchemaValidator, which takes a declarative schema and validates it for a given property. - Add specs for JsonSchemaValidator - Enable the validator for template_params
This commit is contained in:
84
app/models/concerns/json_schema_validator.rb
Normal file
84
app/models/concerns/json_schema_validator.rb
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# This file defines a custom validator class `JsonSchemaValidator` for validating a JSON object against a schema.
|
||||||
|
# To use this validator, define a schema as a Ruby hash and include it in the validation options when validating a model.
|
||||||
|
# The schema should define the expected structure and types of the JSON object, as well as any validation rules.
|
||||||
|
# Here's an example schema:
|
||||||
|
#
|
||||||
|
# schema = {
|
||||||
|
# 'type' => 'object',
|
||||||
|
# 'properties' => {
|
||||||
|
# 'name' => { 'type' => 'string' },
|
||||||
|
# 'age' => { 'type' => 'integer' },
|
||||||
|
# 'is_active' => { 'type' => 'boolean' },
|
||||||
|
# 'tags' => { 'type' => 'array' },
|
||||||
|
# 'address' => {
|
||||||
|
# 'type' => 'object',
|
||||||
|
# 'properties' => {
|
||||||
|
# 'street' => { 'type' => 'string' },
|
||||||
|
# 'city' => { 'type' => 'string' }
|
||||||
|
# },
|
||||||
|
# 'required' => ['street', 'city']
|
||||||
|
# }
|
||||||
|
# },
|
||||||
|
# 'required': ['name', 'age']
|
||||||
|
# }.to_json.freeze
|
||||||
|
#
|
||||||
|
# To validate a model using this schema, include the `JsonSchemaValidator` in the model's validations and pass the schema
|
||||||
|
# as an option:
|
||||||
|
#
|
||||||
|
# class MyModel < ApplicationRecord
|
||||||
|
# validates_with JsonSchemaValidator, schema: schema
|
||||||
|
# end
|
||||||
|
|
||||||
|
class JsonSchemaValidator < ActiveModel::Validator
|
||||||
|
def validate(record)
|
||||||
|
# Get the attribute resolver function from options or use a default one
|
||||||
|
attribute_resolver = options[:attribute_resolver] || ->(rec) { rec.additional_attributes }
|
||||||
|
|
||||||
|
# Resolve the JSON data to be validated
|
||||||
|
json_data = attribute_resolver.call(record)
|
||||||
|
|
||||||
|
# Get the schema to be used for validation
|
||||||
|
schema = options[:schema]
|
||||||
|
|
||||||
|
# Create a JSONSchemer instance using the schema
|
||||||
|
schemer = JSONSchemer.schema(schema)
|
||||||
|
|
||||||
|
# Validate the JSON data against the schema
|
||||||
|
validation_errors = schemer.validate(json_data)
|
||||||
|
|
||||||
|
# Add validation errors to the record with a formatted statement
|
||||||
|
validation_errors.each do |error|
|
||||||
|
# byebug
|
||||||
|
format_and_append_error(error, record)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def format_and_append_error(error, record)
|
||||||
|
return handle_required(error, record) if error['type'] == 'required'
|
||||||
|
|
||||||
|
type = error['type'] == 'object' ? 'hash' : error['type']
|
||||||
|
|
||||||
|
handle_type(error, record, type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_required(error, record)
|
||||||
|
missing_values = error['details']['missing_keys']
|
||||||
|
missing_values.each do |missing|
|
||||||
|
record.errors.add(missing, 'is required')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_type(error, record, expected_type)
|
||||||
|
data = get_name_from_data_pointer(error)
|
||||||
|
record.errors.add(data, "must be of type #{expected_type}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_name_from_data_pointer(error)
|
||||||
|
data = error['data_pointer']
|
||||||
|
|
||||||
|
# if data starts with a "/" remove it
|
||||||
|
data[1..] if data[0] == '/'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -37,12 +37,33 @@ class Message < ApplicationRecord
|
|||||||
include Liquidable
|
include Liquidable
|
||||||
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
|
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
|
||||||
|
|
||||||
|
TEMPLATE_PARAMS_SCHEMA = {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'template_params': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'name': { 'type': 'string' },
|
||||||
|
'category': { 'type': 'string' },
|
||||||
|
'language': { 'type': 'string' },
|
||||||
|
'namespace': { 'type': 'string' },
|
||||||
|
'processed_params': { 'type': 'object' }
|
||||||
|
},
|
||||||
|
'required': %w[name category language namespace processed_params]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.to_json.freeze
|
||||||
|
|
||||||
before_validation :ensure_content_type
|
before_validation :ensure_content_type
|
||||||
|
|
||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
validates :inbox_id, presence: true
|
validates :inbox_id, presence: true
|
||||||
validates :conversation_id, presence: true
|
validates :conversation_id, presence: true
|
||||||
validates_with ContentAttributeValidator
|
validates_with ContentAttributeValidator
|
||||||
|
validates_with JsonSchemaValidator,
|
||||||
|
schema: TEMPLATE_PARAMS_SCHEMA,
|
||||||
|
attribute_resolver: ->(record) { record.additional_attributes }
|
||||||
|
|
||||||
validates :content_type, presence: true
|
validates :content_type, presence: true
|
||||||
validates :content, length: { maximum: 150_000 }
|
validates :content, length: { maximum: 150_000 }
|
||||||
|
|
||||||
|
|||||||
@@ -28,13 +28,14 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
|||||||
message.update!(source_id: message_id) if message_id.present?
|
message.update!(source_id: message_id) if message_id.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# rubocop:disable Metrics/CyclomaticComplexity
|
||||||
def processable_channel_message_template
|
def processable_channel_message_template
|
||||||
if template_params.present?
|
if template_params.present?
|
||||||
return [
|
return [
|
||||||
template_params['name'],
|
template_params['name'],
|
||||||
template_params['namespace'],
|
template_params['namespace'],
|
||||||
template_params['language'],
|
template_params['language'],
|
||||||
template_params['processed_params'].map { |_, value| { type: 'text', text: value } }
|
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
|||||||
end
|
end
|
||||||
[nil, nil, nil, nil]
|
[nil, nil, nil, nil]
|
||||||
end
|
end
|
||||||
|
# rubocop:enable Metrics/CyclomaticComplexity
|
||||||
|
|
||||||
def template_match_object(template)
|
def template_match_object(template)
|
||||||
body_object = validated_body_object(template)
|
body_object = validated_body_object(template)
|
||||||
|
|||||||
112
spec/models/concerns/json_schema_validator_spec.rb
Normal file
112
spec/models/concerns/json_schema_validator_spec.rb
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe JsonSchemaValidator, type: :validator do
|
||||||
|
schema = {
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => {
|
||||||
|
'name' => { 'type' => 'string' },
|
||||||
|
'age' => { 'type' => 'integer' },
|
||||||
|
'is_active' => { 'type' => 'boolean' },
|
||||||
|
'tags' => { 'type' => 'array' },
|
||||||
|
'address' => {
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => {
|
||||||
|
'street' => { 'type' => 'string' },
|
||||||
|
'city' => { 'type' => 'string' }
|
||||||
|
},
|
||||||
|
'required' => %w[street city]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
:required => %w[name age]
|
||||||
|
}.to_json.freeze
|
||||||
|
|
||||||
|
# Create a simple test model for validation
|
||||||
|
before_all do
|
||||||
|
# rubocop:disable Lint/ConstantDefinitionInBlock
|
||||||
|
# rubocop:disable RSpec/LeakyConstantDeclaration
|
||||||
|
TestModelForJSONValidation = Struct.new(:additional_attributes) do
|
||||||
|
include ActiveModel::Validations
|
||||||
|
|
||||||
|
validates_with JsonSchemaValidator, schema: schema
|
||||||
|
end
|
||||||
|
# rubocop:enable Lint/ConstantDefinitionInBlock
|
||||||
|
# rubocop:enable RSpec/LeakyConstantDeclaration
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with valid data' do
|
||||||
|
let(:valid_data) do
|
||||||
|
{
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'age' => 30,
|
||||||
|
'tags' => %w[tag1 tag2],
|
||||||
|
'is_active' => true,
|
||||||
|
'address' => {
|
||||||
|
'street' => '123 Main St',
|
||||||
|
'city' => 'Iceland'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'passes validation' do
|
||||||
|
model = TestModelForJSONValidation.new(valid_data)
|
||||||
|
expect(model.valid?).to be true
|
||||||
|
expect(model.errors.full_messages).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with missing required attributes' do
|
||||||
|
let(:invalid_data) do
|
||||||
|
{
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'address' => {
|
||||||
|
'street' => '123 Main St',
|
||||||
|
'city' => 'Iceland'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails validation' do
|
||||||
|
model = TestModelForJSONValidation.new(invalid_data)
|
||||||
|
expect(model.valid?).to be false
|
||||||
|
expect(model.errors.messages).to eq({ :age => ['is required'] })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with incorrect address hash' do
|
||||||
|
let(:invalid_data) do
|
||||||
|
{
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'age' => 30,
|
||||||
|
'address' => 'not-a-hash'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails validation' do
|
||||||
|
model = TestModelForJSONValidation.new(invalid_data)
|
||||||
|
expect(model.valid?).to be false
|
||||||
|
expect(model.errors.messages).to eq({ :address => ['must be of type hash'] })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with incorrect types' do
|
||||||
|
let(:invalid_data) do
|
||||||
|
{
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'age' => '30',
|
||||||
|
'is_active' => 'some-value',
|
||||||
|
'tags' => 'not-an-array',
|
||||||
|
'address' => {
|
||||||
|
'street' => 123,
|
||||||
|
'city' => 'Iceland'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails validation' do
|
||||||
|
model = TestModelForJSONValidation.new(invalid_data)
|
||||||
|
expect(model.valid?).to be false
|
||||||
|
expect(model.errors.messages).to eq({ :age => ['must be of type integer'], :'address/street' => ['must be of type string'],
|
||||||
|
:is_active => ['must be of type boolean'], :tags => ['must be of type array'] })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,6 +5,7 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
name: 'sample_shipping_confirmation',
|
name: 'sample_shipping_confirmation',
|
||||||
namespace: '23423423_2342423_324234234_2343224',
|
namespace: '23423423_2342423_324234234_2343224',
|
||||||
language: 'en_US',
|
language: 'en_US',
|
||||||
|
category: 'Marketing',
|
||||||
processed_params: { '1' => '3' }
|
processed_params: { '1' => '3' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user