Building JSON APIs

Ruby on Rails · Advanced · 14 min

Rails is a productive back end for mobile apps and SPAs, and APIs need consistent responses and errors.

rails new myapi --api creates an API-only app: controllers inherit from ActionController::API, and middleware for cookies, sessions, flash and views is left out. A regular app can serve JSON too, from the same models.

Render JSON with render json: @article, status: :created. By default this calls as_json on the object. Control the output with:

  • as_json(only: [...], include: { author: { only: :name } }) for simple cases.
  • Jbuilder templates (show.json.jbuilder), which ship with Rails.
  • Serializer libraries such as Alba or Blueprinter, or plain Ruby objects with a to_h, for larger APIs.

Never expose models wholesale, or new columns (and secrets like password digests) leak automatically.

Consistent errors: use rescue_from ActiveRecord::RecordNotFound to render a JSON 404, and return 422 with { errors: record.errors } for validation failures. Use the right status codes: 201 on create, 204 with head :no_content on delete.

Versioning: namespace :api do namespace :v1 do resources :articles end end puts controllers in Api::V1::.

Authentication: API clients send a bearer token. authenticate_or_request_with_http_token reads the Authorization: Bearer ... header. generates_token_for :api, expires_in: 30.days on the model creates signed, expiring tokens that find_by_token_for(:api, token) verifies; for long-lived random tokens, store only a digest. Rate-limit with rate_limit (Rails 7.2+) and paginate collections with a gem such as Pagy or with limit and offset.

Example

# app/controllers/api/v1/articles_controller.rb
module Api::V1
  class ArticlesController < ActionController::API
    include ActionController::HttpAuthentication::Token::ControllerMethods
    before_action :authenticate!
    rescue_from ActiveRecord::RecordNotFound, with: -> { render json: { error: "not found" }, status: :not_found }

    def index
      render json: Article.published.includes(:author).limit(50)
                          .as_json(only: %i[id title], include: { author: { only: :name } })
    end

    def create
      article = @user.articles.new(params.expect(article: %i[title body]))
      if article.save then render json: article, status: :created
      else render json: { errors: article.errors }, status: :unprocessable_entity
      end
    end
    private
    def authenticate!
      @user = authenticate_with_http_token { |token, _| User.find_by_token_for(:api, token) }
      head :unauthorized unless @user
    end
  end
end

Remember

  • --api apps use ActionController::API with a slimmer middleware stack
  • Shape JSON explicitly (as_json options, Jbuilder or serializers); don't dump models
  • Return proper status codes and JSON errors via rescue_from

Watch out for

  • Rendering models straight to JSON and leaking columns such as password_digest or internal flags.

Check yourself

  1. What does ActionController::API leave out compared to ActionController::Base?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: guides.rubyonrails.org/api_app.html