avatarPhuong Le (@func25)

Summarize

API DESIGN

How to Design “Practical” APIs for Your Application

When designing an API, my primary focus is on simplicity, ease of use, and consistency.

Photo by Tamanna Rumee on Unsplash

“What is practical API design?”

When designing an API, my primary focus is on simplicity, ease of use, and consistency.

Now, let’s be clear, there’s no one-size-fits-all solution here.

I’ve been part of many teams and worked on a wide range of projects. What’s perfect for one project might not make sense for another. And that’s totally okay. It’s this variety, this mix of methods and perspectives, that really keeps our work interesting and dynamic.

“What is REST API?”

Think of a REST API like a collection of resources.

Imagine each resource as an object — it has a type, its own data, links to other resources, and a bunch of methods you can use to interact with it.

Disclaimer: I don’t always stick to the strict rules of RESTful APIs, e.g. all those specific status codes like 201, 202, 422,…PATCH operators,… Sometimes I veer off that path a bit.

Before we dive deeper, let’s quickly brush up on the basics of API design.

Consider these examples for operations at the collection level on /orders:

GET /orders // Retrieves a list of all orders or a subset based on applied filters
POST /orders // Creates a new order
PUT /orders // Replaces an entire list of orders with another list, which isn't a common practice
PATCH /orders // Applies partial updates to multiple orders based on filters, not commonly used
DELETE /orders // Deletes all orders or those matching certain filters, use with caution

For item-level operations on an individual order specified by :id:

GET /orders/:id // Retrieves the details of a specific order
POST /orders/:id // Create a new order but with a specific ID
PUT /orders/:id // Replaces the specific order with the provided details
PATCH /orders/:id // Applies partial updates to specific fields of the order.
DELETE /orders/:id // Deletes the specific order.

Resource Name: Singular or Plural?

When it comes to naming resources in APIs, there’s a bit of a debate. Should we use singular or plural names? Let me break it down for you.

There are 3 key points to keep in mind:

  • A resource name is, essentially, its identifier and it needs to be unique.
  • A collection is a type of resource that holds a list of sub-resources, all of the same type.
  • A resource or collection ID is what uniquely identifies that resource, but only within the context of its parent.

Let’s take a common example, think about a folder filled with files so each file has its own name, right? In API terms, you might see something like this:

folders/<folder-id>/files/<file-id>

Personally, I lean towards using plural names. It just feels more standard and logical to me. But the debates we’ve had in this field, especially when it comes to words that don’t follow the regular pluralization rules, like ‘people-person’, ‘child-children’, or some words only have one form.

“But how about this: api/users/{id}/profile/? Why is ‘profile’ singular?”

There are definitely cases where the singular form makes more sense.

Take a look at these examples:

GET /api/forms/login
GET /api/users/{id}/profile

In the first case, we’ve got a bunch of forms, but ‘login’ is just one specific form. That’s why it’s singular.

In the second example, if a user has only one profile, it’s a singular resource. So, we stick with singular because… it wouldn’t make much sense to have multiple profiles under one user ID, right?

And here’s something interesting: you can still dive deeper into a singular resource. Like this:

GET api/users/{id}/profile/addresses/{address-id}

Each user has one profile, but that profile might have multiple addresses, so ‘addresses’ is plural.

Custom actions

Let’s talk about those tricky situations where you’ve got actions that don’t quite fit into the usual CRUD mold… I’m talking about things like ‘undelete’, ‘publish’, or ‘sell’.

You’ve probably run into this, right? Where the standard CRUD approach just doesn’t cut it? So, what do we do?

Well, we’ve got a couple of main strategies to handle these custom actions:

Slash after the resource name

It’s pretty straightforward. For instance, if you want to restore a file, you might have an endpoint like:

POST /files/{id}/restore

This feels intuitive, right? Like you’re performing an action directly on the resource.

The colon method

This one’s got some big-name backing — Google APIs use it a lot. It looks like this: POST /files/{id}:restore. It’s become quite a popular convention and here’s how it shows up in Google’s Design:

POST /v1/{resource}:getIamPolicy
POST /v1/{resource}:setIamPolicy

“Why not just stick with slashes all the way?”

Well, using a slash, like:

POST /v1/{resource}/getIamPolicy

This can get a bit messy.

It kind of implies that getIamPolicy is a sub-resource of {resource}, which it isn't and that's a bit of a... headache?

