Skip to content
Closed
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
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,12 @@ 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 Account Engagement API integration for subscriptions
PARDOT_API_SUBSCRIPTION_URL=
PARDOT_BUSINESS_UNIT_ID=
PARDOT_AUTH_URL=
PARDOT_CLIENT_ID=
PARDOT_CLIENT_SECRET=
PARDOT_AUTH_SCOPE=
75 changes: 75 additions & 0 deletions app/controllers/api/subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true

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

Rails.logger.warn(
"[subscriptions#create] payload=#{payload.inspect} result=#{errors.empty? ? 'success' : 'failure'}"
)

if errors.empty?
submit_result = subscriptions_submitter.call(subscription_payload: payload)
if submit_result.success?
render json: {
ok: true,
message: 'Subscription accepted',
subscription: payload
}, status: :ok
else
render json: {
ok: false,
error_code: submit_result.error_code,
message: submit_result.message
}, status: submit_result.status
end
else
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::PardotApiSubmitter.new(
subscription_url: Rails.configuration.x.subscriptions.pardot_api_subscription_url,
business_unit_id: Rails.configuration.x.subscriptions.pardot_business_unit_id,
token_provider: subscriptions_token_provider
)
end

def subscriptions_token_provider
@subscriptions_token_provider ||= Subscriptions::PardotTokenProvider.new(
auth_url: Rails.configuration.x.subscriptions.pardot_auth_url,
client_id: Rails.configuration.x.subscriptions.pardot_client_id,
client_secret: Rails.configuration.x.subscriptions.pardot_client_secret,
scope: Rails.configuration.x.subscriptions.pardot_auth_scope
)
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

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
71 changes: 71 additions & 0 deletions app/services/subscriptions/pardot_api_submitter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

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

REQUEST_TIMEOUT_SECONDS = 10
OPEN_TIMEOUT_SECONDS = 5

def initialize(subscription_url:, business_unit_id:, token_provider:)
@subscription_url = subscription_url
@business_unit_id = business_unit_id
@token_provider = token_provider
end

def call(subscription_payload:)
return missing_configuration_result if subscription_url.blank?

response = post_with_token(subscription_payload, force_refresh: false)
response = post_with_token(subscription_payload, force_refresh: true) if response.status == 401

return Result.new(success?: true) if response.success?

Result.new(
success?: false,
status: :bad_gateway,
error_code: 'subscription_provider_rejected',
message: 'Subscription provider rejected the request.'
)
rescue Faraday::Error
Result.new(
success?: false,
status: :service_unavailable,
error_code: 'subscription_provider_unavailable',
message: 'Subscription provider is currently unavailable.'
)
end

private

attr_reader :subscription_url, :business_unit_id, :token_provider

def faraday
@faraday ||= Faraday.new do |f|
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 configuration is incomplete.'
)
end

def post_with_token(subscription_payload, force_refresh:)
token = token_provider.access_token(force_refresh:)
raise Faraday::UnauthorizedError.new('Pardot access token unavailable') if token.blank?

faraday.post(subscription_url) do |request|
request.headers['Authorization'] = "Bearer #{token}"
request.headers['Pardot-Business-Unit-Id'] = business_unit_id if business_unit_id.present?
request.headers['Content-Type'] = 'application/json'
request.body = subscription_payload.to_json
end
end
end
end
66 changes: 66 additions & 0 deletions app/services/subscriptions/pardot_token_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

module Subscriptions
class PardotTokenProvider
CACHE_KEY = 'subscriptions:pardot:access_token'
EXPIRY_SKEW_SECONDS = 60
REQUEST_TIMEOUT_SECONDS = 10
OPEN_TIMEOUT_SECONDS = 5

def initialize(auth_url:, client_id:, client_secret:, scope: nil)
@auth_url = auth_url
@client_id = client_id
@client_secret = client_secret
@scope = scope
end

def access_token(force_refresh: false)
return nil if missing_configuration?

Rails.cache.delete(CACHE_KEY) if force_refresh

cached_token = Rails.cache.read(CACHE_KEY)
return cached_token if cached_token.present?

token, expires_in = fetch_token!
ttl = [expires_in.to_i - EXPIRY_SKEW_SECONDS, 1].max
Rails.cache.write(CACHE_KEY, token, expires_in: ttl)
token
rescue Faraday::Error, KeyError, JSON::ParserError
nil
end

private

attr_reader :auth_url, :client_id, :client_secret, :scope

def missing_configuration?
auth_url.blank? || client_id.blank? || client_secret.blank?
end

def fetch_token!
response = faraday.post(auth_url) do |request|
request.headers['Content-Type'] = 'application/x-www-form-urlencoded'
request.body = {
grant_type: 'client_credentials',
client_id:,
client_secret:,
scope:
}.compact
end

raise Faraday::BadRequestError.new('Token fetch failed', response:) unless response.success?

body = JSON.parse(response.body)
[body.fetch('access_token'), body.fetch('expires_in', 3600)]
end

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
end
end
7 changes: 7 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,12 @@ 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_api_subscription_url = ENV.fetch('PARDOT_API_SUBSCRIPTION_URL', '')
config.x.subscriptions.pardot_business_unit_id = ENV.fetch('PARDOT_BUSINESS_UNIT_ID', '')
config.x.subscriptions.pardot_auth_url = ENV.fetch('PARDOT_AUTH_URL', '')
config.x.subscriptions.pardot_client_id = ENV.fetch('PARDOT_CLIENT_ID', '')
config.x.subscriptions.pardot_client_secret = ENV.fetch('PARDOT_CLIENT_SECRET', '')
config.x.subscriptions.pardot_auth_scope = ENV.fetch('PARDOT_AUTH_SCOPE', '')
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
93 changes: 93 additions & 0 deletions spec/requests/api/subscriptions_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 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::PardotApiSubmitter::Result.new(success?: true)
end
let(:submitter_result_failure) do
Subscriptions::PardotApiSubmitter::Result.new(
success?: false,
status: :service_unavailable,
error_code: 'subscription_provider_unavailable',
message: 'Subscription provider is currently unavailable.'
)
end
let(:submitter) { instance_double(Subscriptions::PardotApiSubmitter) }

before do
allow(Subscriptions::PardotApiSubmitter).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(
subscription_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
end
end
Loading