Testing JSON APIs in Rails

By now the Cookbook app has a full HTML cycle: CRUD, nested forms, Turbo, auth, ownership, mailers, and background jobs. This stack works well for humans who click links and submit forms. But it is a poor fit for mobile apps, scripts, and AI agents who expect JSON responses instead of HTML.

So in this chapter you will add a versioned JSON API under /api/v1 and open up the Cookbook app to mobile apps and AI agents where they can view (list/show) recipes without authentication while needing authentication with a Bearer token to create, update, and delete recipes.

What you will do in this chapter #

  1. Put API tests under test/controllers/api/v1/ and assert status plus response body.
  2. Mount routes under /api/v1 from day one so clients get a stable version prefix.
  3. TDD guest viewing first (index, show, quick filter, JSON media type), then login with Bearer token, revoke, and rate-limited sessions.
  4. Wire Bearer auth on ApiController for authenticating APIs, then put those helpers on ApiTestCase for authenticating API tests.
  5. TDD create (including nested records), update, and destroy.
  6. Mirror HTML share and export/download over the API.
  7. Add authorization tests in a separate access file. Guest 401s cover create, update, destroy, export, and download; share stays public while non-owner 403s cover update and destroy.
  8. Try important APIs with curl, then read about the tradeoffs we skipped from this chapter on purpose.
  9. Run bin/rails test:all to ensure all tests are passing and commit.

Scenarios to automate #

Scenario Status Parsed body / side effect
Guest can list recipes 200 Includes pancake title under recipes; response media type is application/json
Guest can filter to quick recipes 200 Only recipes with prep time under 30 minutes
Guest can show a recipe 200 recipe.title matches; response contains expected JSON keys
Guest shows a missing recipe 404 “Not found” in error message
User with valid credentials can login 201 token present
User cannot login with a bad password 401 Error message “Invalid email or password”
Revoke token (logout) 204 Token expires and reusing it again for another request gets 401 (unauthenticated)
Rate limit blocks API login after 10 attempts 429 Error message “Try again later.”
User can create a recipe 201 signed-in Alice, recipe.title set; count +1
User cannot create a recipe with a blank title 422 signed-in Alice, errors present; count unchanged
User can create a recipe with nested ingredient and step 201 signed-in Alice, ingredient and step counts each +1
User can update their own recipe 200 signed-in Alice, title changed in body
User cannot update a recipe with a blank title 422 signed-in Alice, errors present; title unchanged
User can destroy their own recipe 204 signed-in Alice, count decreases by 1; empty body
Signed-in user can share a recipe by email 200 RecipeMailer#share enqueued with Alice’s email as sender
Guest can share a recipe by email 200 RecipeMailer#share enqueued with nil sender
User can enqueue a recipes export 200 ExportRecipesJob enqueued for Alice
User cannot download when export is missing 404 No attachment yet
User can download exported recipes 200 signed-in Alice, body includes pancake title; Content-Disposition is “attachment”
Guest cannot create a recipe 401 Count unchanged
Guest cannot edit a recipe 401 Title unchanged
Guest cannot destroy a recipe 401 Count unchanged
Guest cannot export recipes 401 Job not enqueued
Guest cannot download an export 401 Guest GET download → 401
Non-owner cannot update another user’s recipe 403 signed-in Bob tries to change Alice’s recipe; title unchanged
Non-owner cannot destroy another user’s recipe 403 signed-in Bob tries to destroy Alice’s recipe; count unchanged

API versioning from day one #

One of the mistakes I see in many projects is not maintaining “versioning” in the API. When the team start designing the API, they just put everything under /api/ instead of /api/v1/. An unversioned /api/ looks tidy on day one but the pain starts surfacing when you rename a field or change an error shape in the existing API. The consequence on the side of the API client is severe because they depend on your old API. And when you suddenly update it to support what it never intended to do in the first place, the API breaks for all of them.

Here are the main reasons why you should version your API:

  1. Backward compatibility – You can change the API without breaking existing clients.
  2. Room for growth – Software is ever changing and you need to be able to evolve the API without breaking existing clients.
  3. Clear contracts for your clients – Each version defines a clear contract for the client to follow. When they integrate with the API, they know what to expect from the server for that version.

Today you ship v1 and later when/if you need to change the API, you can add v2 routes beside it. This way, existing clients can continue to use the old API while new clients can use the new API. This also gives existing clients time to migrate to the new API and access new features or changes.

This chapter does not implement v2 though, it is to keep the focus on the testing of the core API features instead of the versioning details.

When the API routes are complete later in this chapter, they will look like the following:

# config/routes.rb
namespace :api do
  namespace :v1 do
    resources :sessions, only: :create
    resource :session, only: :destroy

    resources :recipes do
      post :share, on: :member
      post :export, on: :collection
      get :download, on: :collection
    end
  end
end

This will open up route helpers like api_v1_recipes_url, share_api_v1_recipe_url, and download_api_v1_recipes_url that you can use in your app and tests.

Where the tests live #

APIs mostly only touch controllers part of the request/response cycle. For such code, you have learned to test them inside test/integration/ instead of test/controllers/. In Chapter 5, I even asked you to leave and forget about the controller tests generated by Rails scaffold.

But with the introduction of the API, that changes and you can’t abandon test/controllers/ like you have done till this point. This is because API tests are more focused on the controller actions and the JSON response than the full HTTP stack (e.g. view rendering, redirects, flash messages, etc.) sp they are suitable to be tested in test/controllers/ while the tests for full flow stay in test/integration/.

This guide puts API files under app/controllers/api/v1/ and tests under test/controllers/api/v1/ because of the API versioning we discussed earlier. API tests still inherit from ActionDispatch::IntegrationTest; the same class used by existing integration tests as that’s how Rails sets up controller tests by default as well.

Why not dump these into existing integration test files (e.g. test/integration/recipes_integration_test.rb)?

  • Integration test file is the browser and form source of truth (Turbo, nested attributes, redirects, flash).
  • API clients send Authorization: Bearer … instead of a signed session cookie so they need different authentication mechanisms/helpers.
  • API paths live under /api/v1, so helpers and failure messages stay clearer in their own files.
  • A mega-file that mixes HTML and JSON gets hard to skim.

All in all, mixing API and non-API tests in the same file will only introduce more complexity and confusion to tests.

User can view records (index & show actions) #

In the Cookbook app, anyone can view the list and details of a recipe which means these actions won’t need the new Bearer token flow for the authentication. A perfect first set of scenarios to start with API testing.

What counts as working? #

Flow You might say
Guest list GET /api/v1/recipes returns 200 and includes the pancake title
Quick filter GET /api/v1/recipes?quick=1 returns only quick recipes (prep_time under 30 minutes)
Guest show GET /api/v1/recipes/:id returns 200 for pancakes with expected JSON keys in the response body
Missing record on show Unknown recipe ID returns 404 with error "Not found" in the response body

Add scenarios to the test file #

Create a test file under api/v1 with mkdir -p test/controllers/api/v1/ && nano test/controllers/api/v1/recipes_controller_test.rb and add the following scenarios:

# test/controllers/api/v1/recipes_controller_test.rb
require "test_helper"

class Api::V1::RecipesControllerTest < ActionDispatch::IntegrationTest
  # Actor: guest (API client)
  # Starting point: recipes(:pancakes) in fixtures
  # Action: GET /api/v1/recipes as JSON
  # Expected outcome: 200; pancakes title in parsed body; JSON media type
  # test "lists recipes" do
  # end

  # Actor: guest
  # Starting point: recipes(:pancakes) is quick; recipes(:pizza) is not
  # Action: GET index with quick=1
  # Expected outcome: 200; only quick titles
  # test "filters quick recipes" do
  # end

  # Actor: guest
  # Starting point: recipes(:pancakes)
  # Action: GET show as JSON
  # Expected outcome: 200; title matches; id/title/prep_time keys present
  # test "shows a recipe" do
  # end

  # Actor: guest
  # Starting point: no recipe with id 0
  # Action: GET show for a missing id
  # Expected outcome: 404; error message "Not found"
  # test "shows not found for a missing recipe" do
  # end
end

mkdir -p tells the command to create the directory if it doesn’t exist yet and only then create the file inside it. If you don’t use -p, you will get an error saying the directory doesn’t exist.

Red: add tests for viewing recipes #

Replace the test block of each scenario with the following:

# test/controllers/api/v1/recipes_controller_test.rb
require "test_helper"

