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.

<%= render Guide::ChapterProgress.new(
  feature: "Versioned JSON API under `/api/v1` that opens the Cookbook app to external clients: guest reads, Bearer login with rate limits, CRUD, share by email, export and download.",
  why: "App now has all of the pieces required for humans. In this agentic era, almost all of the applications on the internet need an API to access the app's data and functionality.",
  test: "Controller: sessions (login, revoke, rate limit); CRUD recipes (including nested create); share; export enqueue and download; authorization",
  later: "[Chapter 17](/guide/stubs-and-doubles-in-minitest/) teaches stubs and doubles so you can fake other Ruby objects in a test (a PDF renderer, the clock) without calling the real implementation."
) %>

## 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:

```ruby
# 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](/guide/your-first-test/#where-this-guide-keeps-http-tests), 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:

```ruby
# 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:

```ruby
# 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
```

<%= render Shared::Tip.new(
  title: "New assertions",
  markdown: <<~MD
    1. **`get api_v1_recipes_url, …, as: :json`**
      Asks Rails to treat the request as JSON (Accept / format). Syntax: pass `as: :json` as a keyword on `get` / `post` / `patch` / `delete`.
      Make sure to use `as: :json` for API tests, it's a good practice. The advantage will not be immediately obvious in our app because we will have a dedicated API controller which will always return JSON. But in applications that serve multiple formats (html/ markdown/JSON) you may get HTML from the wrong responder if you don't specify the format explicitly.

    2. **`response.parsed_body`**
      Asks "what is the JSON body as Ruby?" Syntax: call `parsed_body` on `response`, then `fetch` / `dig` / `[]` so you can assert if something you expect in the test is present in the response.

    3. **`assert_not_includes titles, recipes(:pizza).title`**
      Asks "is pizza absent from this list?" Syntax: `assert_not_includes` plus the collection, then the item that must not be there.
  MD
) %>

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](/guide/testing-models/#our-rule-prep-time-under-30-minutes). 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](#tradeoffs) section at the end of the chapter.
- Missing show expects `404` (`"Not found"`) in the JSON `error` field.

Run the test:

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

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

```bash
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.

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    `touch` creates an empty file. It is different from the usual `nano` command you have been using to create or edit files. You use `touch` here because you don't want to add any content to the file yet.
  MD
) %>

Run the test again:

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

You should see all tests failing with a similar error:

```bash
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:

```ruby
# 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:

```ruby
# 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`:

```ruby
# 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.

<%= render Shared::Tip.new(
  title: "What does serialize mean?",
  markdown: <<~MD
    Serialize means turning a Ruby object into a shape a client can read (or expects). Here that shape is JSON: keys and values a client can parse. For example `{ "id": 1, "title": "Fluffy pancakes", "prep_time": 15, "ingredients": [ { "name": "Flour", "quantity": "2.0", "unit": "cups" } ], "steps": [ { "position": 1, "instruction": "Preheat the pan." } ] }`.

    `as_json(only: %i[id title prep_time])` filters out the column required by the client. Without `only`, Rails would dump far more than what the client needs.
  MD
) %>

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    For the response, sending only required fields for recipe/s manually is enough for our use case because we are only dealing with one controller (`RecipesController`) in Cookbook app. But for production applications, you may want to use a gem to do this as it gets out of hand quickly when there are multiple controllers and actions. Most of them can generate a schema for the API and validate the response against it so you don't have to do it manually.

    You can also use [Jbuilder](https://github.com/rails/jbuilder?utm_source=minitestrails.com), a Rails default that lets you write JSON templates under `app/views/`. This chapter keeps serialization in the controller with `as_json` to keep things simple and focused on the API testing.

    If you want to see an example of using Jbuilder, you can check `app/views/recipes/show.json.jbuilder` and `app/views/recipes/index.json.jbuilder` files. If you end up using Jbuilder, you can remove the `as_json` calls from the controller – just variable declarations like `@recipe`/`@recipes` will be enough and use the Jbuilder templates by moving them to the `app/views/api/v1/recipes/` directory.
  MD
) %>

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

```bash
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.

<%= render Shared::Tip.new(
  title: "Tip",
  markdown: <<~MD
    An opaque bearer token is a random string that has no meaning or user information encoded into it. The server treats it as a credential and looks up the token (or its digest) to determine which user it belongs to. It's called "bearer" because whoever possesses the token can use it to authenticate. The server doesn't need to know anything about the token itself beyond whether it is valid and which user it belongs to.

    This differs from a JWT which contains encoded claims, such as a user ID, expiration time, or other metadata. The server verifies the JWT's signature and can then read those claims to determine who the token represents without looking up the token in a database.
  MD
) %>

### 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:

```ruby
# 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

```ruby
# 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
```

<%= render Shared::Tip.new(
  title: "New assertions",
  markdown: <<~MD
    1. **`assert_response :created`** and **`assert_response :unauthorized`**
      Same `assert_response` helper as before with some new status symbols for this chapter: `:created` is 201, `:unauthorized` is 401.

    2. **`assert_response :no_content`**
      Asks "did revoke (or destroy) succeed with an empty body?" Syntax: same helper. `:no_content` is 204.

    3. **`assert_response :too_many_requests`**
      Asks "did rate limiting kick in?" Syntax: same helper. `:too_many_requests` is 429. Pair it with the `"Try again later."` error string from the controller's `rate_limit` block.
  MD
) %>

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](/guide/testing-authentication/#user-fixtures).
   - 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](/guide/testing-authentication/#integration-tests). 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:

```bash
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:

```bash
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:

```bash
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:

```ruby
# 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`:

```ruby
# 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:

```ruby
# 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:

```ruby
# 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:

```ruby
# 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`:

```ruby
# 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:

```ruby
# 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](/guide/testing-authentication/#integration-tests). 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](/guide/testing-authentication/#integration-tests)), 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:

```bash
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](/guide/testing-authentication/#green-use-a-real-cache-store-in-test)).

<%= render Guide::SupportCta.new(
  variant: :mid_chapter,
  site_metadata: site.data.site_metadata,
  headline: "AI agents can now login to your app!",
  body_text: "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:

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

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

```bash
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:

```ruby
# 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:

```bash
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:

```ruby
# 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:

```ruby
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:

```ruby
# 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`:

```ruby
# 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:

```bash
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:

```ruby
# 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:

```ruby
# 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
```

<%= render Shared::Tip.new(
  title: "New assertion",
  markdown: <<~MD
    **`headers: bearer_token_for(users(:alice))`** asks "call this endpoint as Alice's API client." Syntax: pass the hash from `bearer_token_for` as `headers:` on `get` / `post` / `patch` / `delete`.
    
    The helper creates an `ApiToken` row and returns `{ "Authorization" => "Bearer …" }`.
  MD
) %>

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:

```bash
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:

```bash
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:

```ruby
# 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](/guide/authorization-testing/#generate-a-policy-for-recipes).
- 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](/guide/second-resource-and-associations/#refactor-wire-nested-form-fields). You will add test for nested records creation in the next section.

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

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

Wait, why are they still failing?

You might be seeing the following error:

```bash
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:

```ruby
# 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:

```bash
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:

```ruby
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:

```bash
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:

```ruby
# 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:

```ruby
# 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:

```bash
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:

```ruby
# 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:

```ruby
# 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:

```bash
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:

```bash
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:

```ruby
# 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:

```bash
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:

```ruby
# 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:

```ruby
# 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:

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

You should see a failure:

```bash
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:

```ruby
# 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:

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

## Share by email

[Chapter 14](/guide/testing-mailers/#share-a-recipe-by-email) 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:

```ruby
# 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:

```ruby
# 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:

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

You should see an error:

```bash
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:

```ruby
# 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:

```ruby
# 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:

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

## Exporting and downloading a file

[Chapter 15](/guide/testing-background-jobs/) 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:

```ruby
# 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:

```ruby
# 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:

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

You should see errors:

```bash
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:

```ruby
# 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:

```ruby
# 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:

```bash
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:

```ruby
# 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:

```ruby
# 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
```

<%= render Shared::Tip.new(
  title: "New assertion",
  markdown: <<~MD
    **`assert_response :forbidden`** asks "did the server refuse an authenticated caller?" Syntax: same as `assert_response` helper. `:forbidden` is 403. Use it when the token is valid but `RecipePolicy` says no (Bob editing Alice's pancakes).
  MD
) %>

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:

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

You should see an error:

```bash
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:

```ruby
# 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:

```bash
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.

<%= render Guide::LoadDevelopmentFixtures.new %>

### 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.

```bash
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:

```json
{"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:

```bash
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:

```json
{"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:

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

You should see a JSON object with the recipe details:

```json
{"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:

```bash
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:

```json
{"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:

```bash
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:

```json
{"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:

```bash
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:

```bash
HTTP/1.1 204 No Content
```

<%= render Shared::Tip.new(
  markdown: <<~MD
    **`-i`** tells curl to also include the server detail and the response code.
    
    This is especially useful in cases like "deleting a record" which by standard doesn't return any content in the response body so you need to rely on the response code to confirm the action was successful.
  MD
) %>

### 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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
bin/rails test:all
```

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

```bash
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](/guide/stubs-and-doubles-in-minitest/) 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](/guide/mock-external-services/) where you will mock external services so tests don't hit real servers.

Continue to **[Stubs and doubles in Minitest](/guide/stubs-and-doubles-in-minitest/)**.
