JSON over POST: A simple alternative to REST, gRPC and GraphQL
What if all your API endpoints exclusively used POST with JSON payloads instead of trying to follow REST?
As a software engineer designing an API service, whether it's an internal backend service or a public web API, you have a plethora of API paradigms to pick from.
You could go old school and design a REST API, most likely using JSON. Or you could embrace GraphQL and its rich ecosystem to let API clients perform personalised queries while reducing unnecessary bandwidth usage and database queries. Or maybe you could go the gRPC route and leverage the efficient binary serialisation of Protocol Buffers. Options abound, with Apache Thrift being yet another one.
JSON-based REST APIs
JSON-based REST APIs are the most common out there, presumably because you don't need to spend any effort setting up any particular library or tooling. Most languages and tools offer at least rudimentary support for sending and receiving JSON payloads over HTTP(S).
JSON-based APIs are generally RESTful, or at least "REST-like". REST is based on HTTP semantics, which strictly specify the meaning of each HTTP method (GET, POST, PATCH, PUT, DELETE and so on). There are advantages to following REST principles. For example, most clients will automatically cache the response of GET requests when the server returns the appropriate headers. REST also brings a consistent structure to your endpoints, thanks to resource-based namespacing such as /users/:id
.
However, designing a true REST API can be quite difficult. You may find yourself having a lot of debates with your team members about exactly how to design a specific endpoint, which HTTP method to use (POST, PUT or PATCH?), and so on and so forth. In my last job, we ended up spending several weeks of engineering time writing a detailed guide to designing RESTful endpoints with step-by-step decision trees, so that we could settle all debates once and for all.
REST also means that request parameters are split up between path parameters (/users/123
), query parameters (/users?limit=100
) and a request body, depending on the endpoint. That is, there are three possible ways of passing data to a REST endpoint (four if you count headers).
Request bodies aren't meant to be used in GET and DELETE endpoints. This can be problematic when implementing relatively complex endpoints such as /users/search
. You could use query parameters, but you will quickly find that there is no universally accepted format to represent structured data in URLs. Some backend frameworks such as Node.js interpret a repeated query parameter as a list, whereas others like Ruby on Rails require the query parameter name to end with []
, or else only the first (or maybe last?) value will be used. This can lead to endless arguments and ensuing confusion. In fact, a new HTTP method called SEARCH was drafted in 2015 to solve this problem. That one now looks abandoned, but there is another proposal to introduce a QUERY method. Unfortunately, it will be years before we can use it.
GraphQL and gRPC
GraphQL and gRPC are very different in that they both do away with HTTP semantics.
With gRPC, every request is a Remote Procedure Call. There is no distinction between querying data or mutating it. There is no network-level caching—instead it's an application-level responsibility. Each RPC maps to a POST request over an HTTP/2 connection (see Wireshark analysis). The URL matches the procedure's unique identifier, such as /PersonSearchService/Search
.
GraphQL is conceptually and functionally very different from gRPC, but it does have one thing in common: it exclusively uses POST. In fact, it always hits the same URL, typically /graphql
. It also breaks away from HTTP semantics: both queries and mutations occur over POST. Again, there is no network-level caching.
GraphQL and gRPC are both great options for designing an API. They each come with a rich ecosystem of developer tools that can make them significantly more pleasant to use than REST on a daily basis.
JSON over POST
One approach I have rarely seen discussed, perhaps because REST advocates would consider it heresy, is to build a JSON API that deliberately breaks away from REST principles: JSON over POST.
Here is how it works:
- Every endpoint uses the HTTP POST method.
- Every endpoint has an associated path that clearly describes what it does, without any dynamic path parameters. For example
/users/get
,/users/list
or/users/update
. - Request data is always passed in the request body as JSON. Query or path parameters are never used.
Just like gRPC and GraphQL, this breaks HTTP-based mechanisms like GET request caching (since there are no GET requests anymore). However, it can make both server-side and client-side implementations significantly simpler.
An endpoint is now defined exclusively as a fixed path, a request body schema and a response body schema. We don't need to worry about HTTP methods, path or query parameters, and so on.
That is, each endpoint is equivalent to a function that takes a single, structured parameter (the request body). It's not REST anymore, but it's definitely RPC!
Example in TypeScript
This approach simplifies the implementation of client-side API calls, as well as server-side API endpoints. Let's look at an example in TypeScript.
Here is an endpoint definition I'm using in Preview.js. It takes a few lines of code, which transpile down to a single line of code in JavaScript:
import type { Endpoint } from "@previewjs/api";
export const CheckVersion: Endpoint<CheckVersionRequest, CheckVersionResponse> = {
path: "versions/check",
};
type CheckVersionRequest = {
appInfo: {
platform: string;
version: string;
}
}
type CheckVersionResponse = { ... }
// Output in JavaScript:
// export const CheckVersion = { path: "versions/check "}
Using this endpoint declaration, I can then make a request with:
const checkVersionResponse = await webApi.request(CheckVersion, { appInfo });
Here is the entire implementation of the WebApi
class:
import type { Endpoint, RequestOf, ResponseOf } from "@previewjs/api";
import axios from "axios";
export class WebApi {
constructor(private readonly baseUrl: string) {}
async request<E extends Endpoint<unknown, unknown>>(
...[endpoint, request]: RequestOf<E> extends void ? [E] : [E, RequestOf<E>]
): Promise<ResponseOf<E>> {
const { data } = await axios.post<ResponseOf<E>>(
`${this.baseUrl}${endpoint.path}`,
request
);
return data;
}
}
And for completeness, here are the types exposed by the @previewjs/api
package:
export type Endpoint<Request, Response> = {
path: string;
};
// Extract the first type parameter from Endpoint<A, B>.
export type RequestOf<E> = E extends Endpoint<infer Request, any>
? Request
: never;
// Extract the second type parameter from Endpoint<A, B>.
export type ResponseOf<E> = E extends Endpoint<any, infer Response>
? Response
: never;
That's it. We have a fancy RPC client library in less than 50 lines of TypeScript! It also transpiles down to less than 10 lines of actual JavaScript.
It's even simpler on the server side: all we're doing is implementing POST endpoints that exclusively look at the request body, ignoring path or query parameters. That means that you only have one untrusted value to validate/sanitise (if you use TypeScript, check out ts-shift to automate that).
The advantages of JSON over POST don’t stop at TypeScript/JavaScript. Whatever language you use, your code will be dramatically simpler because your endpoints themselves are simpler.
Breaking out of technical dogma
We've all heard for years that REST is the "correct" way to implement an API. GraphQL and gRPC have brought alternatives, but what if you want pure simplicity? Consider JSON over POST!