DEV Community

Cover image for Rails Routing & APIs: What Actually Happens Between the URL and Your Controller
Lucas Luan de Melo
Lucas Luan de Melo

Posted on

Rails Routing & APIs: What Actually Happens Between the URL and Your Controller

When I started studying APIs more seriously, I realized there was a problem with the way I was learning.

I knew how to create a Rails API.

I knew how to write:

resources :products
Enter fullscreen mode Exit fullscreen mode

I knew what GET, POST, PATCH and DELETE were supposed to do.

But I wasn't always able to explain why things worked the way they did.

So I decided to go one step back and review the fundamentals: routing, HTTP, REST and how Rails puts all of these things together.

This is what I learned.

Rails Routing

At its simplest, routing is the thing that connects a URL to some code in your application.

In Rails, this happens in routes.rb.

For example:

get '/about', to: 'pages#about'
Enter fullscreen mode Exit fullscreen mode

If someone requests:

GET /about
Enter fullscreen mode Exit fullscreen mode

Rails knows that it should call:

PagesController#about
Enter fullscreen mode Exit fullscreen mode

Pretty straightforward.

But Rails gets much more interesting when we start using RESTful routes.

resources does a lot of work

Instead of manually defining every route for a resource:

get    '/products',          to: 'products#index'
get    '/products/:id',      to: 'products#show'
post   '/products',          to: 'products#create'
patch  '/products/:id',      to: 'products#update'
delete '/products/:id',      to: 'products#destroy'
Enter fullscreen mode Exit fullscreen mode

Rails lets us write:

resources :products
Enter fullscreen mode Exit fullscreen mode

And generates the conventional CRUD routes for us.

HTTP Verb Action Purpose
GET index List resources
GET show Show one resource
GET new Form for a new resource
POST create Create a resource
GET edit Form to edit a resource
PATCH update Update a resource
DELETE destroy Delete a resource

This is one of the reasons Rails feels so productive.

The framework isn't just giving us routing functionality. It is encouraging a convention.

resource vs resources

This one confused me for a while.

resources represents a collection:

resources :products
Enter fullscreen mode Exit fullscreen mode

There can be many products, so Rails generates an index route.

resource represents a single resource:

resource :profile
Enter fullscreen mode Exit fullscreen mode

There isn't an index because we're talking about one profile.

It is a small difference, but it makes sense once you think about the resource you're modeling.

only and except

We don't always need every CRUD action.

Instead of:

resources :products
Enter fullscreen mode Exit fullscreen mode

we can be explicit:

resources :products, only: [:index, :show]
Enter fullscreen mode Exit fullscreen mode

Or exclude actions:

resources :products, except: [:destroy]
Enter fullscreen mode Exit fullscreen mode

I generally prefer only when designing an API because it makes the exposed interface explicit.

member vs collection

Eventually, you'll need an action that doesn't quite fit the standard CRUD operations.

For example:

resources :products do
  member do
    get :activate
  end
end
Enter fullscreen mode Exit fullscreen mode

This generates something like:

GET /products/:id/activate
Enter fullscreen mode Exit fullscreen mode

The important part is that activate operates on one product.

That's what member means.

For operations on the collection:

resources :products do
  collection do
    get :download_all
  end
end
Enter fullscreen mode Exit fullscreen mode

The resulting route doesn't require a product ID.

So my mental shortcut is:

  • member → one resource
  • collection → multiple resources / the collection

Nested resources

Sometimes resources have a natural relationship.

For example, posts and comments:

resources :posts do
  resources :comments
end
Enter fullscreen mode Exit fullscreen mode

Now Rails can generate routes such as:

/posts/:post_id/comments
/posts/:post_id/comments/:id
Enter fullscreen mode Exit fullscreen mode

This makes the relationship explicit in the URL.

But nested routes can also become difficult to maintain when taken too far. So just because Rails lets us nest resources doesn't mean we should create five levels of nesting.

When the application becomes an API

Now things get more interesting.

Imagine we have:

/api/v1/products
Enter fullscreen mode Exit fullscreen mode

We probably don't want our API controllers mixed together with our regular web controllers.

Rails gives us namespace for this.

namespace :api, defaults: { format: :json } do
  namespace :v1 do
    resources :products
  end
end
Enter fullscreen mode Exit fullscreen mode

Now we can have:

Api::V1::ProductsController
Enter fullscreen mode Exit fullscreen mode

and a URL such as:

/api/v1/products
Enter fullscreen mode Exit fullscreen mode

This is particularly useful when versioning an API.

We can have:

/api/v1/products
/api/v2/products
Enter fullscreen mode Exit fullscreen mode

with different controllers and behavior behind them.

The idea is simple:

Versioning is a way of allowing the old world and the new world to exist at the same time.

Clients using v1 don't suddenly break because we changed something in v2.

namespace vs scope

These two can look very similar, but they have an important difference.

namespace changes both the URL and the controller module.

namespace :api do
  namespace :v1 do
    resources :products
  end
end
Enter fullscreen mode Exit fullscreen mode

This maps to something like:

Api::V1::ProductsController
Enter fullscreen mode Exit fullscreen mode

A scope, on the other hand, can change the URL without necessarily changing the controller namespace.

This becomes useful when organizing routes without wanting to mirror that organization in the controller structure.

But what exactly is REST?

This is where I realized I had been using the word "REST" rather casually.

REST stands for:

Representational State Transfer.

It comes from Roy Fielding's dissertation and describes an architectural style based on a set of constraints.

The six constraints are:

  1. Client-Server
  2. Stateless
  3. Cacheable
  4. Uniform Interface
  5. Layered System
  6. Code on Demand (optional)

The important part here is that REST isn't simply:

"Use JSON and HTTP verbs."

There is considerably more to it.

