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 #
- Put API tests under
test/controllers/api/v1/and assert status plus response body. - Mount routes under
/api/v1from day one so clients get a stable version prefix. - TDD guest viewing first (index, show, quick filter, JSON media type), then login with Bearer token, revoke, and rate-limited sessions.
- Wire Bearer auth on
ApiControllerfor authenticating APIs, then put those helpers onApiTestCasefor authenticating API tests. - TDD create (including nested records), update, and destroy.
- Mirror HTML share and export/download over the API.
- 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.
- Try important APIs with
curl, then read about the tradeoffs we skipped from this chapter on purpose. - Run
bin/rails test:allto 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:
- Backward compatibility – You can change the API without breaking existing clients.
- Room for growth – Software is ever changing and you need to be able to evolve the API without breaking existing clients.
- 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=1reuses the sameRecipe.quickscope 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 recipeasserts the JSON keys (id,title,prep_time, plus nestedingredientsandsteps). 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 JSONerrorfield.
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
ApiControllerthat inherits fromActionController::APIinstead ofApplicationController. Base controller lives insideapp/controllers/instead ofapp/controllers/api/v1/because base code rarely changes and doesn’t need to be versioned. rescue_from ActiveRecord::RecordNotFoundrescues all exceptions related to ActiveRecord not finding the record in the database and returns a404JSON response with"Not found"error message. This will be useful for theshowaction ofRecipesControllerin 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
quickparameter to filter the recipes. It returns the full list if the filter parameter is not passed. - show action finds the recipe by the
idparameter and returns the recipe as JSON. If the recipe is not found,findraises anActiveRecord::RecordNotFoundexception which is rescued by therescue_fromblock in theApiController. - Both actions use the
as_jsonmethod to serialize the recipe to JSON. Theonlyoption 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:
-
test “logs in with valid credentials”
- For login, the password is
"password". That string comes from user fixture attest/fixtures/users.ymlgenerated 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.
- For login, the password is
-
test “rejects login with a bad password”
- Bad password returns 401 status code with
"Invalid email or password"error message indicating the authentication failed.
- Bad password returns 401 status code with
-
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
DELETEon/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.
- Revoke invalidates the token so API client can no longer use that token to authenticate requests. The HTTP verb is
-
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 needconfig.cache_store = :memory_storeinconfig/environments/test.rbfor 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.
- 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
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_tokengenerates a new token and stores the digest in the database.authenticatefinds the token by its digest in the database.digestgenerates a SHA-256 hash of the token.before_validation :generate_token, on: :creategenerates 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_tokenis a new attribute to hold theApiTokenrecord to retrieve the logged in user.api_userreads the user offCurrent.api_tokenso policies and API controllers can callcurrent_userthe 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: :createcreates a plural route for user login (POST /api/v1/sessions).resource :session, only: :destroycreates 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 dodefines a class methodallow_unauthenticated_accessthat 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 ofapi_tokenin theCurrentobject.current_userreturns the logged in user from theCurrentobject. 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: :createskips the authentication logic for the create action so the user can login without a token.rate_limit to: 10, within: 3.minutes, only: :createcaps login attempts the same way HTML sign-in does in Chapter 12. Thewith:block returns error status code 429 unlike the redirect in non-API login process.createaction checks the email and password withUser.authenticate_by(same as Chapter 12), then creates a newApiTokenfor 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.destroyaction deletes (revokes) theApiTokenfor 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.ApiTestCaseinherits fromActionDispatch::IntegrationTest, same as whatSessionsControllerTestandRecipesControllerTestalready 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 witherrors.
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_jsonextracts the JSON keys previously hardcoded in theshowaction to a private method since these keys are now used in bothshowandcreateactions while futureupdateaction will also use the same keys.- In
showaction,.as_jsonis replaced byrecipe_jsonmethod. - In
createaction,authorize!uses the existingRecipePolicy(create?needs a user) from Chapter 13. - Validation errors are sent back as a simple
errorsarray of full messages so API clients can render them in the UI as it is. recipe_paramsextracts the permitted attributes from the request parameters. Nestedingredients_attributesandsteps_attributesreuseaccepts_nested_attributes_forfrom 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::Controllerincludes theActionPolicy::Controllermodule which provides theauthorize!helper.authorize :user, through: :current_usertells theActionPolicy::Controllerto usecurrent_userfor authorization which we have defined in theApiAuthenticationconcern.
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 witherrors. 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 theupdateaction.updateaction uses the existingRecipePolicy(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_withis 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 :shareopens up the share action for each recipe with theshare_api_v1_recipe_url(recipe)path. allow_unauthenticated_accessincludes:shareso 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:
exportaction enqueues theExportRecipesJobwith the current user’s ID. It’s the same code as in non-API export feature apart from the JSON success message.downloadaction 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::Unauthorizedcalls thedeny_forbiddenmethod to render a 403 status code (forbidden). Since we have added this in theApiControllerclass, 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:
- Open the rails console with
bin/rails console. - Find the ID of the recipe you want to view with
Recipe.find_by(title: "Pizza").id. - 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.