class Api::V1::RecipesControllerTest < ActionDispatch::IntegrationTest
  test "lists recipes" do
    get api_v1_recipes_url, as: :json

    assert_response :success
    assert_equal "application/json", response.media_type

    titles = response.parsed_body.fetch("recipes").map { _1["title"] }
    assert_includes titles, recipes(:pancakes).title
  end

  test "filters quick recipes" do
    get api_v1_recipes_url, params: { quick: "1" }, as: :json

    assert_response :success
    titles = response.parsed_body.fetch("recipes").map { _1["title"] }
    assert_includes titles, recipes(:pancakes).title
    assert_not_includes titles, recipes(:pizza).title
  end

  test "shows a recipe" do
    get api_v1_recipe_url(recipes(:pancakes)), as: :json

    assert_response :success
    recipe = response.parsed_body.fetch("recipe")
    assert_equal recipes(:pancakes).title, recipe["title"]
    assert_equal %w[id ingredients prep_time steps title].sort, recipe.keys.sort
  end

  test "shows not found for a missing recipe" do
    get api_v1_recipe_url(id: 0), as: :json

    assert_response :not_found
    assert_equal "Not found", response.parsed_body["error"]
  end
end

This is what’s happening in the code above:

  • Index and show pages are accessible by guests (everyone in the app), same as their HTML pages counterparts.
  • quick=1 reuses the same Recipe.quick scope as the HTML filter in Chapter 8. Due to that, Pancakes (15 minutes) match and should be included in the response while Pizza (60 minutes) does not.
  • shows a recipe asserts the JSON keys (id, title, prep_time, plus nested ingredients and steps). That is normally ensured by a schema gem but we are not using one for this chapter. We will discuss more on this decision in the tradeoffs section at the end of the chapter.
  • Missing show expects 404 ("Not found") in the JSON error field.

Run the test:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

You should see an error, the test will not even execute:

uninitialized constant Api (NameError)

class Api::V1::RecipesControllerTest < ActionDispatch::IntegrationTest

This is because the Api::V1::RecipesController does’t exist yet, create a blank controller for now with mkdir -p app/controllers/api/v1/ && touch app/controllers/api/v1/recipes_controller.rb so the test can run.

Run the test again:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

You should see all tests failing with a similar error:

E

Error:
Api::V1::RecipesControllerTest#test_lists_recipes:
NoMethodError: undefined method 'api_v1_recipes_url' for an instance of Api::V1::RecipesControllerTest

That is a red for the view recipe API. The error is because of the missing route due to which the helper api_v1_recipes_url is not found.

Green: API base controller and recipe routes #

Add API routes for recipes under the api/v1 namespace at the bottom of the file:

# config/routes.rb
Rails.application.routes.draw do
  # ... existing routes ...

  namespace :api do
    namespace :v1 do
      resources :recipes
    end
  end
end

With these routes in place, you can now use the api_v1_recipes_url helper in the test file. Also note that you are using the resources :recipes shorthand to create all the CRUD routes for the RecipesController at once instead of using only: %i[index show] which would have been enough for this section. This is to avoid the repetition of the missing route error for every test, it’s enough to see and fix this error just once.

API acts different than regular request/response cycle in many cases so you will also need to create a new base controller for it instead of using existing ApplicationController. This new base controller will be used by all API controllers and will handle common things like authentication, authorization and catch-all errors (e.g. 404 not found / 403 forbidden).

Create a new file with nano app/controllers/api_controller.rb and add the following content:

# app/controllers/api_controller.rb
class ApiController < ActionController::API
  rescue_from ActiveRecord::RecordNotFound, with: :deny_not_found

  private

  def deny_not_found(_exception)
    render json: { error: "Not found" }, status: :not_found
  end
end

This is what’s happening in the code above:

  • You create a new base controller ApiController that inherits from ActionController::API instead of ApplicationController. Base controller lives inside app/controllers/ instead of app/controllers/api/v1/ because base code rarely changes and doesn’t need to be versioned.
  • rescue_from ActiveRecord::RecordNotFound rescues all exceptions related to ActiveRecord not finding the record in the database and returns a 404 JSON response with "Not found" error message. This will be useful for the show action of RecipesController in a while.

You already created the app/controllers/api/v1/recipes_controller.rb to fix the test errors, now update the controller to include index and show actions and inherit from the new ApiController:

# app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApiController
  before_action :set_recipe, only: :show

  def index
    recipes = params[:quick] == "1" ? Recipe.quick : Recipe.all

    render json: { recipes: recipes.as_json(only: %i[id title prep_time]) }
  end

  def show
    render json: {
             recipe:
               @recipe.as_json(
                 only: %i[id title prep_time],
                 include: {
                   ingredients: {
                     only: %i[name quantity unit]
                   },
                   steps: {
                     only: %i[position instruction]
                   }
                 }
               )
           }
  end

  private

  def set_recipe
    @recipe = Recipe.find(params[:id])
  end
end

This is what’s happening in the code above:

  • Both index and show actions have similar code as the normal controller actions. The only difference is the response format: JSON instead of HTML.
  • index action uses the quick parameter to filter the recipes. It returns the full list if the filter parameter is not passed.
  • show action finds the recipe by the id parameter and returns the recipe as JSON. If the recipe is not found, find raises an ActiveRecord::RecordNotFound exception which is rescued by the rescue_from block in the ApiController.
  • Both actions use the as_json method to serialize the recipe to JSON. The only option is used to specify the fields to include in the JSON response so the response only sends columns required by clients.

Run the recipe API tests again, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

Authenticating API clients #

Before you move on to any other features/tests, you need to add authentication to the API as all other features require users to be signed in to the app. The normal authentication we’ve been using for the web app relies on a session cookie. API clients can use cookies too but they don’t necessarily persist and resend them automatically like a browser does. For an API, it’s often more convenient to use a token-based authentication.

Enters bearer token, it’s a random string shown once in the login response, with only a digest (encrypted string) stored in the database. This token is associated with the user and is used to authenticate subsequent requests by sending it in the Authorization header e.g. Authorization: Bearer <token>.

For logging out the user, you can simply call a DELETE request on the sessions resource with that Bearer header also known popularly as revoking a token.

In production applications, API authentication can be implemented using JWTs (JSON Web Token) or opaque bearer tokens, depending on the application’s requirements. To keep this chapter simple and focused on API testing, you won’t use an authentication gem. Instead you’ll build a simple ApiToken model that stores a digest of the token and the user it belongs to, then add the authentication flow needed to authenticate users for API requests.

This gives us everything we need to demonstrate the API authentication and testing patterns without introducing an additional authentication library.

What counts as working? #

Flow You might say
Valid login POST /api/v1/sessions with Alice’s email and "password" returns 201 and a token string
Bad password Same path with a wrong password returns 401 and error "Invalid email or password"
Revoke token (logout) DELETE /api/v1/session with Bearer returns 204; a later write with that token returns 401 and error "Invalid token"
Rate limited login After 10 POST /api/v1/sessions within 3 minutes, the next POST returns 429 and error "Try again later."

Add scenarios to the test file #

Create a test file for the sessions controller at test/controllers/api/v1/sessions_controller_test.rb with the following scenarios:

# test/controllers/api/v1/sessions_controller_test.rb
require "test_helper"

class Api::V1::SessionsControllerTest < ActionDispatch::IntegrationTest
  # Actor: Alice (API client)
  # Starting point: users(:alice), password "password" from fixtures
  # Action: POST /api/v1/sessions as JSON with email and password
  # Expected outcome: 201; parsed body has a non-blank token
  # test "logs in with valid credentials" do
  # end

  # Actor: Alice with a wrong password
  # Starting point: users(:alice)
  # Action: POST /api/v1/sessions with a bad password
  # Expected outcome: 401; error "Invalid email or password"
  # test "rejects login with a bad password" do
  # end

  # Actor: Alice with a live token
  # Starting point: token from POST sessions
  # Action: DELETE /api/v1/session with Bearer, then POST recipes with same token
  # Expected outcome: revoke 204; create 401 with error "Invalid token"
  # test "revokes the bearer token" do
  # end

  # Actor: attacker hammering API login
  # Starting point: rate limit counter empty
  # Action: POST /api/v1/sessions 11 times with a bad password
  # Expected outcome: 11th response is 429 with error "Try again later."
  # test "rate limits login attempts" do
  # end
end

Red: add the session tests #

# test/controllers/api/v1/sessions_controller_test.rb
require "test_helper"

