Skip to content

Commit d82883f

Browse files
DNR500cocomarine
andauthored
1272: wire up form to pardot - explore using form handler (#800)
issue: [1272](RaspberryPiFoundation/digital-editor-issues#1272) # Form Handler for Subscription form ## Overview This PR explores using a Pardot Form Handler on the backend to send subscription data via `editor-api`. --- ## Key Considerations (Form Handler) - **Non-standard handling required for Form Handler integration** Because Pardot Form Handlers do not provide a standard, structured API response contract, editor-api has had to use heuristic response handling (status/body pattern checks). This means outcomes are inferred rather than definitively confirmed on every request, and frontend error detail is necessarily less precise than a typical JSON API integration. --- ## How to Test (Postman) ### Request - **Method:** POST - **URL:** http://localhost:3009/api/subscriptions **Headers:** - Content-Type: application/json - Accept: application/json **Body (raw JSON):** ```json { "subscription": { "email": "teacher@example.com", "test_opt_in": true, "privacy_policy": true } } ``` ### Expected Responses - `200 OK` → valid request, provider accepted - `422 Unprocessable Content` → validation error - `503 Service Unavailable` → provider unavailable / not configured - `502 Bad Gateway` → provider response rejected or ambiguous ### Invalid Example ```json { "subscription": { "email": "bad-email", "test_opt_in": true, "privacy_policy": false } } ``` --------- Co-authored-by: HJ <cocomarine@users.noreply.github.com>
1 parent 6d9b9f6 commit d82883f

8 files changed

Lines changed: 408 additions & 2 deletions

File tree

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,7 @@ SALESFORCE_CONNECT_PASSWORD=password
6464
SALESFORCE_CONNECT_USER=postgres
6565

6666
SCRATCH_ASSET_CONFIG_BASE_URL=https://example.com/config/
67-
SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/
67+
SCRATCH_ASSET_IMPORT_BASE_URL=https://example.com/assets/
68+
69+
# Pardot Form Handler endpoint for subscription forwarding
70+
PARDOT_SUBSCRIPTION_URL=
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
class SubscriptionsController < ApiController
5+
def create
6+
payload = subscription_params.to_h
7+
errors = validation_errors_for(payload)
8+
9+
if errors.empty?
10+
submit_result = subscriptions_submitter.call(form_payload: payload)
11+
if submit_result.success?
12+
Rails.logger.info('[subscriptions#create] outcome=success')
13+
render json: {
14+
ok: true,
15+
message: 'Subscription accepted',
16+
subscription: payload
17+
}, status: :ok
18+
else
19+
Rails.logger.warn(
20+
"[subscriptions#create] outcome=failure error_code=#{submit_result.error_code}"
21+
)
22+
render json: {
23+
ok: false,
24+
error_code: submit_result.error_code,
25+
message: submit_result.message
26+
}, status: submit_result.status
27+
end
28+
else
29+
Rails.logger.warn('[subscriptions#create] outcome=failure error_code=subscription_validation_failed')
30+
render json: {
31+
ok: false,
32+
error_code: 'subscription_validation_failed',
33+
message: 'Subscription rejected due to invalid input',
34+
errors:,
35+
subscription: payload
36+
}, status: :unprocessable_content
37+
end
38+
end
39+
40+
private
41+
42+
def subscription_params
43+
params.require(:subscription).permit(:email, :test_opt_in, :privacy_policy)
44+
end
45+
46+
def subscriptions_submitter
47+
@subscriptions_submitter ||= Subscriptions::PardotFormHandlerSubmitter.new(
48+
endpoint_url: Rails.configuration.x.subscriptions.pardot_form_handler_url
49+
)
50+
end
51+
52+
def validation_errors_for(payload)
53+
errors = []
54+
errors << 'email is required' if payload['email'].blank?
55+
errors << 'email is invalid' if payload['email'].present? && !valid_email?(payload['email'])
56+
errors << 'privacy_policy must be true' unless payload['privacy_policy'] == true
57+
errors
58+
end
59+
60+
def valid_email?(email)
61+
# Keep codebase-consistent validator and also require a dot in the domain.
62+
email.match?(EmailValidator.regexp) && email.split('@').last&.include?('.')
63+
end
64+
end
65+
end
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# frozen_string_literal: true
2+
3+
module Subscriptions
4+
class PardotFormHandlerSubmitter
5+
Result = Struct.new(:success?, :status, :error_code, :message, keyword_init: true)
6+
7+
REQUEST_TIMEOUT_SECONDS = 10
8+
OPEN_TIMEOUT_SECONDS = 5
9+
SUCCESS_STATUS_CODE = 200
10+
ERROR_BODY_PATTERNS = ['error page'].freeze
11+
SUCCESS_BODY_PATTERNS = ['success page'].freeze
12+
13+
def initialize(endpoint_url:)
14+
@endpoint_url = endpoint_url
15+
end
16+
17+
def call(form_payload:)
18+
return missing_configuration_result if endpoint_url.blank?
19+
20+
response = faraday.post(endpoint_url, provider_payload(form_payload))
21+
Rails.logger.info(
22+
"[subscriptions#provider] status=#{response.status} " \
23+
"location=#{redirect_location(response)} " \
24+
"classification=#{classification_for(response)}"
25+
)
26+
classify_response(response)
27+
rescue Faraday::Error => e
28+
Sentry.capture_exception(e)
29+
Result.new(
30+
success?: false,
31+
status: :service_unavailable,
32+
error_code: 'subscription_provider_unavailable',
33+
message: 'Subscription provider is currently unavailable.'
34+
)
35+
end
36+
37+
private
38+
39+
attr_reader :endpoint_url
40+
41+
def faraday
42+
@faraday ||= Faraday.new do |f|
43+
f.request :url_encoded
44+
f.options.timeout = REQUEST_TIMEOUT_SECONDS
45+
f.options.open_timeout = OPEN_TIMEOUT_SECONDS
46+
end
47+
end
48+
49+
def missing_configuration_result
50+
Result.new(
51+
success?: false,
52+
status: :service_unavailable,
53+
error_code: 'subscription_provider_not_configured',
54+
message: 'Subscription provider endpoint is not configured.'
55+
)
56+
end
57+
58+
def provider_payload(form_payload)
59+
# Map internal API contract to Pardot Form Handler external field names.
60+
{
61+
'email' => form_payload['email'],
62+
'Tester' => form_payload['test_opt_in']
63+
}.compact
64+
end
65+
66+
def classify_response(response)
67+
body = response_body(response)
68+
69+
return reject_result if error_body?(body)
70+
return reject_result unless response.status == SUCCESS_STATUS_CODE
71+
return Result.new(success?: true) if success_body?(body)
72+
73+
ambiguous_result
74+
end
75+
76+
def reject_result
77+
Result.new(
78+
success?: false,
79+
status: :bad_gateway,
80+
error_code: 'subscription_provider_rejected',
81+
message: 'Subscription provider rejected the request.'
82+
)
83+
end
84+
85+
def ambiguous_result
86+
Result.new(
87+
success?: false,
88+
status: :bad_gateway,
89+
error_code: 'subscription_provider_ambiguous',
90+
message: 'Subscription provider response was ambiguous.'
91+
)
92+
end
93+
94+
def error_body?(body)
95+
ERROR_BODY_PATTERNS.any? { |pattern| body.include?(pattern) }
96+
end
97+
98+
def success_body?(body)
99+
SUCCESS_BODY_PATTERNS.any? { |pattern| body.include?(pattern) }
100+
end
101+
102+
def response_body(response)
103+
response.body.to_s.downcase
104+
end
105+
106+
def redirect_location(response)
107+
response.headers.fetch('location', '').to_s.downcase
108+
end
109+
110+
def classification_for(response)
111+
body = response_body(response)
112+
113+
return 'rejected_error_body' if error_body?(body)
114+
return 'rejected_status' unless response.status == SUCCESS_STATUS_CODE
115+
return 'accepted_success_body' if success_body?(body)
116+
117+
'ambiguous_response'
118+
end
119+
end
120+
end

config/application.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,7 @@ class Application < Rails::Application
6868
config.active_record.encryption.primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY')
6969
config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY')
7070
config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT')
71+
72+
config.x.subscriptions.pardot_form_handler_url = ENV.fetch('PARDOT_SUBSCRIPTION_URL', '')
7173
end
7274
end

config/initializers/cors.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
Rails.application.config.middleware.insert_before 0, Rack::Cors do
99
allow do
1010
# localhost and test domain origins
11-
origins(%r{https?://localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test?
11+
origins(%r{https?://([a-z0-9-]+\.)?localhost([:0-9]*)$}) if Rails.env.development? || Rails.env.test?
1212

1313
standard_cors_options
1414
end

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
resources :features, only: %i[index]
100100

101101
resources :profile_auth_check, only: %i[index]
102+
resources :subscriptions, only: %i[create]
102103
end
103104

104105
resource :github_webhooks, only: :create, defaults: { formats: :json }
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'Subscriptions API' do
6+
describe 'POST /api/subscriptions' do
7+
let(:path) { '/api/subscriptions' }
8+
let(:payload) do
9+
{
10+
subscription: {
11+
email: 'teacher@example.com',
12+
test_opt_in: true,
13+
privacy_policy: true
14+
}
15+
}
16+
end
17+
18+
let(:submitter_result_success) do
19+
Subscriptions::PardotFormHandlerSubmitter::Result.new(success?: true)
20+
end
21+
let(:submitter_result_failure) do
22+
Subscriptions::PardotFormHandlerSubmitter::Result.new(
23+
success?: false,
24+
status: :service_unavailable,
25+
error_code: 'subscription_provider_unavailable',
26+
message: 'Subscription provider is currently unavailable.'
27+
)
28+
end
29+
let(:submitter_result_rejected) do
30+
Subscriptions::PardotFormHandlerSubmitter::Result.new(
31+
success?: false,
32+
status: :bad_gateway,
33+
error_code: 'subscription_provider_rejected',
34+
message: 'Subscription provider rejected the request.'
35+
)
36+
end
37+
let(:submitter) { instance_double(Subscriptions::PardotFormHandlerSubmitter) }
38+
39+
before do
40+
allow(Subscriptions::PardotFormHandlerSubmitter).to receive(:new).and_return(submitter)
41+
allow(submitter).to receive(:call).and_return(submitter_result_success)
42+
end
43+
44+
it 'returns success for a valid payload' do
45+
post(path, params: payload, as: :json)
46+
47+
expect(response).to have_http_status(:ok)
48+
expect(response.parsed_body).to include(
49+
'ok' => true,
50+
'message' => 'Subscription accepted'
51+
)
52+
expect(submitter).to have_received(:call).with(
53+
form_payload: {
54+
'email' => 'teacher@example.com',
55+
'test_opt_in' => true,
56+
'privacy_policy' => true
57+
}
58+
)
59+
end
60+
61+
it 'returns 422 when email is missing' do
62+
post(path, params: payload.deep_merge(subscription: { email: '' }), as: :json)
63+
64+
expect(response).to have_http_status(:unprocessable_content)
65+
expect(response.parsed_body['errors']).to include('email is required')
66+
end
67+
68+
it 'returns 422 when email is malformed' do
69+
post(path, params: payload.deep_merge(subscription: { email: 'invalid-email' }), as: :json)
70+
71+
expect(response).to have_http_status(:unprocessable_content)
72+
expect(response.parsed_body['errors']).to include('email is invalid')
73+
end
74+
75+
it 'returns 422 when privacy_policy is not true' do
76+
post(path, params: payload.deep_merge(subscription: { privacy_policy: false }), as: :json)
77+
78+
expect(response).to have_http_status(:unprocessable_content)
79+
expect(response.parsed_body['errors']).to include('privacy_policy must be true')
80+
end
81+
82+
it 'returns 400 when subscription params are missing' do
83+
post(path, params: {}, as: :json)
84+
85+
expect(response).to have_http_status(:bad_request)
86+
end
87+
88+
it 'returns provider error status/message when provider submission fails' do
89+
allow(submitter).to receive(:call).and_return(submitter_result_failure)
90+
91+
post(path, params: payload, as: :json)
92+
93+
expect(response).to have_http_status(:service_unavailable)
94+
expect(response.parsed_body).to include(
95+
'ok' => false,
96+
'error_code' => 'subscription_provider_unavailable',
97+
'message' => 'Subscription provider is currently unavailable.'
98+
)
99+
end
100+
101+
it 'returns provider rejection shape when provider rejects request' do
102+
allow(submitter).to receive(:call).and_return(submitter_result_rejected)
103+
104+
post(path, params: payload, as: :json)
105+
106+
expect(response).to have_http_status(:bad_gateway)
107+
expect(response.parsed_body).to include(
108+
'ok' => false,
109+
'error_code' => 'subscription_provider_rejected',
110+
'message' => 'Subscription provider rejected the request.'
111+
)
112+
end
113+
end
114+
end

0 commit comments

Comments
 (0)