Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,7 @@ SALESFORCE_CONNECT_PASSWORD=password
SALESFORCE_CONNECT_USER=postgres

SCRATCH_ASSET_CONFIG_BASE_URL=https://example.com/config/
SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/
SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/

# Pardot Form Handler endpoint for subscription forwarding
PARDOT_SUBSCRIPTION_URL=
65 changes: 65 additions & 0 deletions app/controllers/api/subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

module Api
class SubscriptionsController < ApiController
def create
payload = subscription_params.to_h
errors = validation_errors_for(payload)

if errors.empty?
submit_result = subscriptions_submitter.call(form_payload: payload)
if submit_result.success?
Rails.logger.info('[subscriptions#create] outcome=success')
Comment thread
DNR500 marked this conversation as resolved.
render json: {
ok: true,
message: 'Subscription accepted',
subscription: payload
}, status: :ok
else
Rails.logger.warn(
"[subscriptions#create] outcome=failure error_code=#{submit_result.error_code}"
)
render json: {
ok: false,
error_code: submit_result.error_code,
message: submit_result.message
}, status: submit_result.status
end
else
Rails.logger.warn('[subscriptions#create] outcome=failure error_code=subscription_validation_failed')
render json: {
ok: false,
error_code: 'subscription_validation_failed',
message: 'Subscription rejected due to invalid input',
errors:,
subscription: payload
}, status: :unprocessable_content
end
end

private

def subscription_params
params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy)
end

def subscriptions_submitter
@subscriptions_submitter ||= Subscriptions::PardotFormHandlerSubmitter.new(
endpoint_url: Rails.configuration.x.subscriptions.pardot_form_handler_url
)
end

def validation_errors_for(payload)
errors = []
errors << 'email is required' if payload['email'].blank?
errors << 'email is invalid' if payload['email'].present? && !valid_email?(payload['email'])
errors << 'privacy_policy must be true' unless payload['privacy_policy'] == true
errors
end
Comment thread
DNR500 marked this conversation as resolved.

def valid_email?(email)
# Keep codebase-consistent validator and also require a dot in the domain.
email.match?(EmailValidator.regexp) && email.split('@').last&.include?('.')
end
end
end
120 changes: 120 additions & 0 deletions app/services/subscriptions/pardot_form_handler_submitter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# frozen_string_literal: true

module Subscriptions
class PardotFormHandlerSubmitter
Result = Struct.new(:success?, :status, :error_code, :message, keyword_init: true)

REQUEST_TIMEOUT_SECONDS = 10
OPEN_TIMEOUT_SECONDS = 5
SUCCESS_STATUS_CODE = 200
ERROR_BODY_PATTERNS = ['error page'].freeze
SUCCESS_BODY_PATTERNS = ['success page'].freeze

def initialize(endpoint_url:)
@endpoint_url = endpoint_url
end

def call(form_payload:)
return missing_configuration_result if endpoint_url.blank?

response = faraday.post(endpoint_url, provider_payload(form_payload))
Rails.logger.info(
"[subscriptions#provider] status=#{response.status} " \
"location=#{redirect_location(response)} " \
"classification=#{classification_for(response)}"
)
classify_response(response)
rescue Faraday::Error => e
Sentry.capture_exception(e)
Result.new(
success?: false,
status: :service_unavailable,
error_code: 'subscription_provider_unavailable',
message: 'Subscription provider is currently unavailable.'
)
end

private

attr_reader :endpoint_url

def faraday
@faraday ||= Faraday.new do |f|
f.request :url_encoded
f.options.timeout = REQUEST_TIMEOUT_SECONDS
f.options.open_timeout = OPEN_TIMEOUT_SECONDS
end
end

def missing_configuration_result
Result.new(
success?: false,
status: :service_unavailable,
error_code: 'subscription_provider_not_configured',
message: 'Subscription provider endpoint is not configured.'
)
end

def provider_payload(form_payload)
# Map internal API contract to Pardot Form Handler external field names.
{
'email' => form_payload['email'],
'Tester' => form_payload['test_opt_in']
}.compact
Comment thread
DNR500 marked this conversation as resolved.
end

def classify_response(response)
body = response_body(response)

return reject_result if error_body?(body)
return reject_result unless response.status == SUCCESS_STATUS_CODE
return Result.new(success?: true) if success_body?(body)

ambiguous_result
end

def reject_result
Result.new(
success?: false,
status: :bad_gateway,
error_code: 'subscription_provider_rejected',
message: 'Subscription provider rejected the request.'
)
end

def ambiguous_result
Result.new(
success?: false,
status: :bad_gateway,
error_code: 'subscription_provider_ambiguous',
message: 'Subscription provider response was ambiguous.'
)
end