class Api::V1::SessionsControllerTest < ActionDispatch::IntegrationTest
  test "logs in with valid credentials" do
    post api_v1_sessions_url,
         params: {
           email_address: users(:alice).email_address,
           password: "password"
         },
         as: :json

    assert_response :created
    assert response.parsed_body["token"].present?
    assert_equal users(:alice).email_address,
                 response.parsed_body.dig("user", "email_address")
  end

  test "rejects login with a bad password" do
    post api_v1_sessions_url,
         params: {
           email_address: users(:alice).email_address,
           password: "wrong-password"
         },
         as: :json

    assert_response :unauthorized
    assert_equal "Invalid email or password", response.parsed_body["error"]
  end

  test "revokes the bearer token" do
    post api_v1_sessions_url,
         params: {
           email_address: users(:alice).email_address,
           password: "password"
         },
         as: :json

    token = response.parsed_body.fetch("token")
    headers = { "Authorization" => "Bearer #{token}" }

    delete api_v1_session_url, headers: headers, as: :json
    assert_response :no_content

    # assert_no_difference "Recipe.count" do
    #   post api_v1_recipes_url,
    #        params: {
    #          recipe: {
    #            title: "After revoke"
    #          }
    #        },
    #        headers: headers,
    #        as: :json
    # end
    # assert_response :unauthorized
    # assert_equal "Invalid token", response.parsed_body["error"]
  end

  test "rate limits login attempts" do
    Rails.cache.clear

    10.times do
      post api_v1_sessions_url,
           params: {
             email_address: users(:alice).email_address,
             password: "wrong-password"
           },
           as: :json
    end

    post api_v1_sessions_url,
         params: {
           email_address: users(:alice).email_address,
           password: "wrong-password"
         },
         as: :json

    assert_response :too_many_requests
    assert_equal "Try again later.", response.parsed_body["error"]

    Rails.cache.clear
  end
end

This is what’s happening in the code above:

  1. test “logs in with valid credentials”

    • For login, the password is "password". That string comes from user fixture at test/fixtures/users.yml generated by the authentication generator in Chapter 12.
    • Valid login only checks that a token string came back with the correct email address of the user who logged in.
  2. test “rejects login with a bad password”

    • Bad password returns 401 status code with "Invalid email or password" error message indicating the authentication failed.
  3. test “revokes the bearer token”

    • Revoke invalidates the token so API client can no longer use that token to authenticate requests. The HTTP verb is DELETE on /api/v1/session. After revoke, the same Bearer string must fail writes with 401 and "Invalid token" (commented code). You do not need a separate “invalid bearer token” create example. Revoke is the proof that a dead token cannot write.
    • The revoke example posts a recipe after destroy to prove the token is dead and can’t authenticate the user anymore. The API for the recipe creation has not been added yet so the code is commented out for now. You will uncomment it later when you add the recipe creation API and run these tests again to ensure the full revoke flow is working as expected.
  4. test “rate limits login attempts”

    • Rate limit clears the cache to ensure the login limit counter is reset in each new test run, then hits create eleven times. The first ten get normal 401 responses. The eleventh returns 429 with "Try again later.". This matches the HTML sign-in brake you already tested in Chapter 12. You need config.cache_store = :memory_store in config/environments/test.rb for the counter to stick (Chapter 12 already set that up).
    • You need to clear the cache both at the beginning and the end of the test to ensure other tests are not affected by the rate limit counter. If you don’t do this and if the rate limit test runs first then it will lead to failure in other tests since the login counter limit will be reached and subsequent login attempts will fail.

Run the sessions tests:

bin/rails test test/controllers/api/v1/sessions_controller_test.rb

You should see an error because the api_v1_sessions_url helper (route for the sessions controller) is not defined yet:

E

Error:
Api::V1::SessionsControllerTest#test_logs_in_with_valid_credentials:
NameError: undefined local variable or method 'api_v1_sessions_url' for an instance of Api::V1::SessionsControllerTest

Once you fix the error by adding routes, you will also get more errors with the missing controller and database table/s required to support the full authentication flow. You will fix these all errors at once before running the tests again.

Refactor: ApiToken model and migration #

First of all, you will require a database table to store the tokens. For our app you will do that by creating a new model and migration for the ApiToken. This new model will be used to store the token and the user it belongs to.

Generate the model for the ApiToken, then migrate. Skip the fixture and model test, you will add those later manually:

bin/rails generate model ApiToken user:references token_digest:string:uniq --skip-fixture --no-test-framework
bin/rails db:migrate

The database can now store the token but you still need to add the logic to generate the token during the login process.

Open the app/models/api_token.rb file and add the logic to generate the token plus few other methods to authenticate the token and find the user it belongs to. Replace the whole file with the following content:

# app/models/api_token.rb
class ApiToken < ApplicationRecord
  belongs_to :user

  attr_accessor :raw_token

  before_validation :generate_token, on: :create

  def self.authenticate(raw_token)
    return if raw_token.blank?

    find_by(token_digest: digest(raw_token))
  end

  def self.digest(raw_token)
    Digest::SHA256.hexdigest(raw_token)
  end

  private

  def generate_token
    self.raw_token = SecureRandom.hex(32)
    self.token_digest = self.class.digest(raw_token)
  end
end

This is what’s happening in the code above:

  • generate_token generates a new token and stores the digest in the database.
  • authenticate finds the token by its digest in the database.
  • digest generates a SHA-256 hash of the token.
  • before_validation :generate_token, on: :create generates a new token and stores the digest in the database before the record is created. This ensures the token is always generated automatically during the login process.

You need to now update the User model to include the association for the ApiToken model. This is required later to create a new token for the valid user with user.api_tokens.create!. Add has_many :api_tokens just below the has_many :sessions line at app/models/user.rb:

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  has_many :api_tokens, dependent: :destroy
  # ... existing code ...
end

Finally, to access the logged in user throughout the controller and policy files you will also need to update the Current to hold the api_token so the api_user can be accessed from API sessions similar to how the user is accessed for the cookie session.

Replace the app/models/current.rb file with the following content:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :session
  attribute :api_token
  delegate :user, to: :session, allow_nil: true

  def api_user
    api_token&.user
  end
end

This is what’s happening in the code above:

  • attribute :api_token is a new attribute to hold the ApiToken record to retrieve the logged in user.
  • api_user reads the user off Current.api_token so policies and API controllers can call current_user the same way they do for non-API requests.

Green: sessions routes, API authentication concern, and sessions controller #

Sessions routes #

Update the routes file to include sessions routes for login and logout. Replace the whole api/v1 namespace with the following content:

# config/routes.rb
Rails.application.routes.draw do
  # ... existing routes ...

  namespace :api do
    namespace :v1 do
      resources :sessions, only: :create
      resource :session, only: :destroy

      resources :recipes
    end
  end
end

This is what’s happening in the code above:

  • resources :sessions, only: :create creates a plural route for user login (POST /api/v1/sessions).
  • resource :session, only: :destroy creates a singular route for user logout/token revoke (DELETE /api/v1/session). This is so that the token ID is not needed in the URL. The token is instead identified from the Bearer header (Authorization: Bearer <token>).

API authentication concern #

Since the authentication flow for the API acts differently than the normal authentication flow in the app, you will create a different Authentication concern for the API. This will help in keeping the existing app/controllers/concerns/authentication.rb clean and focused on the normal authentication flow instead of adding API specific logic to it.

Create a new file with nano app/controllers/concerns/api_authentication.rb and add the following content:

# app/controllers/concerns/api_authentication.rb
module ApiAuthentication
  extend ActiveSupport::Concern

  included { before_action :require_api_authentication }

  class_methods do
    def allow_unauthenticated_access(**options)
      skip_before_action :require_api_authentication, **options
    end
  end

  private

  def authenticated?
    resume_api_session
  end

  def current_user
    return nil unless authenticated?
    return @current_user if defined?(@current_user)

    @current_user = Current.api_user
  end

  def require_api_authentication
    resume_api_session || request_api_authentication
  end

  def resume_api_session
    return true if Current.api_token.present?

    api_token = ApiToken.authenticate(bearer_token)
    return false unless api_token

    Current.api_token = api_token

    true
  end

  def bearer_token
    header = request.authorization.to_s
    return unless header.match?(/\ABearer /i)

    header.split(" ", 2).last
  end

  def request_api_authentication
    render json: { error: "Invalid token" }, status: :unauthorized
  end
end

This is what’s happening in the code above:

  • included { before_action :require_api_authentication } ensures every API controller authenticates the user before processing the request.
  • class_methods do defines a class method allow_unauthenticated_access that can be used to skip the authentication logic for certain actions e.g. recipe index and show actions.
  • authenticated? checks if the user is authenticated by checking the presence of api_token in the Current object.
  • current_user returns the logged in user from the Current object. This is good to have method to access the logged in user throughout the controller and policy files similar to how you do it for normal authentication flow in the app.

Now, tell the ApiController to include the new ApiAuthentication concern so every request goes through the authentication gate first.

Add include ApiAuthentication just below the ActionController::API line in app/controllers/api_controller.rb:

# app/controllers/api_controller.rb
class ApiController < ActionController::API
  include ApiAuthentication

  # ... existing code ...