Despite this, a lot of devs still prefer the slash method. It’s familiar, it’s in line with standard URL conventions, and let’s be honest, sometimes sticking with what you know just feels easier.

Versioning

We all know that APIs evolve and with evolution, sometimes you end up breaking stuff.

Maybe you add new features, tweak some, or even drop what’s not working. In this ever-changing landscape, you definitely don’t want to leave your users hanging with outdated or broken functions.

Let’s talk about “breaking changes”. Here are a few classic examples:

  • Renaming a field: This one’s a classic. Change the name of a field, and suddenly, all the existing integrations start tripping over themselves. Your users who’ve coded to the older version are not having a great day.
  • Changing a field’s type or format: Switching a field from a string to an integer, or flipping around the date format. It might not crash and burn, but it sure can cause some weird, unexpected behaviors.
  • Tweaking field validation rules: Say you decide to change the max length of a string or mess around with regex patterns.
  • Flipping a field from optional to required: This one’s sneaky, a field that was once optional is now mandatory, and all those requests missing the field? They’re failing.

So, how do we handle this mess? One word: Versioning.

URL versioning

This method is pretty straightforward, you just slap the version number in the URL, like /v1/orders. It's easy to grasp, but it's also pretty rigid, why?

New version? New URL.

But if you want to get a bit more sophisticated, you can try channel-based versioning. We’re talking about paths like

/v1/stable/orders
# == /v1/orders 

/v1/beta/orders
# == /v1beta/orders

Here’s the lowdown on what these versions typically mean:

  • v1alpha (Alpha Version): This is an early version, often unfinished, and mainly for internal testing. Don’t expect stability here; it’s all about experimenting and getting that initial feedback.
  • v1beta (Beta Version): A bit more polished than alpha. It’s out there for some public testing, but still a work in progress. Still, Beta is closer to the final deal but might still get some tweaks based on user feedback.
  • v1 (Stable Version): This is the big leagues, after surviving alpha and beta, the API is ready for prime time as a stable release. It’s got all the bells and whistles, tested and ready for general use.

The alpha and beta stages are really important for ironing out the kinks and really nailing down what users need.

Header versioning

Alright, let’s dive into header versioning, the version information hides in the HTTP request headers.

It’s kind of like a secret code, not immediately visible, but super super flexible. The cool part? You can switch versions without messing with your URLs.

But it might be a bit sneaky for your clients to track, and anything hidden always has the potential to be buggy.

Query parameter versioning

Now, this one’s a bit like URL versioning’s cousin. Instead of the version living in the URL, it hangs out in a query parameter. You’ve seen URLs like /orders?version=1.0, right? This method is pretty easy to implement and offers flexibility.

“So, should we even use versioning?”

The million-dollar question, I will take a ChatGPT answer… “it depends”.

If you’re working on an internal API, just for your own use, or if you need update all your clients whenever there’s an API change, then you might not need versioning. Otherwise, you will end up stuck forever with v1, since it makes no sense to change the version.

Idempotancy

Idempotancy… don’t worry, it’s not just a fancy word to throw around in meetings. It’s actually a crucial concept for making your API reliable.

Think of idempotency as a promise of predictability.

In simple terms, it means doing the same thing over and over and always getting the same result, particularly when it comes to the side effects of your operations.

Take this example:

  1. You send a request to create a new user.
  2. Bam — you’ve got a new user.
  3. Send that same request again (by mistake, or maybe your internet connection is acting up)…
  4. And you don’t get a second user, instead, you get the same outcome as the first time.

That’s idempotency in action.

So, when does idempotency really matter? Let’s look at some HTTP methods, assume there’s no concurrent request in the middle:

  • GET (idempotent): This method is naturally idempotent, you’re just fetching data, not changing anything. Doing it once or a hundred times, the result’s the same.
  • POST (non-idempotent): Here’s where things get tricky. POST is like saying, “Hey, create something new”. Do it twice, you end up with two things. Definitely not idempotent.
  • PUT (idempotent): This one’s usually idempotent since it either updates a resource or creates it if it’s not there. Do it multiple times with the same data, and the end result stays the same.
  • DELETE (idempotent): Deleting something is idempotent too, once it’s gone, it’s gone and deleting it again won’t make it “more gone”.
  • PATCH (potentially idempotent): PATCH can go either way, if it always updates a resource the same way, it’s idempotent. But if each PATCH adds something new, like incrementing a number, then it’s not.

Now, how do we keep things idempotent, especially for those naturally non-idempotent POST requests?

