PITIful RESTful URLs

If REST is done right, we end up with simplified URLs for resources. Here are some common mistakes made when designing RESTful URLs.

You should definitely check out the presentation Teach a Dog to REST (Pet Store API). It taught me a lot about simple, scalable API design. It takes around 30 endpoints and converts them into two URLs while still maintaining all functionality.

/items/year/2010/mon/05/day/01

We need to filter some items by Year, Month and Day, and decided to do it in the most RESTful way possible. Except:

RESTful URLs are not SEO URLs.

Here are problems with the above approach:

  • If there are many parameters, it produces lots of permutations like /items/year/2010/mon/05/day/01/limit/10/start/20/sort/name
  • If some parameters are optional, URLs end up having empty values like /items/year/2010, /items/mon/05/day/01, /items/year/05/day/01. This either creates a large amount of Path specifications in controllers, or having to manually parse the Path ourselves.
  • Have to go through an extra decision to support order of parameters or not. Becomes more trickier with optional parameters since order becomes undefined.
  • Paths come with implicit expectations of HTTP Methods. POST /items/year/2010 should semantically create an item with year as 2010. DELETE /items/year/2010 should semantically delete all 2010 items.
  • And ambiguities like what happens if POST /items/year/2010 is invoked with a body containing year as 2011. Which year to take — the one in the path or one in the body — or throw a 400 Bad Request?
Good APIs (not just REST) provide only one way of doing a thing.

Teams end up writing a lot of code to parse all these semantics involved with Paths. But this is merely a re-invention of query parameters.

TL;DR — Don’t model query parameters as Path parameters. Query parameters allows you to change the order or number of parameters, forget about having to handle different methods, and results in a clear API specification.

/items/year:2010/month:05/day:01

The practice of using special delimiters in Path parameters. This comes with all the problems as the above, and one more: Most REST frameworks use colon as a special notation for Path parameters (e.g. /users/:id). And having a “:” in the Path creates complex and messy routing rules.

TL;DR — Don’t use special delimiters in paths. And use query parameters already.

/items/active
/items/pending
/items/drafts

Special Sub-Paths. Used to filter resources by state. At first glance they look like parameter-less scopes offering functional value, but the parameter “status” here is actually invisible but implied. This again carries same problems as the above.

  • This causes problems when you want both Active and Pending items,leading to URLs like /items/active+pending or /items/active/pending
  • These often inherit most of the explicit filters (like year/month/day) as well as this implied status. So URLs sometimes end up looking like /items/active/<year/x/month/y/…>
If two different URLs return the same data structure, then reconsider if they are the same
  • In this example, maybe all we needed was a “status” parameter, instead of a special Sub-Path
  • Its fine to have URLs that return different response structures, like /items/by-category which groups every item by category
  • The only reason where same response structures but different Paths are useful is Technical limitations. Certain system filters are not cheap, and might involve some costly aggregation/joins/analytics across other systems. In that case, probably scoping it as a separate URL might be required

TL;DR — Try to merge different paths if they return the same data structure, unless they are computationally expensive.

/articles/
/article/1

Using different plural/singular form for resources. This results in URLs like /people and /person/1 due to differing inflection rules in English grammar.

TL;DR — Stick to only one of plural or singular forms

/orgs/1234/teams
/orgs/1234/teams/1

The practice of Nesting every Relationship in the URL.

  • Whenever a client wants to get a team’s details, the client needs to know beforehand the organization of that team. Otherwise they won’t be able to construct the URL. So they need organization details to GET the team, but they need to GET the team to know the organization! This is a chicken-and-egg problem and results in composite primary keys.
  • API started out by nesting teams under /org/1234/teams. Now there is a new need for listing all the teams separately. This leads to duplication of URLs like /teams and /org/teams and /org/1234/teams. All these URLs end up getting supported for a long time
  • Now again there are two ways to get a team details — /teams/1 and /orgs/1234/teams/1. So to create a team, clients can now call POST /teams/1 with body org_id=1 or call POST /orgs/1234/teams. Again more permutations on Paths
RESTful Resources can have relationships.
But every relationship doesn’t need to be nested.

The way people view and consume data is different from relationships between the data. e.g. In Reddit — Every Post must belong to a User, so it might seem like /users/1/posts could be a good idea. But end clients who consume the service aren’t going to care about the users, they only care about getting /posts.

TL;DR — Treat all resources as First-class Top-level resources, unless you are 100% sure that they will never ever become one (e.g. multi-tenant applications). After quite some time of working with REST, I can safely say this works best. At the very least, make sure that any URL has no more than one ID in the Path Parameters.

/orgs/1234/teams/56/users
/orgs/1234/teams/56/users/78

This deserves a separate specification called NESTful URLs.

/users.json

Suffixing all JSON endpoints as .json. There is a lurking namespace problem here. Most REST/MVC frameworks have namespaced Controllers with relative paths.

@Path("/items")      => everything under /items
ItemsController

@GET("/") => /items/
list() { ... }

@GET("/:id") => /items/1234
get() { ... }

The anomalous .json extension interferes with this in two ways:

@Path("/items.json") # duh
ItemsController
@GET("/:id") # oops! /items.json/1234

@Path("/items")
@GET("/.json") # oops! /items/.json

TL;DRUse HTTP Accepts header. Default extensions mess up with framework path namespacing features.