end

Sessions controller #

Finally, add the sessions controller that will handle the login and logout requests.

Create a new file with nano app/controllers/api/v1/sessions_controller.rb and add the following content:

# app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApiController
  allow_unauthenticated_access only: :create
  rate_limit to: 10,
             within: 3.minutes,
             only: :create,
             with: -> do
               render json: {
                        error: "Try again later."
                      },
                      status: :too_many_requests
             end

  def create
    if (user = User.authenticate_by(session_params))
      api_token = user.api_tokens.create!
      render json: {
               token: api_token.raw_token,
               user: {
                 id: user.id,
                 email_address: user.email_address
               }
             },
             status: :created
    else
      render json: { error: "Invalid email or password" }, status: :unauthorized
    end
  end

  def destroy
    Current.api_token.destroy!

    head :no_content
  end

  private

  def session_params
    params.permit(:email_address, :password)
  end
end

This is what’s happening in the code above:

  • allow_unauthenticated_access only: :create skips the authentication logic for the create action so the user can login without a token.
  • rate_limit to: 10, within: 3.minutes, only: :create caps login attempts the same way HTML sign-in does in Chapter 12. The with: block returns error status code 429 unlike the redirect in non-API login process.
  • create action checks the email and password with User.authenticate_by (same as Chapter 12), then creates a new ApiToken for the user and returns the token in the response along with the user’s id and email address. API clients can now use this token to authenticate subsequent requests.
  • destroy action deletes (revokes) the ApiToken for the current user and returns a 204 status code.

Run the session tests, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/sessions_controller_test.rb

If the test is still failing, especially at rate limits login attempts, you might be missing the configuration for the cache store, it should use memory_store instead of the default null_store (see Chapter 12).

AI agents can now login to your app!

You wrote the sessions test first, watched them fail, then shipped the full API authentication flow with token revocation. If that red-to-green loop is paying off, consider supporting this guide by sponsoring it or buying me a drink.

Fix failing view tests #

If you run the API tests for the recipes controller right now, they will fail because API controller now requires authentication for all requests by default.

Run the recipe API tests to see the errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

You should see multiple failures, all with the 401 Unauthorized response:

Expected response to be a <2XX: success>, but was a <401: Unauthorized>
Response body: {"error":"Invalid token"}

Update the app/controllers/api/v1/recipes_controller.rb file to allow unauthenticated access for the index and show actions. Add allow_unauthenticated_access just below the controller class definition:

# app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApiController
  allow_unauthenticated_access only: %i[index show]
  # ... existing code ...
end

Run the recipe API tests again, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

Add an API auth helper #

Before you move onto features that modify a recipe, you will need a helper method to login the user to the app without actually having to go through the login flow every time similar to how you do it for non-API tests using the sign_in_as helper.

For the API tests you will create an ApiToken for a fixture user and return the Authorization header with a bearer token so that header can be used for all tests that require authentication.

Create a new file with nano test/test_helpers/api_test_helper.rb and add the following content:

# test/test_helpers/api_test_helper.rb
module ApiTestHelper
  def bearer_token_for(user)
    api_token = user.api_tokens.create!

    { "Authorization" => "Bearer #{api_token.raw_token}" }
  end
end

bearer_token_for method creates a new ApiToken record for the user and returns the Authorization header so, that header can be used for all tests that require authentication.

In tests you can call this helper method inside the headers: option like this:

post api_v1_recipes_url,
     params: {
       recipe: {
         title: "API stew"
       }
     },
     headers: bearer_token_for(users(:alice)),
     as: :json

ApiTestCase class #

To use the new API helper in tests, you will have to require and include it in every API test file i.e. require_relative "test_helpers/api_test_helper" and include ApiTestHelper. That will be annoying! To avoid that, you can create a base class that will do this once while you use this new base class in all API test files; similar pattern as the test/application_system_test_case.rb for system tests.

Create a new file with nano test/api_test_case.rb and add the following content:

# test/api_test_case.rb
require "test_helper"
require_relative "test_helpers/api_test_helper"

class ApiTestCase < ActionDispatch::IntegrationTest
  include ApiTestHelper
end

This is what’s happening in the code above:

  • require_relative "test_helpers/api_test_helper" loads the helper required for authentication in every API test file.
  • ApiTestCase inherits from ActionDispatch::IntegrationTest, same as what SessionsControllerTest and RecipesControllerTest already do in their API test files.

Now update the test/controllers/api/v1/recipes_controller_test.rb file to inherit from ApiTestCase and require that file instead of the require test_helper:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing code ...
end

Run the test suite to ensure nothing is breaking, you should see 0 failures and 0 errors:

bin/rails test

You can leave SessionsControllerTest with its existing content as it is not affected by the new API helper. It’s fine for the destroy test to use the login flow instead of this new API helper for login. In future if any other tests inside the sessions controller require authentication then you can update the SessionsControllerTest to inherit from ApiTestCase as well so you can use the new API login helper.

Creating a record #

Now that the API authentication helper is in place, you can test features that can modify the data in the database. You will start with creating a recipe.

What counts as working? #

Flow You might say
Alice create Alice signed in with a valid bearer token, valid title → 201 and count +1
Invalid create Alice signed in, blank title → 422 and errors

Add scenarios for creating a recipe #

Append the following scenarios to the test/controllers/api/v1/recipes_controller_test.rb file just below the existing viewing tests:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  # Actor: Alice (API client)
  # Starting point: users(:alice) with a Bearer token
  # Action: POST /api/v1/recipes with a valid title
  # Expected outcome: 201; Recipe.count +1; title in parsed body
  # test "creates a recipe" do
  # end

  # Actor: Alice (API client)
  # Starting point: users(:alice) with a Bearer token
  # Action: POST /api/v1/recipes with a blank title
  # Expected outcome: 422; count unchanged; errors present
  # test "does not create a recipe with invalid attributes" do
  # end
end

Red: add the create tests #

Replace each test block with the real tests:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # … existing viewing tests …

  test "creates a recipe" do
    assert_difference "Recipe.count", 1 do
      post api_v1_recipes_url,
           params: {
             recipe: {
               title: "API stew"
             }
           },
           headers: bearer_token_for(users(:alice)),
           as: :json
    end

    assert_response :created
    assert_equal "API stew", response.parsed_body.dig("recipe", "title")
  end

  test "does not create a recipe with invalid attributes" do
    assert_no_difference "Recipe.count" do
      post api_v1_recipes_url,
           params: {
             recipe: {
               title: ""
             }
           },
           headers: bearer_token_for(users(:alice)),
           as: :json
    end

    assert_response :unprocessable_entity
    assert response.parsed_body["errors"].present?
  end
end

This is what’s happening in the tests above:

  • test "creates a recipe" posts a valid title as Alice and expects the recipe count to increase by 1 with matching title in the response.
  • test "does not create a recipe with invalid attributes" posts a blank title and expects 422 with errors.

Run tests for creating a recipe:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb -i "/test_creates_a_recipe|test_does_not_create_a_recipe_with_invalid_attributes/"

You should see errors:

F

Failure:
Api::V1::RecipesControllerTest#test_creates_a_recipe [test/controllers/api/v1/recipes_controller_test.rb:41]:
`Recipe.count` didn't change by 1, but by 0.
Expected: 4
  Actual: 3

F

Failure:
Api::V1::RecipesControllerTest#test_does_not_create_a_recipe_with_invalid_attributes [test/controllers/api/v1/recipes_controller_test.rb:68]:
Expected response to be a <422: unprocessable_entity>, but was a <404: Not Found>

That’s a red for the recipe create API. Both errors are due to the missing create action in the controller.

Green: add create action to the controller #

Replace the whole content in app/controllers/api/v1/recipes_controller.rb with the following code:

# app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApiController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_recipe, only: :show

  def index
    recipes = params[:quick] == "1" ? Recipe.quick : Recipe.all

    render json: { recipes: recipes.as_json(only: %i[id title prep_time]) }
  end

  def show
    render json: { recipe: recipe_json(@recipe) }
  end

  def create
    recipe = current_user.recipes.build(recipe_params)
    authorize! recipe

    if recipe.save
      render json: { recipe: recipe_json(recipe) }, status: :created
    else
      render json: {
               errors: recipe.errors.full_messages
             },
             status: :unprocessable_entity
    end
  end

  private

  def set_recipe
    @recipe = Recipe.find(params[:id])
  end

  def recipe_params
    params.require(:recipe).permit(
      :title,
      :description,
      :servings,
      :prep_time,
      ingredients_attributes: [%i[name quantity unit]],
      steps_attributes: [%i[instruction position]]
    )
  end

  def recipe_json(recipe)
    recipe.as_json(
      only: %i[id title prep_time],
      include: {
        ingredients: {
          only: %i[name quantity unit]
        },
        steps: {
          only: %i[position instruction]
        }
      }
    )
  end
