32
Rails Tutorial: How to Build a Subscription Form that Integrates with Mailchimp Part 2-(controllers and workers)
This article is part 2. You can read part 1 here.
In the first part, we built the underlying model of our contact form.
Remember, we had the following criteria:
- Capture a valid email address and name
- Ensure that users can’t see who’s also subscribed
- Sync these emails with Mailchimp
Now we’re going to round out our functionality.
Assuming your app is still running, let’s navigate to /subscribers
The first problem that we have is that users can see who else subscribed. This means a potential data breach. Luckily, we can solve this quickly.
Go to your subscribers controller and change your index action.
# app/controllers/subscribers_controller.rb
def index
@subscribers = Subscriber.all
end
Change it to the following:
# app/controllers/subscribers_controller.rb
def index
end
If you refresh the page, you should get an error.
undefined method `each' for nil:NilClass
This error has many names, nil error, null error, undefined, but it all boils down to the same thing. We tried to use the .each
method on something that doesn’t exist.
When we removed the line @subscribers = Subscriber.all
, our view did not know what to do.
To fix the error, we can remove some code.
Let’s remove the following code:
# app/views/subscribers/index.html.erb
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th colspan="3"></th>
</tr>
</thead>
<tbody>
<% @subscribers.each do |subscriber| %>
<tr>
<td><%= subscriber.name %></td>
<td><%= subscriber.email %></td>
<td><%= link_to 'Show', subscriber %></td>
<td><%= link_to 'Edit', edit_subscriber_path(subscriber) %></td>
<td><%= link_to 'Destroy', subscriber, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<% end %>
</tbody>
</table>
Now when we refresh, everything will work again.
Woohoo.
However, I’ve been leading you astray. We don’t need the index action at all. I just wanted you to experience something that can go wrong and give you the tools to fix it on your own.
So let’s remove the index action and while, we’re at it, we’ll remove some other code.
rm app/views/subscribers/index.html.erb
rm app/views/subscribers/edit.html.erb
In our controller, remove the edit action, index action, update action and destroy action.
For the moment, we will keep the new, show and create actions.
Our controller should now look like the following:
# app/controllers/subscribers_controller.rb
class SubscribersController < ApplicationController
before_action :set_subscriber, only: %i[show]
# GET /subscribers/1 or /subscribers/1.json
def show
end
# GET /subscribers/new
def new
@subscriber = Subscriber.new
end
# POST /subscribers or /subscribers.json
def create
@subscriber = Subscriber.new(subscriber_params)
respond_to do |format|
if @subscriber.save
format.html { redirect_to @subscriber, notice: "Subscriber was successfully created." }
format.json { render :show, status: :created, location: @subscriber }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @subscriber.errors, status: :unprocessable_entity }
end
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_subscriber
@subscriber = Subscriber.find(params[:id])
end
# Only allow a list of trusted parameters through.
def subscriber_params
params.require(:subscriber).permit(:name, :email)
end
end
If you navigate to /subscribers
, you will get a new error.
The action 'index' could not be found for SubscribersController
That’s because we’ve told our routes file that the index action exists, but it doesn’t. Let’s fix it.
In our routes file, change the resources method to the following:
resources :subscribers, only: %i[new create show]
Now, if you refresh, you will get a different error. This error will be a 404 Not Found instead of a 500 internal server error.
When we create a subscriber on our show page, they are being redirected to the show page.
The show page will have an error because we previously changed the routes.rb page.
To fix that error, remove the following two lines.
# app/views/subscribers/show.html.erb
<%= link_to 'Edit', edit_subscriber_path(@subscriber) %> |
<%= link_to 'Back', subscribers_path %>
Ensure that users can’t see who’s also subscribed
In the browser, you’ll notice that after each subscriber, the number after the URL gets incremented by 1.
This is another security flaw and, once again, violates the original criteria we laid out.
A nefarious user might be able to get everyone’s details by going from 1 to n.
Let’s fix that by removing that show action similar to how we removed the other actions.
In our routes file, change the resources method to the following:
resources :subscribers, only: %i[new create]
In our controller, remove the show method and anything that has to do with finding a subscriber so you can ensure better security.
# app/controllers/subscribers_controller.rb
class SubscribersController < ApplicationController
# GET /subscribers/new
def new
@subscriber = Subscriber.new
end
# POST /subscribers or /subscribers.json
def create
@subscriber = Subscriber.new(subscriber_params)
respond_to do |format|
if @subscriber.save
format.html { redirect_to @subscriber, notice: "Subscriber was successfully created." }
format.json { render :show, status: :created, location: @subscriber }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @subscriber.errors, status: :unprocessable_entity }
end
end
end
private
# Only allow a list of trusted parameters through.
def subscriber_params
params.require(:subscriber).permit(:name, :email)
end
end
However, we’ve now created a bug.
When we created a subscriber, it gets redirected to a broken route.
Let’s make sure that we fix this the right way using tests. Rails has already helped us a lot by creating a controller test file. We’ll use this to get us started on writing a test.
Rails has generated some tests that we don’t need. Let’s get rid of them first.
In test/controllers/subscribers_controller_test.rb, remove the tests that are no longer relevant. These are the tests for the new action, show action, destroy and edit.
Your test/controllers/subscribers_controller_test.rb should now look like this:
# test/controllers/subscribers_controller_test.rb
require "test_helper"
class SubscribersControllerTest < ActionDispatch::IntegrationTest
setup do
@subscriber = subscribers(:one)
end
test "should get new" do
get new_subscriber_url
assert_response :success
end
test "should create subscriber" do
assert_difference('Subscriber.count') do
post subscribers_url, params: { subscriber: { email: @subscriber.email, name: @subscriber.name } }
end
assert_redirected_to subscriber_url(Subscriber.last)
end
end
When you run the tests, you will notice that you will get an error.
Run the following command:
rails test test/controllers/subscribers_controller_test.rb
Your output will look like this:
Running via Spring preloader in process 58423
Run options: --seed 48833
# Running:
.F
Failure:
SubscribersControllerTest#test_should_create_subscriber [/Users/williamkennedy/projects/teaching_rails/subscriber_app/test/controllers/subscribers_controller_test.rb:14]:
"Subscriber.count" didn't change by 1.
Expected: 3
Actual: 2
rails test test/controllers/subscribers_controller_test.rb:13
Finished in 0.306320s, 6.5291 runs/s, 6.5291 assertions/s.
2 runs, 2 assertions, 1 failures, 0 errors, 0 skips
We are not creating a unique subscriber email. Let’s fix that by changing our test.
Change the parameters in the test to the following.
test "should create subscriber" do
assert_difference('Subscriber.count') do
post subscribers_url, params: { subscriber: { email: "[email protected]", name: @subscriber.name } }
end
assert_redirected_to subscriber_url(Subscriber.last)
end
Rerun tests, and you’ll see that we will get a new error.
Error:
SubscribersControllerTest#test_should_create_subscriber:
NoMethodError: undefined method `subscriber_url' for #<SubscribersController:0x000000000088e0>
Did you mean? subscribers_url
app/controllers/subscribers_controller.rb:14:in `block (2 levels) in create'
app/controllers/subscribers_controller.rb:12:in `create'
test/controllers/subscribers_controller_test.rb:15:in `block (2 levels) in <class:SubscribersControllerTest>'
test/controllers/subscribers_controller_test.rb:14:in `block in <class:SubscribersControllerTest>'
Can you guess what’s going wrong here before you proceed?
Simply put, we have to correct our tests and our controller. So let’s do that.
First of all, in our config/routes.rb file:
resources :subscribers, only: %i[new create] do
collection do
get :thank_you
end
end
Next, let’s see what routes are generated.
rails routes | grep subscriber
thank_you_subscribers GET /subscribers/thank_you(.:format) subscribers#thank_you
subscribers POST /subscribers(.:format) subscribers#create
new_subscriber GET /subscribers/new(.:format) subscribers#new
Now let’s update our tests.
# test/controllers/subscribers_controller_test.rb
assert_redirected_to thank_you_subscribers_path
Now run the tests for the controller.
rails test test/controllers/subscribers_controller_test.rb
Notice we have a failing test. It’s telling us what line is failing.
Let’s update our controller and rerun the tests.
Now run the tests.
rails test test/controllers/subscribers_controller_test.rb
It looks like we are still getting an error.
To fix this, let’s add our thank you page file.
# app/controllers/subscribers_controllers.rb
class SubscribersController < ApplicationController
# POST /subscribers or /subscribers.json
def create
@subscriber = Subscriber.new(subscriber_params)
respond_to do |format|
if @subscriber.save
format.html { redirect_to thank_you_subscribers_path, notice: "Subscriber was successfully created." }
format.json { render :show, status: :created, location: @subscriber }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @subscriber.errors, status: :unprocessable_entity }
end
end
end
def thank_you
end
end
Finally, let’s add one more test.
test "should get thank_you" do
get thank_you_subscribers_path
assert_response :success
end
To make this pass, we need to create a thank you page.
touch app/views/thank_you.html.erb
So now we have the perfect architecture to start accepting email addresses and sending them to Mailchimp.
This is the exciting part. We now send it to Mailchimp.
To communicate with 3rd parties, we generally use an API. This involves writing code that communicates with Mailchimp.
We aimed to send each subscriber over to Mailchimp. So let’s discuss how we will do that.
- Get a Mailchimp API key
- Write the code that sends the subscriber information over to Mailchimp
- Ensure this code is run in the background as much as possible
Head over to the Mailchimp website and get an API key.
Next, we will install a gem that will do all the Mailchimp work for us.
bundle add gibbon
Finally, because we want the app to be efficient, we will create a background job that sends over the subscriber information using the Gibbon gem.
Let’s set up our background jobs.
Follow the guidelines laid out by sidekiq.
After we have added sidekiq, let’s send over the subscriber to mail chimp after they create the following:
app/workers/mailchimp_subscribe_worker.rb
test/workers/mailchimp_subscribe_worker_test.rb
In our app/workers/mailchimp_subscriber_workers.rb, do the following:
# app/workers/mailchimp_subscriber_worker.rb
class MailchimpSubscribeWorker
include Sidekiq::Worker
def perform(subscriber_id)
gibbon = Gibbon::Request.new(api_key: "API_KEY")
subscriber = Subscriber.find subscriber_id
gibbon.lists("YOUR_LIST_ID").members.create(body: {email_address: subscriber.email, status: "subscribed", merge_fields: {FNAME: subscriber.name, LNAME: ""}})
end
end
In your controller, you can now add the worker to your create action to ensure that the worker runs after the instance variable calls save.
# app/controllers/subscribers_controller.rb
# POST /subscribers or /subscribers.json
def create
@subscriber = Subscriber.new(subscriber_params)
respond_to do |format|
if @subscriber.save
MailchimpSubscribeWorker.perform_async(@subscriber.id)
format.html { redirect_to thank_you_subscribers_path, notice: "Subscriber was successfully created." }
format.json { render :show, status: :created, location: @subscriber }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @subscriber.errors, status: :unprocessable_entity }
end
end
end
This approach can lead to a race condition. Instead, I would recommend putting this in a callback.
We now have a working proof of concept for adding subscribers to our app then sending it to Mailchimp.
However, we have loads of work still to do.
My hope with writing this in-depth tutorial was to go beyond the basics and explain the different decisions that can crop up in the real world. Many tutorials explain how to do something but don’t explain why we do things the way we do them.
In this article, we’ve covered test, security concerns, and we even have enough room to improve.
- How do we ensure a person does not subscribe twice or three times?
- Our worker depends on the subscriber created in the database. What happens if the worker runs before the subscriber gets saved to the database?
- We are only saving the name, but Mailchimp expects first name and second name? Can we improve our original design?
- Is our worker idempotent?
- How do we secure our Mailchimp API key so it is not available in the source code?
- How did I get list_id using the Gibbon gem?_
In the real world, edge cases come up and can affect 1000’s people. If this article is popular, I’ll add a 3rd part that involves different edge cases that we could encounter.
We didn’t even cover making our app look nice. I’ll leave that to you.
Source code can be found here.
32