Designing and Building RESTful JSON APIs in Ruby on Rails

1. Introduction to Web APIs and Structured Data

An Application Programming Interface (API) provides a standardized contract that enables distinct software applications to communicate and exchange data programmatically over networks. In modern web architectures, a backend application rarely serves only a single type of client. Instead, it must support diverse consumers:

  • Human Users via Web Browsers: The backend delivers server-rendered HTML documents containing visual formatting, cascading styles, and layout elements such as <table>, <tr>, and <td> tags.
  • External and Automated Systems: Automated scripts, background workers, mobile applications, and third-party integrations (such as a nightly inventory synchronization tool) require raw, predictable data structures without the overhead of visual presentation markup.

Relying on HTML parsing (commonly referred to as screen scraping) for automated system integrations creates fragile architectures. When user interface styling, layout structures, or markup hierarchies change, integrations that depend on HTML parsing immediately break. To provide resilient interfaces, web applications expose dedicated endpoints that return raw, structured data.

In modern architectures, JavaScript Object Notation (JSON) is the standard format for API data exchange. JSON maps directly to core native data structures across programming languages, such as Ruby Hashes or Python Dictionaries:

{
"id": 12,
"tag": "UNIT-2026-X",
"model": "Sensor Hub"
}


2. Core Architectural Principles of REST

Representational State Transfer (REST) is an architectural style that governs network-based software communication. Designing a RESTful API requires adhering to several foundational constraints:

  • Resource Identification via Uniform Resource Locators (URLs): Every conceptual domain entity (resource) is identified by a unique, predictable URL.
  • Standardized HTTP Verbs: Actions performed on resources are driven by explicit HTTP methods rather than arbitrary verbs placed within the URL path:
    • GET: Retrieves a representation of a resource or collection without modifying server state.
    • POST: Creates a new resource.
    • PATCH / PUT: Updates an existing resource representation.
    • DELETE: Removes an existing resource.
  • Multiple Representations: A single domain entity can be represented in multiple formats (e.g., HTML for interactive web browsers, JSON or XML for programmatic consumers).
  • Statelessness: Every HTTP request from a client must contain all necessary context and authentication credentials. The server does not maintain conversational session state across requests.
  • Direct Responses Without Redirection: While traditional web interfaces use HTTP redirects (e.g., redirecting to a show page following a successful form submission), REST APIs do not issue 3xx redirects. Instead, mutation actions process the request and immediately return the newly created or updated payload directly with appropriate semantic HTTP status codes.


3. URL Routing, Namespacing, and Resource Conventions

Ruby on Rails relies on convention over configuration. RESTful API endpoints should mirror standard resource conventions, isolated under an explicit API namespace and version segment.

URL Structure and Conventions

Resource endpoints must use pluralized English nouns, map operations to standard HTTP verbs, and avoid embedding action verbs within path segments.

Operation

HTTP Verb

Recommended Endpoint

Anti-patterns to Avoid

Index (List)

GET

/api/v1/units

/api/v1/getUnits, /api/v1/unitList

Show (Detail)

GET

/api/v1/units/:id

/api/v1/showUnit/12, /api/v1/units?id=12

Create

POST

/api/v1/units

/api/v1/units/create

Update

PATCH

/api/v1/units/:id

/api/v1/units/update/12

Destroy

DELETE

/api/v1/units/:id

/api/v1/units/destroy/12

Nested Resources

Hierarchical relationships between entities are expressed through nested resource paths while retaining consistent REST conventions:

GET /api/v1/item_models/3/units/12/loans/7

In Rails, nested routes and API versioning are declared using namespace and resources blocks within config/routes.rb:

# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1, defaults: { format: :json } do
resources :item_models do
resources :units do
resources :loans
end
end
end
namespace :v2, defaults: { format: :json } do
# Future versioned routes
end
end
end

Specifying defaults: { format: :json } instructs Rails to treat incoming requests as JSON by default without requiring a explicit .json file extension appended to the URI path.


4. Rails Controller Architecture: Web vs. API

Standard Rails web applications structure controllers under ApplicationController, which inherits from ActionController::Base. While suitable for HTML template composition, ActionController::Base includes modules unnecessary for stateless APIs.

Inheriting from ActionController::API

Rails provides ActionController::API, a streamlined base class optimized for data serialization.

# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ActionController::API
# Shared authentication, exception handling, and filters
end

Resource controllers inherit from this base class and reflect the route namespace hierarchy in their directory location and Ruby constant scope:

# app/controllers/api/v1/units_controller.rb
class Api::V1::UnitsController < Api::V1::BaseController
# Resource actions
end