Client-Server

The client and server have separate responsibilities.

The client is responsible for the user interface and user experience.

The server is responsible for the data and business logic.

They communicate through a defined interface.

Stateless

Each request should contain everything the server needs to understand it.

The server shouldn't need to remember the state of the previous request in order to process the next one.

This is one of those concepts that sounds obvious until you start designing distributed systems.

Cacheable

Responses should indicate whether they can be cached.

Caching isn't just an optimization that happens somewhere in front of your application. It is part of the architectural model.

Uniform Interface

This is probably the most important part of REST.

The interface should be consistent.

Resources should be identified by URLs.

Resources can be manipulated through representations.

Messages should be self-descriptive.

And HATEOAS is part of this constraint as well.

Resource vs Representation

Another distinction that helped me understand REST better:

A resource is the thing we're talking about.

A representation is how that resource is presented.

For example:

Resource:
Product #42

Representation:
{
  "id": 42,
  "name": "Keyboard"
}
Enter fullscreen mode Exit fullscreen mode

The product is the resource.

The JSON is its representation.

The same resource could potentially have different representations, such as JSON or XML.

HTTP is more than GET and POST

When building APIs, it's easy to think about HTTP methods simply as CRUD operations.

But their semantics matter.

For example, idempotency is an important concept.

An operation is idempotent when repeating the same request produces the same intended result.

Methods such as:

GET
PUT
DELETE
HEAD
OPTIONS
Enter fullscreen mode Exit fullscreen mode

are defined as idempotent.

PATCH is more interesting because it can be idempotent, but isn't necessarily so. It depends on what the operation actually does.

That distinction matters when designing APIs that clients might retry.

HTTP status codes

A good API communicates through status codes.

Some that I find particularly useful to keep in mind:

Status Meaning
304 Not Modified
404 Not Found
409 Conflict
422 Unprocessable Entity
429 Too Many Requests

They tell the client something about what happened without requiring the client to interpret an arbitrary application-specific response.

For example:

422
Enter fullscreen mode Exit fullscreen mode

can communicate:

"The request was understood, but the data isn't valid."

While:

429
Enter fullscreen mode Exit fullscreen mode

basically means:

"You're making too many requests. Slow down."

Stateless vs Stateful

This is another distinction worth remembering.

A stateless server doesn't depend on remembering previous requests from a client.

A stateful system does.

For APIs, statelessness is valuable because requests can be handled more independently. This becomes particularly useful when you have multiple application instances behind a load balancer.

CORS and OPTIONS

CORS is another thing that tends to appear when working with APIs.

At a high level, CORS controls whether a browser is allowed to make requests from one origin to another.

The server can tell the browser which origins, methods and headers are allowed.

OPTIONS requests are often involved in this process because the browser can use them to determine what the server permits.

This is one of those topics that feels mysterious until you realize that the browser is enforcing the rules, not your Rails application magically refusing the request.

REST vs GraphQL

REST and GraphQL solve some similar problems in different ways.

With REST, we generally expose multiple endpoints representing resources:

GET /products
GET /products/42
GET /products/42/reviews
Enter fullscreen mode Exit fullscreen mode

The server defines the representation returned by each endpoint.

With GraphQL, we typically have a single endpoint and the client specifies which data it wants.

A very simplified mental model is:

REST is several doors with a fixed menu.

GraphQL is one door where you can order exactly what you want.

Neither approach is automatically better. They optimize for different problems.

HATEOAS

HATEOAS stands for Hypermedia as the Engine of Application State.

The idea is that a response can contain information about what the client can do next.

You can think of it as a kind of GPS for an API.

Instead of the client having to know every possible next URL, the server provides links representing the available transitions.

For example, a response could conceptually contain:

{
  "id": 42,
  "status": "pending",
  "_links": {
    "self": "/orders/42",
    "cancel": "/orders/42/cancel"
  }
}
Enter fullscreen mode Exit fullscreen mode

The client can use the links to understand the available actions.

It's a powerful idea, although it also adds complexity and isn't used by every API that calls itself RESTful.

The Richardson Maturity Model

Another useful way of thinking about APIs is the Richardson Maturity Model.

It describes different levels of RESTfulness:

Level Idea
0 One endpoint / one method
1 Resources
2 HTTP verbs and status codes
3 Hypermedia / HATEOAS

Level 0 is basically the "Swamp of POX": everything goes through a single endpoint, often using POST.

At level 1, we start identifying resources.

At level 2, we properly use HTTP methods and status codes.

At level 3, hypermedia enters the picture.

This was useful for me because it showed that there's a difference between:

"I have an HTTP API."

and:

"I have an API that follows REST principles."

So where does Rails fit into all of this?

Rails gives us conventions and tools that make it very easy to build APIs following these principles.

For example:

namespace :api, defaults: { format: :json } do
  namespace :v1 do
    resources :products
  end
end
Enter fullscreen mode Exit fullscreen mode

From this small piece of routing configuration, we're already expressing quite a lot:

  • The API has a namespace.
  • It has a version.
  • It expects JSON.
  • Products are modeled as resources.
  • Standard HTTP verbs map to standard CRUD actions.

And that's probably the biggest thing I took away from revisiting all of this.

Rails makes a lot of these concepts feel automatic.

But "automatic" doesn't mean "unimportant."

Understanding what Rails is doing underneath the conventions makes it much easier to design APIs intentionally instead of just following patterns because that's what Rails usually does.

And I think that's one of the differences between knowing Rails and understanding what you're building with Rails.


These are my notes from revisiting Rails routing, HTTP and REST fundamentals. There are definitely deeper rabbit holes here, especially around REST constraints, idempotency, caching and HATEOAS—but understanding these fundamentals already makes the day-to-day work with APIs much less mysterious.

Top comments (0)