diff --git a/app/models/concerns/json_schema_validator.rb b/app/models/concerns/json_schema_validator.rb new file mode 100644 index 000000000..4ae94df12 --- /dev/null +++ b/app/models/concerns/json_schema_validator.rb @@ -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 diff --git a/app/models/message.rb b/app/models/message.rb index 1b1bd1479..e0e5d94a2 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -37,12 +37,33 @@ class Message < ApplicationRecord include Liquidable 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 validates :account_id, presence: true validates :inbox_id, presence: true validates :conversation_id, presence: true validates_with ContentAttributeValidator + validates_with JsonSchemaValidator, + schema: TEMPLATE_PARAMS_SCHEMA, + attribute_resolver: ->(record) { record.additional_attributes } + validates :content_type, presence: true validates :content, length: { maximum: 150_000 } diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb index 6b96d1731..3d843e64b 100644 --- a/app/services/whatsapp/send_on_whatsapp_service.rb +++ b/app/services/whatsapp/send_on_whatsapp_service.rb @@ -28,13 +28,14 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService message.update!(source_id: message_id) if message_id.present? end + # rubocop:disable Metrics/CyclomaticComplexity def processable_channel_message_template if template_params.present? return [ template_params['name'], template_params['namespace'], template_params['language'], - template_params['processed_params'].map { |_, value| { type: 'text', text: value } } + template_params['processed_params']&.map { |_, value| { type: 'text', text: value } } ] end @@ -55,6 +56,7 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService end [nil, nil, nil, nil] end + # rubocop:enable Metrics/CyclomaticComplexity def template_match_object(template) body_object = validated_body_object(template) diff --git a/spec/models/concerns/json_schema_validator_spec.rb b/spec/models/concerns/json_schema_validator_spec.rb new file mode 100644 index 000000000..77e08f53f --- /dev/null +++ b/spec/models/concerns/json_schema_validator_spec.rb @@ -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 diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb index 1dc405abd..0df9316f5 100644 --- a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb +++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb @@ -5,6 +5,7 @@ describe Whatsapp::SendOnWhatsappService do name: 'sample_shipping_confirmation', namespace: '23423423_2342423_324234234_2343224', language: 'en_US', + category: 'Marketing', processed_params: { '1' => '3' } }