Architectural Comparison

Feature / Concern

ActionController::Base (Standard Web)

ActionController::API (API Only)

Output Type

HTML views rendered via ERB/HAML templates.

Structured data representations (JSON, XML).

Session & Cookies

Enabled for server-side sessions and flash messages.

Disabled by default to enforce stateless interactions.

CSRF Protection

Enforced via authenticity tokens (protect_from_forgery).

Disabled; APIs rely on token headers (Bearer/JWT).

View Overhead

Loads template compilation and layout engines.

Excluded, minimizing memory and execution latency.


5. Response Formatting, Serialization, and Data Protection

Directly serializing raw database models via render json: @unit exposes internal database column names, leaks sensitive data (e.g., password hashes, private keys), and tightly couples the public interface to the internal database schema.

Explicit Serialization via Builder Methods

Payloads should be explicitly formatted into structured Ruby hashes before rendering to ensure stability and data encapsulation:

# app/controllers/api/v1/units_controller.rb
class Api::V1::UnitsController < Api::V1::BaseController
def show
unit = Unit.find(params[:id])
render json: unit_json(unit), status: :ok
end
private
def unit_json(unit)
{
id: unit.id,
asset_tag: unit.asset_tag,
accepted_at: unit.accepted_at,
available: unit.available?,
item_model: {
id: unit.item_model.id,
name: unit.item_model.name,
brand: unit.item_model.brand
}
}
end
end

Granularity Differences: Index vs. Show

  • Index (GET /api/v1/units): Returns collections of lightweight summary objects containing only identifying and high-level fields.
  • Show (GET /api/v1/units/:id): Returns detailed representations including deeper nested relationships and computed attributes.
# Transforming collections using functional mapping
def index
units = Unit.includes(:item_model)
render json: units.map { |unit| unit_summary_json(unit) }, status: :ok
end

The map method transforms the enumerable collection of ActiveRecord objects into an array of structured hashes matching the desired API schema.


6. Query Optimization: Mitigating the N+1 Queries Problem

When returning collections with associated data, iterating over records and accessing relations inside serialization methods introduces the N+1 queries problem.

The N+1 Mechanism

Consider this controller action:

# Inefficient implementation
def index
units = Unit.all
render json: units.map { |unit| unit_json(unit) }
end

Executing this code results in:

  1. 1 initial query to retrieve all N units: SELECT * FROM units;
  2. N additional queries executed within unit_json to fetch the related item_model for every single unit: SELECT * FROM item_models WHERE id = ?;

For 100 units, the application executes 1 + 100 = 101 database queries.

Resolution via Eager Loading (includes)

ActiveRecord's .includes method preloads associated records, eliminating redundant database operations:

def index
units = Unit.includes(:item_model)
render json: units.map { |unit| unit_json(unit) }, status: :ok
end

With eager loading, query execution is optimized from O(N) database round-trips to O(1) constant queries:

SELECT "units".* FROM "units";
SELECT "item_models".* FROM "item_models" WHERE "item_models"."id" IN (1, 2, 3, ...);


7. Pagination for Collection Endpoints

Querying unbounded collections directly from database tables risks memory exhaustion and performance degradation. APIs must paginate large collections using database LIMIT and OFFSET strategies.

Paginated responses separate resource data from collection metadata:

{
"data": [
{ "id": 51, "tag": "U-51" },
{ "id": 52, "tag": "U-52" }
],
"meta": {
"page": 2,
"per_page": 50,
"total_count": 180
}
}

Clients consume this metadata using query parameters (e.g., GET /api/v1/units?page=2&per_page=50) to traverse datasets without placing excessive load on backend resources.


8. Defensive Mutations, Strong Parameters, and Status Codes

API controllers must handle mutations defensively, sanitizing incoming parameters and returning explicit HTTP status codes along with actionable error payloads.

# app/controllers/api/v1/units_controller.rb
class Api::V1::UnitsController < Api::V1::BaseController
before_action :authenticate_api_user!, only: [:create]
def create
unit = Unit.new(unit_params)
if unit.save
render json: unit_json(unit), status: :created
else
render json: { errors: unit.errors.full_messages }, status: :unprocessable_content
end
end
private
def unit_params
params.expect(unit: [:accepted_at, :item_model_id])
end
end
  • HTTP 201 Created: Confirms successful resource creation and returns the serialized entity representation.
  • HTTP 422 Unprocessable Content: Indicates that semantic model validations failed on save.
  • Parameter Filtering (params.expect / Strong Parameters): Protects mass-assignment boundaries by whitelisting permitted input keys.