end

This is what’s happening in the code above:

  • recipe_json extracts the JSON keys previously hardcoded in the show action to a private method since these keys are now used in both show and create actions while future update action will also use the same keys.
  • In show action, .as_json is replaced by recipe_json method.
  • In create action, authorize! uses the existing RecipePolicy (create? needs a user) from Chapter 13.
  • Validation errors are sent back as a simple errors array of full messages so API clients can render them in the UI as it is.
  • recipe_params extracts the permitted attributes from the request parameters. Nested ingredients_attributes and steps_attributes reuse accepts_nested_attributes_for from Chapter 9. You will add test for nested records creation in the next section.

Run the test again, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

Wait, why are they still failing?

You might be seeing the following error:

E

Error:
Api::V1::RecipesControllerTest#test_creates_a_recipe:
NoMethodError: undefined method 'authorize!' for an instance of Api::V1::RecipesController

Okay, it looks like the action_policy gem doesn’t attach itself automatically to ApiController like it does to ApplicationController. To fix the error, you need to manually include the module from the gem that provides the authorize! helper.

Update the app/controllers/api_controller.rb file to include the ActionPolicy::Controller class and then also tell it to use current_user for authorization:

# app/controllers/api_controller.rb
class ApiController < ActionController::API
  include ApiAuthentication
  include ActionPolicy::Controller

  # ... existing rescue_from code ...

  authorize :user, through: :current_user

  # ... existing code ...
end

This is what’s happening in the code above:

  • include ActionPolicy::Controller includes the ActionPolicy::Controller module which provides the authorize! helper.
  • authorize :user, through: :current_user tells the ActionPolicy::Controller to use current_user for authorization which we have defined in the ApiAuthentication concern.

Run the test again, you should finally see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

Uncomment the recipe creation inside the revoke token test #

If you remember, you added a test in the sessions controller to ensure the bearer token can’t be reused after it’s revoked. There, you had commented out the code that ensures the revoked token can’t be used to create a recipe. It’s time to uncomment it!

Open the test/controllers/api/v1/sessions_controller_test.rb file and uncomment the recipe POST inside revokes the bearer token test:

require "api_test_case"

class Api::V1::SessionsControllerTest < ApiTestCase
  # ... existing tests ...

  test "revokes the bearer token" do
    # ... existing uncommented test ...

    assert_no_difference "Recipe.count" do
      post api_v1_recipes_url,
           params: {
             recipe: {
               title: "After revoke"
             }
           },
           headers: headers,
           as: :json
    end
    assert_response :unauthorized
    assert_equal "Invalid token", response.parsed_body["error"]
  end
end

Run the session test, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/sessions_controller_test.rb

Creating nested records #

Authenticated user can also create a recipe with ingredients and steps associated with it. It’s a good idea to see the variety of nested records creation in the API tests before you move on to updating a record.

What counts as working? #

Flow You might say
Nested create Title plus one ingredients_attributes row and one steps_attributes row → 201 and Ingredient count +1 and Step count +1
Nested create with invalid attributes Blank ingredient or step under a valid title → 422; parent and child counts unchanged

Add scenarios for nested create #

Append the following scenarios to the test/controllers/api/v1/recipes_controller_test.rb file:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  # Actor: Alice (API client)
  # Starting point: users(:alice) with a Bearer token
  # Action: POST create with one nested ingredient and one nested step
  # Expected outcome: 201; Recipe, Ingredient, and Step counts each +1
  # test "creates a recipe with nested ingredient and step" do
  # end

  # Actor: Alice (API client)
  # Starting point: users(:alice) with a Bearer token
  # Action: POST create with blank nested ingredient and step
  # Expected outcome: 422; Recipe, Ingredient, and Step counts unchanged
  # test "does not create a recipe with invalid nested attributes" do
  # end
end

Add the nested create tests #

Replace the test do ... end block of both new tests you just added above with the following content:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  test "creates a recipe with nested ingredient and step" do
    assert_difference -> { Recipe.count } => 1,
                      -> { Ingredient.count } => 1,
                      -> { Step.count } => 1 do
      post api_v1_recipes_url,
           params: {
             recipe: {
               title: "API stew",
               ingredients_attributes: {
                 "0" => {
                   name: "Paprika",
                   quantity: 1,
                   unit: "tsp"
                 }
               },
               steps_attributes: {
                 "0" => {
                   instruction: "Boil the water"
                 }
               }
             }
           },
           headers: bearer_token_for(users(:alice)),
           as: :json
    end

    assert_response :created
    assert_equal "Paprika", Recipe.order(:id).last.ingredients.first.name
    assert_equal "Boil the water",
                 Recipe.order(:id).last.steps.first.instruction
  end

  test "does not create a recipe with invalid nested attributes" do
    assert_no_difference %w[Recipe.count Ingredient.count Step.count] do
      post api_v1_recipes_url,
           params: {
             recipe: {
               title: "API stew",
               ingredients_attributes: {
                 "0" => {
                   name: ""
                 }
               },
               steps_attributes: {
                 "0" => {
                   instruction: ""
                 }
               }
             }
           },
           headers: bearer_token_for(users(:alice)),
           as: :json
    end

    assert_response :unprocessable_entity
    assert response.parsed_body["errors"].present?
  end
end

The recipe API controller already implements create action and permits the nested attributes (both ingredients and steps) so the tests should pass.

Run the test, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

Updating a record #

What counts as working? #

Flow You might say
Owner update Alice signed in, valid title → 200 and title updated
Owner update with blank title Alice signed in, blank title → 422 and title unchanged

Add scenarios for updating a recipe #

Append following scenarios to the test/controllers/api/v1/recipes_controller_test.rb file:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  # Actor: Alice (API client)
  # Starting point: recipes(:pancakes) owned by Alice
  # Action: PATCH /api/v1/recipes/:id with a new title
  # Expected outcome: 200; title in body and on reload
  # test "updates a recipe" do
  # end

  # Actor: Alice (API client)
  # Starting point: recipes(:pancakes) owned by Alice
  # Action: PATCH with a blank title
  # Expected outcome: 422; errors present; title unchanged
  # test "does not update a recipe with invalid attributes" do
  # end
end

Red: add the update tests #

Replace the test block for each new test you added above with the following code:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  test "updates a recipe" do
    recipe = recipes(:pancakes)

    patch api_v1_recipe_url(recipe),
          params: {
            recipe: {
              title: "API pancakes"
            }
          },
          headers: bearer_token_for(users(:alice)),
          as: :json

    assert_response :success
    assert_equal "API pancakes", response.parsed_body.dig("recipe", "title")
    assert_equal "API pancakes", recipe.reload.title
  end

  test "does not update a recipe with invalid attributes" do
    recipe = recipes(:pancakes)
    original_title = recipe.title

    patch api_v1_recipe_url(recipe),
          params: {
            recipe: {
              title: ""
            }
          },
          headers: bearer_token_for(users(:alice)),
          as: :json

    assert_response :unprocessable_entity
    assert response.parsed_body["errors"].present?
    assert_equal original_title, recipe.reload.title
  end
end

This is what’s happening in the tests above:

  • test "updates a recipe" updates the recipe title and expects the title to be updated in the response and on reload.
  • test "does not update a recipe with invalid attributes" posts a blank title and expects 422 with errors. The title on reload is unchanged.

Run the test:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb -i "/test_updates_a_recipe|test_does_not_update_a_recipe_with_invalid_attributes/"

You should see failures:

F

Failure:
Api::V1::RecipesControllerTest#test_updates_a_recipe [test/controllers/api/v1/recipes_controller_test.rb:142]:
Expected response to be a <2XX: success>, but was a <404: Not Found>

F

Failure:
Api::V1::RecipesControllerTest#test_does_not_update_a_recipe_with_invalid_attributes [test/controllers/api/v1/recipes_controller_test.rb:160]:
Expected response to be a <422: unprocessable_entity>, but was a <404: Not Found>

That is a red for the recipe update API. It’s due to missing update action in the controller.

Green: add update action to the controller #

Append the update action to the recipe API controller app/controllers/api/v1/recipes_controller.rb while also adding update to the before_action :set_recipe list:

# app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApiController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_recipe, only: %i[show update]

  # ... existing code ...

  def create
    # ... existing code ...
  end

  def update
    authorize! @recipe

    if @recipe.update(recipe_params)
      render json: { recipe: recipe_json(@recipe) }
    else
      render json: {
               errors: @recipe.errors.full_messages
             },
             status: :unprocessable_entity
    end
  end

  # ... existing code ...
end

