APIs with Rails

Ruby on Rails Intermediate/Advanced — APIs with Rails
Ruby on Rails Intermediate/Advanced
Course 2 · Chapter 5 · APIs with Rails

🔌 APIs with Rails

Every chapter so far has assumed Rails renders HTML — ERB views, forms, redirects. But a huge share of real Rails apps serve JSON to a separate JavaScript frontend, a mobile app, or another service entirely, with no views in sight. The REST conventions from rails1-2/rails1-3 — resourceful routes, the 7 actions — carry over almost unchanged; what changes is what gets rendered at the end of each action.

⚙️ API-Only Mode

$ rails new my_api --api

--api strips out everything a JSON-only backend doesn't need: no view layer generators, no asset pipeline, no cookie/session/flash middleware, no CSRF protection (there's no HTML form to forge a submission from). The base controller class changes too:

# app/controllers/application_controller.rb class ApplicationController < ActionController::API end

ActionController::API (instead of ActionController::Base) is a deliberately smaller module list — it keeps the parts of Action Controller genuinely useful for a JSON API (strong parameters, rescue_from, before_action) and drops the HTML-rendering machinery this app will never call.

📤 Rendering JSON

# app/controllers/posts_controller.rb class PostsController < ApplicationController def index posts = Post.all render json: posts end def create post = Post.new(post_params) if post.save render json: post, status: :created else render json: { errors: post.errors }, status: :unprocessable_entity end end end

render json: posts calls .to_json on the object automatically, converting an Active Record relation or model instance into a JSON response with the correct Content-Type header set — no separate templating step required for the simplest cases. status: :created/:unprocessable_entity are the same symbol-named HTTP status codes express1-7 covered numerically (201, 422); Rails just lets you write the name instead of memorizing the number.

🏗️ Serializers — Controlling the Shape of the JSON

⚠ render json: model exposes every column, including ones you don't want

render json: post serializes every database column on the model — including things like a soft-delete flag, an internal moderation score, or (on a User) a password_digest column that should never leave the server. This is the JSON-API equivalent of the mass-assignment problem rails1-8's strong parameters solved for incoming data — except here it's the outgoing shape that needs explicit control.

# app/serializers/post_serializer.rb (using ActiveModel::Serializer) class PostSerializer < ActiveModel::Serializer attributes :id, :title, :body, :created_at belongs_to :user, serializer: UserSerializer end # app/serializers/user_serializer.rb class UserSerializer < ActiveModel::Serializer attributes :id, :name # no email, no password_digest end

A serializer declares an explicit allowlist of attributes — the opposite default of "serialize everything." Once PostSerializer exists, render json: post picks it up automatically by naming convention (PostPostSerializer, the same convention-by-class-name pattern rails2-2's Pundit policies used). Nested associations (belongs_to :user) get their own serializer too, so a leaked column on one model can't sneak in through a different model's association.

🔢 Versioning

# config/routes.rb namespace :api do namespace :v1 do resources :posts end end # generates: /api/v1/posts, /api/v1/posts/:id, ... # app/controllers/api/v1/posts_controller.rb module Api module V1 class PostsController < ApplicationController # ... end end end

namespace nests both the URL path (/api/v1/...) and the expected controller location (Api::V1::PostsController) in one declaration — the same resources convention from rails1-2 generating all 7 routes at once, just scoped under a versioned prefix. When breaking API changes are needed later, Api::V2::PostsController can be added alongside the untouched v1 namespace, so existing API consumers keep working against the version they built against.

🔑 Authenticating an API

rails2-1 covered session-based authentication — a cookie holding a session ID, which fits a browser but doesn't fit a mobile app or a separate JS frontend well. API-only apps typically authenticate with a bearer token instead:

# app/controllers/application_controller.rb class ApplicationController < ActionController::API before_action :authenticate_request! private def authenticate_request! token = request.headers["Authorization"]&.split(" ")&.last @current_user = User.find_by(auth_token: token) render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user end end

This is a simplified static-token example — a real app more often issues a short-lived JWT here, exactly the token-based approach the completed Auth & Session Security course (bc1-7) covered in depth, including access/refresh rotation and safe storage. The mechanism differs from Chapter 1's session cookie, but the shape of the check — reject the request before the action runs if authentication fails — is identical.

📜 Rails APIs vs Express REST APIs

Rails (API-only) vs Express (express1-7)

ConcernExpressRails (--api)
RoutesManually defined per method/pathresources :posts generates all 7
Response shapingHand-built response objects per routeSerializer classes, reused across actions
Status codesNumeric (res.status(201))Symbol (status: :created)
VersioningManual route prefixing (/v1/posts)namespace :api do namespace :v1
AuthJWT middleware, hand-writtenbefore_action, token or JWT-based

The philosophical difference is the same one that has run through this entire course: Express gives you the primitives and expects you to assemble them per project; Rails' --api mode is the same convention-over-configuration philosophy from rails1-1, just with the HTML-rendering half of the framework removed rather than the framework replaced by something new.

💻 Coding Challenges

Challenge 1: A JSON Index Action

Write an index action for an API-only CommentsController that renders all comments as JSON, and a create action that renders the created comment with a 201 status on success, or the comment's errors with a 422 status on failure.

Goal: Practice render json: with both success and failure status codes.

→ Solution

Challenge 2: A Serializer

Write a CommentSerializer that exposes only id, body, and created_at, plus a nested user association using a UserSerializer that exposes only id and name (no email or password data).

Goal: Practice controlling exactly what a model exposes to API consumers.

→ Solution

Challenge 3: Versioned Routes

Write the config/routes.rb entry that namespaces a resources :comments route under /api/v1, and give the full class name (including module nesting) the corresponding controller would need.

Goal: Practice namespacing routes and controllers together for API versioning.

→ Solution

💎 A Serializer Is Just Another Centralization Rule

This chapter's serializer allowlist, Chapter 2's Pundit policy, and Course 1's model validations are all the same underlying pattern: a rule about a model, written once, in one predictable location, applied consistently everywhere that model is touched. By this point in the course, spotting "where does this rule live, and is it written in exactly one place" should be a reflex whenever a new Rails feature comes up.

🎯 What's Next

Next chapter: Action Cable — Rails' WebSockets layer, for the cases where even a fast JSON API isn't enough and the server needs to push updates to connected clients in real time, contrasted with Socket.IO from express2-6.