9. Centralized Exception Handling

Rather than writing inline rescue blocks across individual controller actions, exceptions should be managed centrally inside Api::V1::BaseController using rescue_from.

# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ActionController::API
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
private
def handle_not_found
render json: { errors: "Resource not found" }, status: :not_found
end
def handle_bad_request(exception)
render json: { errors: exception.message }, status: :bad_request
end
end

Standard API HTTP Status Codes

  • 400 Bad Request: Malformed request syntax or missing required parameters.
  • 401 Unauthorized: Missing or invalid authentication credentials.
  • 404 Not Found: The requested URI or database record does not exist.
  • 422 Unprocessable Content: Request was syntactically valid, but business validation failed.


10. API Authentication via Bearer Tokens

Stateless APIs avoid session cookies for machine-to-machine integrations, requiring credentials to be passed in standard HTTP headers.

Clients provide authentication tokens via the Authorization header using the Bearer scheme:

POST /api/v1/units HTTP/1.1
Host: api.example.com
Authorization: Bearer e4d909c290d0fb1ca068ffaddf22cbd0
Content-Type: application/json

Token validation is configured in the base controller using Rails built-in HTTP authentication helpers:

# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
private
def authenticate_api_user!
authenticate_or_request_with_http_token do |token, _options|
ApiToken.exists?(token: token)
end
end
end


11. Cross-Origin Resource Sharing (CORS)

Web browsers enforce the Same-Origin Policy (SOP) to prevent client scripts loaded from one origin from reading data from another origin without authorization.

Origin Definition

An origin is defined by the combination of three components: Scheme (Protocol) + Host (Domain) + Port:

https://department.example.com:443
└─┬─┘ └─────────┬──────────┘ └─┬─┘
Scheme Host Port

When client-side JavaScript executing on https://department.example.com attempts an asynchronous fetch() request to https://loandesk.example.com/api/v1/units, the browser will discard the response unless the backend explicitly returns the Access-Control-Allow-Origin header permitting that origin.

CORS Configuration in Rails

Cross-origin rules are configured via middleware using the rack-cors gem:

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "https://department.example.com"
resource "/api/*",
headers: :any,
methods: [:get, :post, :options],
max_age: 600
end
end

For open public APIs, origins "*" can be configured, though wildcard origins cannot be combined with credentialed requests (credentials: true).


12. Client-Side Rendering and Preventing Cross-Site Scripting (XSS)

In decoupled architectures, the frontend fetches JSON from the API and dynamically builds the Document Object Model (DOM).

Asynchronous Fetch Handling

Because the browser fetch() API does not reject promises on HTTP 4xx or 5xx status codes, scripts must explicitly verify response.ok:

// equipment.js
async function loadAvailableEquipment() {
const response = await fetch('https://loandesk.example.com/api/v1/units');

if (!response.ok) {
console.error('Fetch failed with status:', response.status);
return;
}
const units = await response.json();
const list = document.getElementById('equipment-list');
list.innerHTML = '';
for (const unit of units) {
const li = document.createElement('li');
// Using textContent avoids executing malicious embedded scripts
li.textContent = ${unit.department} — ${unit.name};
list.appendChild(li);
}
}
document.addEventListener('DOMContentLoaded', loadAvailableEquipment);

<!-- index.html -->
<ul id="equipment-list"></ul>
<script src="/assets/equipment.js"></script>

XSS Prevention in DOM Manipulation

When untrusted data is injected into HTML using innerHTML, any embedded <script> tags or malicious event handlers (e.g., <img src=x onerror=alert(1)>) will execute in the user's browser context. Using textContent ensures that incoming data is parsed strictly as plain text, eliminating client-side script execution vulnerabilities.


13. System Architecture Comparison: Monolith vs. Split Frontend-Backend

Modern web systems choose between monolithic server-rendered designs and separated frontend-backend architectures based on project constraints:

Architectural Metric

Server-Rendered Monolith (e.g., Rails / Django)

Split Frontend & Backend (e.g., React/JS + Rails API)

Primary Rendering

Server generates complete HTML templates.

Browser compiles and updates DOM from JSON.

Coupling

Tightly coupled view layer and business logic.

Completely decoupled codebases and lifecycles.

CORS Overhead

None (same origin serves assets and data).

Mandatory CORS configuration across domains.

Multi-Client Support

Requires separate APIs for non-browser consumers.

Single JSON API serves web, mobile, and third parties.

Deployment

Single unified deployment pipeline.

Independent deployments, scaling, and tech stacks.