-
Notifications
You must be signed in to change notification settings - Fork 5
1272: wire up form to pardot - explore using form handler #800
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
33df257
feat: establish subscription endpoint
DNR500 e9b1852
feat: set up form handler submission to pardot
DNR500 9434509
feat: lock down success and log
DNR500 72dc376
feat: update the logging
DNR500 ae0e406
feat: conform the payload we send to form handler
DNR500 cae0dad
feat: adapt to how form handler reports success and error
DNR500 d411b62
test: update the tests
DNR500 01ebdc9
Merge branch 'main' into 1272-wire-up-form-to-pardot--form-handler
cocomarine File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| 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 | ||
|
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
120
app/services/subscriptions/pardot_form_handler_submitter.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.