JSON APIs

Updated:

This article discusses communication between a server and a client using JSON. I used to create non-standard responses as it hasn't been clear to me how they should be formatted and I got misled by others.

Required Headers

There exists many headers but only Accept and Content-Type headers are needed in JSON APIs. For example, in request you need

Accept: application/json

and in responses

Content-Type: application/json

Conventions

The JSON APIs generally follows the Restful principles. The resource name is in plural, and the endpoints do not have a version prefix.

GET /books
POST /books

GET /books/id
PUT /books/id
DELETE /books/id

Versioning individual endpoints can be very difficult to maintain as it duplicates the endpoints and logic in the backend. The hot reloading speed is also affected by the number of endpoints so use a header and handle different version in runtime.

Api-Version: 2022-08-13

A general principle to name JSON fields is to use same convention as the client which is likely camelCase. When returning the responses, the body should not be wrapped inside data as it leads to situations where the frontend has confusing fields like data.data.

Single Resource

Some fields should be returned in every response as their use is common, the bare minimum is to include id and the date of creation. The response should not be enveloped.

GET /books/614a71d0-8e7f-4eae-86c5-c67217660efc
{
  "id": "614a71d0-8e7f-4eae-86c5-c67217660efc",
  "title": "Introduction to Algorithms",
  "createdAt": "2022-08-13T12:59:54.584Z"
}

Multiple Items

A common mistake in returning multiple items is to return a bare array, which makes it very hard to paginate the results. The fix will also lead to downtime because the server and client will be out of sync for a while. For these reasons, the response should be enveloped.

GET /books
{
  "items": [
    {
      "id": "02b58150-f9b0-4001-996e-66c9067b7684",
      "title": "Structure and Interpretation of Computer Programs"
    }
  ],
  "page": 1,
  "pages": 10,
  "totalCount": 1000
}

It is also common to support some filter operations in the backend when query parameters are used.

Error Responses

400 Bad Request

Returns an explanation of what's wrong with the request and only use for errors that originates from the client.

{
  "headers": ["API version 2022-07-12 is not supported!"],
  "query": {},
  "body": {
    "quantity": {
      "code": "minValue",
      "message": "Quantity should be greater than 0."
    }
  }
}

The format of desired error object depends on situation, but it could be should return enough information to developer integrating the API. I recommend having specific error messages in frontend and that error payload should never return messages that is shown to users.

401 Unauthorized

Use when Authorization header is not supplied in request or the token is not valid. Do not return any info on what is wrong.

{
  "message": "Supply valid Authorization header!"
}

403 Forbidden

The client or user does not have permission to view the resource. The preferred response is empty body, but you might

{
  "message": "Upgrade to our premium plan to access the resource!"
}

429 Too Many Requests

Use this status code if you throttle the requests, include the Retry-After header in seconds.

Content-Type: application/json
Retry-After: 3600
{
  "message": "Daily request quota 1000 is exceeded.",
}

How to handle responses in client?

The common errors need to be handled at least, one possible way is to define standard methods for them.

const internalResponseHandlers = {
  200: handleOK,
  400: handleBadRequest
}

const handleBadRequest = async response => response.json()

class Requests {
  constructor(responseHandlers) {
    const requiredStatusCodes = [200, 400]
    const handledStatusCodes = Object.keys(responseHandlers)
    requiredStatusCodes.forEach(statusCode => {
      if (!handledStatusCodes.includes(statusCode)) {
        console.warn(`${statusCode} is not handled!`)
      }
    })
    this.responseHandlers = responseHandlers
  }

  async get(url) {
    const response = await fetch(url)
    const preparedResponse = internalResponseHandlers[response.status](response)
    return this.responseHandlers[response.status](preparedResponse)
  }
}

Why not just use if-else statements? Because it causes great inconsistency between developers, and changing the abstracted fetch is always risk because something might depend on bad implementation.