def error_body?(body)
ERROR_BODY_PATTERNS.any? { |pattern| body.include?(pattern) }
end

def success_body?(body)
SUCCESS_BODY_PATTERNS.any? { |pattern| body.include?(pattern) }
end

def response_body(response)
response.body.to_s.downcase
end

def redirect_location(response)
response.headers.fetch('location', '').to_s.downcase
end

def classification_for(response)
body = response_body(response)

return 'rejected_error_body' if error_body?(body)
return 'rejected_status' unless response.status == SUCCESS_STATUS_CODE
return 'accepted_success_body' if success_body?(body)

'ambiguous_response'
end
end
end
2 changes: 2 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,7 @@ class Application < Rails::Application
config.active_record.encryption.primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY')
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY')
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT')

config.x.subscriptions.pardot_form_handler_url = ENV.fetch('PARDOT_SUBSCRIPTION_URL', '')
end
end
2 changes: 1 addition & 1 deletion config/initializers/cors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
# localhost and test domain origins
origins(%r{https?://localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test?
origins(%r{https?://([a-z0-9-]+\.)?localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test?

standard_cors_options
end
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
resources :features, only: %i[index]

resources :profile_auth_check, only: %i[index]
resources :subscriptions, only: %i[create]
end

resource :github_webhooks, only: :create, defaults: { formats: :json }
Expand Down
114 changes: 114 additions & 0 deletions spec/requests/api/subscriptions_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Subscriptions API' do
describe 'POST /api/subscriptions' do
let(:path) { '/api/subscriptions' }
let(:payload) do
{
subscription: {
email: 'teacher@example.com',
test_opt_in: true,
privacy_policy: true
}
}
end

let(:submitter_result_success) do
Subscriptions::PardotFormHandlerSubmitter::Result.new(success?: true)
end
let(:submitter_result_failure) do
Subscriptions::PardotFormHandlerSubmitter::Result.new(
success?: false,
status: :service_unavailable,
error_code: 'subscription_provider_unavailable',
message: 'Subscription provider is currently unavailable.'
)
end
let(:submitter_result_rejected) do
Subscriptions::PardotFormHandlerSubmitter::Result.new(
success?: false,
status: :bad_gateway,
error_code: 'subscription_provider_rejected',
message: 'Subscription provider rejected the request.'
)
end
let(:submitter) { instance_double(Subscriptions::PardotFormHandlerSubmitter) }

before do
allow(Subscriptions::PardotFormHandlerSubmitter).to receive(:new).and_return(submitter)
allow(submitter).to receive(:call).and_return(submitter_result_success)
end

it 'returns success for a valid payload' do
post(path, params: payload, as: :json)

expect(response).to have_http_status(:ok)
expect(response.parsed_body).to include(
'ok' => true,
'message' => 'Subscription accepted'
)
expect(submitter).to have_received(:call).with(
form_payload: {
'email' => 'teacher@example.com',
'test_opt_in' => true,
'privacy_policy' => true
}
)
end

it 'returns 422 when email is missing' do
post(path, params: payload.deep_merge(subscription: { email: '' }), as: :json)

expect(response).to have_http_status(:unprocessable_content)
expect(response.parsed_body['errors']).to include('email is required')
end

it 'returns 422 when email is malformed' do
post(path, params: payload.deep_merge(subscription: { email: 'invalid-email' }), as: :json)

expect(response).to have_http_status(:unprocessable_content)
expect(response.parsed_body['errors']).to include('email is invalid')
end

it 'returns 422 when privacy_policy is not true' do
post(path, params: payload.deep_merge(subscription: { privacy_policy: false }), as: :json)

expect(response).to have_http_status(:unprocessable_content)
expect(response.parsed_body['errors']).to include('privacy_policy must be true')
end

it 'returns 400 when subscription params are missing' do
post(path, params: {}, as: :json)

expect(response).to have_http_status(:bad_request)
end

it 'returns provider error status/message when provider submission fails' do
allow(submitter).to receive(:call).and_return(submitter_result_failure)

post(path, params: payload, as: :json)

expect(response).to have_http_status(:service_unavailable)
expect(response.parsed_body).to include(
'ok' => false,
'error_code' => 'subscription_provider_unavailable',
'message' => 'Subscription provider is currently unavailable.'
)
end

it 'returns provider rejection shape when provider rejects request' do
allow(submitter).to receive(:call).and_return(submitter_result_rejected)

post(path, params: payload, as: :json)

expect(response).to have_http_status(:bad_gateway)
expect(response.parsed_body).to include(
'ok' => false,
'error_code' => 'subscription_provider_rejected',
'message' => 'Subscription provider rejected the request.'
)
end
end
end
Loading
Loading