Rails Tutorial: How to Build a Subscription Form that Integrates with Mailchimp Part 2-(controllers and workers)

alt text Photo by Phil Goodwin on Unsplash

This article is part 2. You can read part 1 here.

4. Exploring the controller

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.

5. Making our application secure

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.

6. Creating our Thank You page

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: "test@test.ie", 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

7. Sending to Mailchimp

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.

  1. Get a Mailchimp API key
  2. Write the code that sends the subscriber information over to Mailchimp
  3. 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.

Conclusion

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.

© 2021 William Kennedy, Inc. All rights reserved.