This is what’s happening in the code above:

  • before_action :set_recipe, only: %i[show update] sets the recipe instance variable for the update action.
  • update action uses the existing RecipePolicy (update? needs a user) for authorization. It returns the recipe JSON with the updated title if the update is successful else sends back the validation errors.

Run the test again, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

Deleting a record #

What counts as working? #

Flow You might say
Owner destroy Alice signed in → 204 and recipe -1

Add scenarios for deleting a recipe #

Append the following scenario to the test/controllers/api/v1/recipes_controller_test.rb file:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  # Actor: Alice (API client)
  # Starting point: recipes(:pancakes) owned by Alice
  # Action: DELETE /api/v1/recipes/:id
  # Expected outcome: 204; Recipe.count -1
  # test "destroys a recipe" do
  # end
end

Red: add the destroy test #

Replace the test block of the destroy scenario with the following:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  test "destroys a recipe" do
    recipe = recipes(:pancakes)

    assert_difference "Recipe.count", -1 do
      delete api_v1_recipe_url(recipe),
             headers: bearer_token_for(users(:alice)),
             as: :json
    end

    assert_response :no_content
  end
end

This is what’s happening in the tests above:

  • test "destroys a recipe" destroys the recipe and expects the recipe count to decrease by 1.

Run the test:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb -i test_destroys_a_recipe

You should see a failure:

F

Failure:
Api::V1::RecipesControllerTest#test_destroys_a_recipe [test/controllers/api/v1/recipes_controller_test.rb:168]:
`Recipe.count` didn't change by -1, but by 0.
Expected: 2
  Actual: 3

That is a red for the recipe destroy API. It’s because of missing destroy action in the controller.

Green: add destroy action to the controller #

Append the destroy action to the recipe API controller app/controllers/api/v1/recipes_controller.rb while also adding destroy to the before_action :set_recipe list:

# app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApiController
  allow_unauthenticated_access only: %i[index show]
  before_action :set_recipe, only: %i[show update destroy]

  # ... existing code ...

  def update
    # ... existing code ...
  end

  def destroy
    authorize! @recipe

    @recipe.destroy!

    head :no_content
  end

  # ... existing code ...
end

Run the test again, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

Share by email #

Chapter 14 added the feature to share any recipe by email. Anyone can POST recipe[recipient_email], and the controller enqueues RecipeMailer#share with Alice’s address or nil for a guest. You will now add the API part of the feature.

What counts as working? #

Flow You might say
Signed-in share Alice signed in; POST share with friend email → 200; RecipeMailer#share enqueued with Alice’s email; JSON message present
Guest share No token; same POST → 200; mailer enqueued with nil sender; JSON message present

Add scenarios for sharing by email #

Append the following scenarios to the test/controllers/api/v1/recipes_controller_test.rb file:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  # Actor: Alice (API client)
  # Starting point: recipes(:pancakes); Alice Bearer token
  # Action: POST /api/v1/recipes/:id/share with recipient_email
  # Expected outcome: 200; RecipeMailer#share enqueued with Alice's email; message in body
  # test "shares a recipe" do
  # end

  # Actor: guest API client
  # Starting point: recipes(:pancakes); no token
  # Action: POST share with recipient_email
  # Expected outcome: 200; RecipeMailer#share enqueued with nil sender; message in body
  # test "guest shares a recipe" do
  # end
end

Red: add the share by email tests #

Replace each test block of the share by email scenarios with the following:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  test "shares a recipe" do
    recipe = recipes(:pancakes)
    sender = users(:alice)

    assert_enqueued_email_with RecipeMailer,
                               :share,
                               args: [
                                 recipe,
                                 "friend@example.com",
                                 sender.email_address
                               ] do
      post share_api_v1_recipe_url(recipe),
           params: {
             recipe: {
               recipient_email: "friend@example.com"
             }
           },
           headers: bearer_token_for(sender),
           as: :json
    end

    assert_response :success
    assert_equal "Recipe shared with friend@example.com.",
                 response.parsed_body["message"]
  end

  test "guest shares a recipe" do
    recipe = recipes(:pancakes)

    assert_enqueued_email_with RecipeMailer,
                               :share,
                               args: [recipe, "friend@example.com", nil] do
      post share_api_v1_recipe_url(recipe),
           params: {
             recipe: {
               recipient_email: "friend@example.com"
             }
           },
           as: :json
    end

    assert_response :success
    assert_equal "Recipe shared with friend@example.com.",
                 response.parsed_body["message"]
  end
end

This is what’s happening in the code above:

  • Both tests are mostly same with one important difference in the bearer token of the user to differentiate between the signed-in and guest share.
  • assert_enqueued_email_with is used to assert that the email is enqueued with the correct arguments.
  • Guest share stays public, so there is no Bearer header on that example.

Run the share examples:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb -i "/shares_a_recipe/"

You should see an error:

E

Error:
Api::V1::RecipesControllerTest#test_shares_a_recipe:
NoMethodError: undefined method 'share_api_v1_recipe_url' for an instance of Api::V1::RecipesControllerTest

That is a red for the share by email API. It’s because of missing share action in the controller and route due to which the share_api_v1_recipe_url is not found.

Green: add route and controller action #

Update the API recipes routes to include share route:

# config/routes.rb
Rails.application.routes.draw do
  # ... existing routes ...

  namespace :api do
    namespace :v1 do
      # ... existing routes ...

      resources :recipes do
        post :share, on: :member
      end
    end
  end
end

Then, append the share action to the recipe API controller app/controllers/api/v1/recipes_controller.rb while also adding share to the before_action :set_recipe and allow_unauthenticated_access list:

# app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApiController
  allow_unauthenticated_access only: %i[index show share]
  before_action :set_recipe, only: %i[show update destroy share]

  # ... existing code ...

  def destroy
    # ... existing code ...
  end

  def share
    recipient = params[:recipe][:recipient_email]
    sender = current_user&.email_address

    RecipeMailer.share(@recipe, recipient, sender).deliver_later

    render json: { message: "Recipe shared with #{recipient}." }
  end

  # ... existing code ...
end

This is what’s happening in the code above:

  • Member route post :share opens up the share action for each recipe with the share_api_v1_recipe_url(recipe) path.
  • allow_unauthenticated_access includes :share so guests can also share the recipe with their friends.

Run the test again, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

Exporting and downloading a file #

Chapter 15 added two actions for the recipe export feature: export action to create the export file and download action to download the export file. Machine clients need the same two steps over the API.

What counts as working? #

Flow You might say
Alice export Alice signed in; POST export → 200; ExportRecipesJob enqueued; message in body
Missing export Alice logged in; GET download with no attachment → 404
Exported file ready for download Alice logged in after exporting recipes; GET download → 200; pancake title; Content-Disposition includes attachment

Add scenarios for export and download #

Append the following scenarios to the test/controllers/api/v1/recipes_controller_test.rb file:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  # Actor: Alice (API client)
  # Starting point: Alice Bearer token
  # Action: POST /api/v1/recipes/export
  # Expected outcome: 200; ExportRecipesJob enqueued for Alice; message in body
  # test "exports recipes" do
  # end

  # Actor: Alice
  # Starting point: no recipes_export attached
  # Action: GET /api/v1/recipes/download
  # Expected outcome: 404
  # test "rejects download when export is missing" do
  # end

  # Actor: Alice
  # Starting point: ExportRecipesJob.perform_now already ran for Alice
  # Action: GET download
  # Expected outcome: 200; attachment disposition; pancake title in body
  # test "downloads exported recipes" do
  # end
end

Red: add the export and download tests #

Replace each test block of the export and download scenarios with the following:

# test/controllers/api/v1/recipes_controller_test.rb
require "api_test_case"

class Api::V1::RecipesControllerTest < ApiTestCase
  # ... existing tests ...

  test "exports recipes" do
    assert_enqueued_with(job: ExportRecipesJob, args: [users(:alice).id]) do
      post export_api_v1_recipes_url,
           headers: bearer_token_for(users(:alice)),
           as: :json
    end

    assert_response :success
    assert_equal "Your recipes are being exported.",
                 response.parsed_body["message"]
  end

  test "rejects download when export is missing" do
    get download_api_v1_recipes_url,
        headers: bearer_token_for(users(:alice)),
        as: :json

    assert_response :not_found
  end

  test "downloads exported recipes" do
    ExportRecipesJob.perform_now(users(:alice).id)

    get download_api_v1_recipes_url, headers: bearer_token_for(users(:alice))

    assert_response :success
    assert_includes response.headers["Content-Disposition"], "attachment"
    assert_match(
      /recipes-export-\d{14}\.txt/,
      response.headers["Content-Disposition"]
    )
    assert_includes response.body, recipes(:pancakes).title
  end