One neat trick is using a unique “transaction ID” or “request ID”.

This way, if the same POST comes in multiple times, your system recognizes it and says, “Hold up, we’ve already handled this. No need to order another pizza.”

Take Stripe, for example, they use request IDs to manage payment attempts and this smart move prevents the headache of double charges (I guess).

Stripe API Reference — Request IDs

Pagination

Now you’ve got a ton of data, like a really long list of orders or something.

It’d be a bit much to send all that data in one go and that’s where pagination steps in. It’s all about breaking that data into more manageable chunks, or “pages”.

Page-based pagination

This one’s an oldie but a goodie, super straightforward.

You’ve got two key parameters: the page number and the size, which is just the number of items you want per page.

The API then serves you the data for that specific page, here’s how it looks:

# page 1
GET /orders?page=1&size=10

# page 2
GET /orders?page=2&size=10

Here’s a twist.

Some APIs talk about “offset” and “limit” instead of “page” and “size”. This comes more from database lingo, it’s like: “Start from this point (offset) and give me this many items (limit).”

# page 1
GET /orders?offset=0&limit=10

# page 2
GET /orders?offset=10&limit=10

Cursor-based pagination

Think of cursor-based pagination like using a bookmark.

You’re basically telling the API, “Hey, start from this point “sdkfj934jf983” right where this specific record is” and it’s super useful for data that keeps updating, like a live feed.

“Why is cool?”

It helps avoid pesky issues like skipping or seeing the same record twice, which can happen if data changes while you’re still flipping through pages.

And, it’s not too hard to set up. The server sends you a batch of records along with a cursor, which is like a pointer to where the next batch starts. When you want more data, just send that cursor back to the server.

Here’s a quick look at how it works:

GET /orders?cursor=123

# response
{
  "orders": [
    {
      "id": 124,
      "name": "Order 124"
    },
    {
      "id": 125,
      "name": "Order 125"
    },
    {
      "id": 126,
      "name": "Order 126"
    }
  ],
  "cursor": "126"
}

“This is cool, but how do we know when we’ve seen everything?”

Good question! The server can give you some extra info, like “hasNext”, “hasPrevious”, “totalRecords”, even a “previousCursor”, to cover different scenarios.

“So, what’s the catch with cursor-based pagination?”

Well, it’s not perfect.

Unlike page-based pagination, where you can jump to any page, cursor-based pagination doesn’t let you skip straight to a specific data set.

But it’s brilliant for stuff like infinite scrolling or live feeds.

One heads-up, though: it usually depends on having a stable order in your data, like timestamps or IDs. If your data’s order keeps changing, cursor-based pagination can get a bit messy, maybe even showing you duplicate or missed records.

Filtering + Sorting

Another common pattern in API design, two bread-and-butter techniques: filtering and sorting.

Filtering

Filtering is a way to zoom in on what you’re really interested in.

You’re basically narrowing down your dataset based on specific criteria. You’ll use query parameters for this, and the great thing is, you can mix and match multiple filters in one go. Like this:

GET /products?price=20&brand=Nike

Sorting

Sorting, on the other hand, is all about putting your data in order. You can arrange it ascending or descending based on various fields.

# Default is usually descending
GET /articles?sort=publish_date

# But you can specify the direction
GET /articles?sort=publish_date,asc

# And even sort by multiple fields
GET /articles?sort=publish_date,asc;title,desc

Rate Limit

Switching gears to rate limiting, a tactic to keep the number of requests a user can make in check, within a set time frame.

Kinda important for managing server load, stopping abuse, and making sure everyone gets a fair shake at using your API.

I’ve actually written a detailed post about rate limiting strategies on my work, definitely give it a look for more in-depth insights.

When someone hits the rate limit, it’s standard to send back a 429 HTTP status code, that’s ‘Too Many Requests’. Along with it, a friendly message like, “Rate limit exceeded. Try again in X minutes” helps set expectations.

Another pro tip: Keep your clients in the loop with HTTP headers.

You can use things like X-RateLimit-Limit: 1000, X-RateLimit-Remaining: 500, or X-RateLimit-Reset: 1588377600.

Retry-After: 120 is another useful one.

If you enjoyed this article, consider trying out the AI service I recommend. It provides the same performance and functions to ChatGPT Plus(GPT-4) but more cost-effective, at just $6/month (Special offer for $1/month). Click here to try ZAI.chat.

Software Development
API
Programming
Recommended from ReadMedium