end

Run the test:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb -i "/exports_recipes|downloads_exported|rejects_download/"

You should see errors:

E

Error:
Api::V1::RecipesControllerTest#test_exports_recipes:
NameError: undefined local variable or method 'export_api_v1_recipes_url' for an instance of Api::V1::RecipesControllerTest

E

Error:
Api::V1::RecipesControllerTest#test_downloads_exported_recipes:
NameError: undefined local variable or method 'download_api_v1_recipes_url' for an instance of Api::V1::RecipesControllerTest

That is a red for the export and download API. It’s because of missing export and download actions in the controller (and routes) due to which the export_api_v1_recipes_url and download_api_v1_recipes_url are not found.

Green: export and download routes and actions #

Update the recipe API routes to include export and download collection routes just below the share route:

# config/routes.rb
Rails.application.routes.draw do
  # ... existing routes ...

  namespace :api do
    namespace :v1 do
      # ... existing routes ...
      
      resources :recipes do
        post :share, on: :member
        post :export, on: :collection
        get :download, on: :collection
      end
    end
  end
end

Next, append the export and download actions to the recipe API controller app/controllers/api/v1/recipes_controller.rb just below the share action:

# app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApiController
  # ... existing code ...

  def share
    # ... existing code ...
  end

  def export
    ExportRecipesJob.perform_later(current_user.id)

    render json: { message: "Your recipes are being exported." }
  end

  def download
    unless current_user.recipes_export.attached?
      head :not_found
      return
    end

    send_data current_user.recipes_export.download,
              filename: current_user.recipes_export.filename.to_s,
              type: current_user.recipes_export.content_type || "text/plain",
              disposition: :attachment
  end

  # ... existing code ...
end

This is what’s happening:

  • export action enqueues the ExportRecipesJob with the current user’s ID. It’s the same code as in non-API export feature apart from the JSON success message.
  • download action sends the export file as a download with the filename and content type.

Run the test again, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipes_controller_test.rb

Authorizing API access #

You have already been authorizing users by adding authorize! calls to the controller actions. In this section, you will add tests to ensure the unauthorized users are denied access to the API.

What counts as working? #

Flow You might say
Guest create Create recipe → 401; count unchanged
Guest update Update recipe → 401; title unchanged
Guest destroy Destroy recipe → 401; count unchanged
Guest export Export recipes → 401; job not enqueued
Guest download Download export → 401
Non-owner update Login as Bob; PATCH Alice’s recipe → 403; title unchanged
Non-owner destroy Login as Bob; DELETE Alice’s recipe → 403; count unchanged

Add scenarios to the access file #

Create a new file for the access tests with nano test/controllers/api/v1/recipe_access_controller_test.rb and add the following scenarios:

# test/controllers/api/v1/recipe_access_controller_test.rb
require "api_test_case"

class Api::V1::RecipeAccessControllerTest < ApiTestCase
  # Actor: guest API client
  # Starting point: no Bearer token
  # Action: POST /api/v1/recipes
  # Expected outcome: 401; Recipe.count unchanged
  # test "guest cannot create a recipe" do
  # end

  # Actor: guest
  # Starting point: recipes(:pancakes); no token
  # Action: PATCH recipe
  # Expected outcome: 401; title unchanged
  # test "guest cannot edit a recipe" do
  # end

  # Actor: guest
  # Starting point: recipes(:pancakes); no token
  # Action: DELETE recipe
  # Expected outcome: 401; count unchanged
  # test "guest cannot destroy a recipe" do
  # end

  # Actor: guest
  # Starting point: no token
  # Action: POST export
  # Expected outcome: 401; ExportRecipesJob not enqueued
  # test "guest cannot export recipes" do
  # end

  # Actor: guest
  # Starting point: no token
  # Action: GET download
  # Expected outcome: 401
  # test "guest cannot download an export" do
  # end

  # Actor: Bob (API client)
  # Starting point: recipes(:pancakes) owned by Alice; Bob's Bearer token
  # Action: PATCH Alice's recipe
  # Expected outcome: 403; title unchanged; empty body
  # test "cannot update recipe of another user" do
  # end

  # Actor: Bob
  # Starting point: recipes(:pancakes) owned by Alice; Bob's Bearer token
  # Action: DELETE Alice's recipe
  # Expected outcome: 403; count unchanged; empty body
  # test "cannot destroy recipe of another user" do
  # end
end

Red: add the access tests #

Replace each test block of the access scenarios with the following:

# test/controllers/api/v1/recipe_access_controller_test.rb
require "api_test_case"

class Api::V1::RecipeAccessControllerTest < ApiTestCase
  test "guest cannot create a recipe" do
    assert_no_difference "Recipe.count" do
      post api_v1_recipes_url,
           params: {
             recipe: {
               title: "API pancakes"
             }
           },
           as: :json
    end
    assert_response :unauthorized
  end

  test "guest cannot edit a recipe" do
    recipe = recipes(:pancakes)
    original_title = recipe.title

    patch api_v1_recipe_url(recipe),
          params: {
            recipe: {
              title: "API pancakes"
            }
          },
          as: :json

    assert_response :unauthorized
    assert_equal original_title, recipe.reload.title
  end

  test "guest cannot destroy a recipe" do
    recipe = recipes(:pancakes)
    assert_no_difference "Recipe.count" do
      delete api_v1_recipe_url(recipe), as: :json
    end
    assert_response :unauthorized
  end

  test "guest cannot export recipes" do
    assert_no_enqueued_jobs only: ExportRecipesJob do
      post export_api_v1_recipes_url, as: :json
    end
    assert_response :unauthorized
  end

  test "guest cannot download an export" do
    get download_api_v1_recipes_url, as: :json
    assert_response :unauthorized
  end

  test "cannot update recipe of another user" do
    recipe = recipes(:pancakes)
    original_title = recipe.title

    patch api_v1_recipe_url(recipe),
          params: {
            recipe: {
              title: "Bob was here"
            }
          },
          headers: bearer_token_for(users(:bob)),
          as: :json

    assert_response :forbidden
    assert_equal original_title, recipe.reload.title
  end

  test "cannot destroy recipe of another user" do
    recipe = recipes(:pancakes)

    assert_no_difference "Recipe.count" do
      delete api_v1_recipe_url(recipe),
             headers: bearer_token_for(users(:bob)),
             as: :json
    end

    assert_response :forbidden
  end
end

This is what’s happening in the code above:

  • Guest will get unauthenticated error resulting in a 401 status code (unauthorized) and no changes to the recipe.
  • Bob’s token authenticates but the recipe policy denies the action resulting in a 403 status code (forbidden); again with no changes to the recipe.

Run the test:

bin/rails test test/controllers/api/v1/recipe_access_controller_test.rb

You should see an error:

E

Error:
Api::V1::RecipeAccessControllerTest#test_cannot_update_recipe_of_another_user:
ActionPolicy::Unauthorized: Not authorized: RecipePolicy#update? returns false

That is a red for the recipe API access tests. Guest access tests should already be green since they are related to Authentication part of the app that is already working. Failing ones would be access tests for different owners because of missing global rescue_from ActionPolicy::Unauthorized in the API base controller.

Green: rescue forbidden globally in ApiController #

The error is not due to missing authorize! calls in the controller actions as you have been adding those already for each action inside the recipe API controller. The missing piece is a JSON friendly denial for policy failures. Right now ActionPolicy::Unauthorized is not rescued and returns 500 status code (internal server error) instead of the expected 403 status code (forbidden).

Open app/controllers/api_controller.rb and replace the whole file with the following:

# app/controllers/api_controller.rb
class ApiController < ActionController::API
  include ApiAuthentication
  include ActionPolicy::Controller

  rescue_from ActiveRecord::RecordNotFound, with: :deny_not_found
  rescue_from ActionPolicy::Unauthorized, with: :deny_forbidden

  authorize :user, through: :current_user

  private

  def deny_not_found(_exception)
    render json: { error: "Not found" }, status: :not_found
  end

  def deny_forbidden(_exception)
    head :forbidden
  end
end

This is what’s happening in the code above:

  • rescue_from ActionPolicy::Unauthorized calls the deny_forbidden method to render a 403 status code (forbidden). Since we have added this in the ApiController class, it rescues the unauthorized requests globally for every API action.

Run the test again, you should see 0 failures and 0 errors:

bin/rails test test/controllers/api/v1/recipe_access_controller_test.rb

Try the API with curl #

Test are now green for the API but you still haven’t tested any API manually to see if it’s working as expected in the wild.

First of all, if you haven’t already, start the app with bin/dev and then also ensure fixtures are loaded in development. You can then use curl to test the API manually from the command line.

Login and capture the bearer token #

Hit the following command in the terminal and copy the token value, that’s what you will use to authenticate your requests to the API in next steps.

curl -s -X POST http://localhost:3000/api/v1/sessions \
  -H "Content-Type: application/json" \
  -d '{"email_address":"alice@example.com","password":"password"}'

You should see a response similar to the following:

{"token":"02b5f9ee89dc20ddd6f476207f86b7953f744d78289ab1d1ea10dc6b62fe2f0e","user":{"id":663665735,"email_address":"alice@example.com"}}

Copy the token value from the response and keep it handy for the next steps.

Guest allowed APIs #

List records #

Anyone can list recipes in the app so you can call the list API without any bearer token:

curl -s http://localhost:3000/api/v1/recipes \
  -H "Accept: application/json"

You should see a JSON object with a recipes array that includes three recipes:

{"recipes":[{"id":266175090,"title":"Pizza","prep_time":60},{"id":422272663,"title":"Lentil soup","prep_time":30},{"id":507601372,"title":"Fluffy pancakes","prep_time":15}]}

View a record #

Everyone can view a recipe in the app, so you can also call the view API without any bearer token.

You can use the ID of the recipe by picking it up from the list you got in the previous step. If you don’t want to pick it up from the list, you can follow the steps below to get the ID from the rails console:

  1. Open the rails console with bin/rails console.
  2. Find the ID of the recipe you want to view with Recipe.find_by(title: "Pizza").id.
  3. Copy the ID and keep it handy for the next steps.

Hit the following command in the terminal to view the recipe, make sure to replace the 266175090 with the ID you copied:

curl -s http://localhost:3000/api/v1/recipes/266175090 \
  -H "Accept: application/json"

You should see a JSON object with the recipe details:

{"recipe":{"id":266175090,"title":"Pizza","prep_time":60,"ingredients":[{"name":"Tomato","quantity":"10.0"},{"name":"Cheese","quantity":"1.0"}],"steps":[{"position":2,"instruction":"Bake the pizza in the oven for 15 minutes at 400 degrees Fahrenheit."},{"position":1,"instruction":"Knead the dough for 10 minutes."}]}}

Create a record #

For creating a recipe, you will need a bearer token of either Alice or Bob.

Hit the following command in the terminal to create the recipe by replacing the PASTE_TOKEN_HERE with the token you copied in the previous step:

curl -s -X POST http://localhost:3000/api/v1/recipes \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer PASTE_TOKEN_HERE" \
  -d '{"recipe":{"title":"Curl stew"}}'

You should see a JSON object with the recipe details:

{"recipe":{"id":508001373,"title":"Curl stew","prep_time":null,"ingredients":[],"steps":[]}}

Note that the ID will be different for your case but other details should match.

Update a record #

You can update the pancake recipe owned by Alice since at the beginning of the CURL exercise you logged in as Alice and got the bearer token.

To find the recipe owned by Alice, you can run the rails console and get the ID of the recipe you want to update with Recipe.find_by(title: "Fluffy pancakes").id. You can also pick it up from the list you got in the previous step where you fetched the list of recipes.

Hit the following command in the terminal to update the recipe by replacing the PASTE_TOKEN_HERE with the token you copied in the previous step and the 507601372 with the ID you copied:

curl -s -X PATCH http://localhost:3000/api/v1/recipes/507601372 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer PASTE_TOKEN_HERE" \
  -d '{"recipe":{"title":"Curl pancakes"}}'

You should see a JSON object with the updated recipe details:

{"recipe":{"id":507601372,"title":"Curl pancakes","prep_time":15,"ingredients":[{"name":"Flour","quantity":"2.0"},{"name":"Salt","quantity":"1.0"}],"steps":[{"position":1,"instruction":"Preheat the pan."}]}}

Destroy a record #

You can reuse the ID of the pancake recipe from the previous step to destroy it.

Hit the following command in the terminal to destroy the recipe by replacing the PASTE_TOKEN_HERE with the token you copied in the previous step:

curl -s -i -X DELETE http://localhost:3000/api/v1/recipes/507601372 \
  -H "Authorization: Bearer PASTE_TOKEN_HERE"

You should see the status code 204 at the top of the response:

HTTP/1.1 204 No Content

Authorization #

For testing authorization, you can try to update the “Pizza” recipe owned by Bob with Alice’s token.

Hit the following command in the terminal to update the recipe by replacing the PASTE_TOKEN_HERE with the token you copied in the previous step and the 266175090 with the ID you copied:

curl -s -i -X PATCH http://localhost:3000/api/v1/recipes/266175090 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer PASTE_TOKEN_HERE" \
  -d '{"recipe":{"title":"Curl pizza"}}'

You should see a 403 status code at the top of the response:

HTTP/1.1 403 Forbidden

Other features #

You can test other features of the API in the same way as shown above. For example, you can try sharing the recipe or downloading the export file. I will not cover all those here, it’s for you to explore on your own.

Revoke the token (logout) #

When you are done testing the API, you can revoke the token so it can’t be used anymore.

Revoke when you are done and don’t forget to use -i to include the response code:

curl -s -i -X DELETE http://localhost:3000/api/v1/session \
  -H "Authorization: Bearer PASTE_TOKEN_HERE"

You should see the status code 204 at the top of the response:

HTTP/1.1 204 No Content

Try to create a recipe with the same token afterward, you should see a 401 status code indicating the token is no longer valid:

curl -s -i -X POST http://localhost:3000/api/v1/recipes \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer PASTE_TOKEN_HERE" \
  -d '{"recipe":{"title":"Curl stew"}}'

You should see a 401 status code at the top of the response:

HTTP/1.1 401 Unauthorized

Tradeoffs #

To keep the chapter focused on API testing instead of the full API app, there are some things that are not covered here. These tradeoffs are intentional but these are the thing that are mostly required for a production API.

Deferred Notes
Token expiry and refresh Bearer tokens here live until revoked manually. In production, tokens should have an expiry date to ensure security. There should also be a refresh endpoint to get a new token when the previous token expires automatically.
Token scopes (recipes:read, recipes:write) Cookbook app doesn’t have roles yet so tokens provide full access for that user’s resource. In some production applications, tokens will also have scopes to restrict access to specific resources and actions.
JWT / OAuth This chapter added a very simple bearer token authentication manually. In production, you might want to use gems related to JWT or OAuth for API authentication.
API schema gems Cookbook app only has a recipe resource which returns a response so we hand-coded columns and nested records to return inside the response. In production apps, there can be 10+ resources and in those cases, this approach will not make any sense. So, for production APIs, you can use OpenAPI / schema gems to generate the API documentation and tests automatically from the code.
v2 beside v1 I wanted to help you understand the importance of versioning in the API, that’s why we added a v1 version instead of unversioned API. v2 would have added more scope to the chapter so we skipped it; learning and takeaways matter more than the second or third version of the API.

What to test where #

This chapter was kind of a reset (and unlearning) regarding the test folder for controllers so here is a quick recap of what you should test where.

Layer Proves
API controller tests (test/controllers/api/v1/ via ApiTestCase) Status + parsed_body for sessions (including 429), recipes CRUD, share, export, access, download; 401, 403, 404, 422, 204
HTML integration - controller + views (recipes_integration_test.rb) Forms, redirects, visible copy, nested attributes, Turbo
Models (test/models/) Validations (always) and public methods worth testing
Policy (test/integration/*_access_test.rb or test/controllers/api/*/*_access_test.rb) Rules live in RecipePolicy; API and HTML both call authorize!
Jobs perform_now works independently; API only enqueues and streams the attachment
System tests Browser only paths; not required for API
Mailers deliver_now works independently

Commit your work #

Run the full suite before you commit:

bin/rails test:all

You want 0 failures and 0 errors across the test suite. Then commit your work:

git add .
git commit -m "Add versioned JSON Recipes API with Bearer auth and tests"

Small commits make it easier to bisect token login, CRUD, or download changes if something breaks in production.

What is next #

Chapter 17 teaches stubs first, then doubles. You will freeze time, temporarily change what a method returns, and pass a fake object into a small class when you need to check that a message was sent. Calling other people’s servers over HTTP waits until Chapter 18 where you will mock external services so tests don’t hit real servers.

Continue to Stubs and doubles in Minitest.

Keep Minitest Rails independent

Minitest Rails is an independent educational guide for Rails developers learning automated testing.

Companies can support the guide by sponsoring a chapter (one-time payment) or becoming a Patron with a monthly subscription. This funds new chapters, Rails version updates, and more real-world examples.

If you just want to chip in as a reader, you can also buy me a drink as a thanks.

Disclaimer: This guide is based on hands-on Rails and testing experience and was proofread by AI. I stand by the advice and patterns here.