In this article, we'll explore APIs created using both the REST architecture and a fairly new player called gRPC. Our goal is to understand how to use these architectural styles to build APIs.
What is REST?
In 2000, Roy Fielding wrote his dissertation and introduced the world to an architecture for distributed systems called "Representational State Transfer," or REST for short. He outlined several constraints for system architecture rooted in the principles that drive the web itself. The goal was to leverage decades of wisdom around building distributed systems that helped to ensure good separation of concerns and scalability.
Central to the REST philosophy is the concept of hypermedia and hypertext (like the "H" in HTTP). A RESTful API allows users to interact with resources identified by URLs and encoded in a variety of hypermedia representations (such as JSON, XML, or HTML). Modifying these resources changes the application state. Typically, clients use HTTP as the protocol of choice for building and interacting with RESTful service.
An oft neglected but critical concept to REST is the idea of hypermedia as the engine of application state (HATEOAS). Simply put, instead of clients choosing what methods to invoke to change an application's state, it's the hypermedia itself that determines what resources a client can interact with and at what time. This is similar to how users interact with web pages today. Users only need to visit a single URL and links provided on the resulting page inform the user where they can browse.
REST is designed to leverage all of the existing technologies that make the web work, especially HTTP, TCP, URLs, and hypermedia types like JSON, XML, and HTML. To that end, no special software is required in order to build or use RESTful APIs.
What is gRPC?
The distributed client-server communication style known as "Remote Procedure Call" (RPC) is nothing new, dating back to at least the early 80s. However, Google's implementation known as gRPC was invented in 2015 as a performance-centric framework for enabling web communication between systems. Built to leverage the newer HTTP/2 protocol under the hood, gRPC supports bidirectional communication between clients and servers in addition to streaming capabilities that enable multiple messages per request or response. This makes it very efficient for building high-performance, high-throughput applications. Performance is the killer feature of gRPC.
Unlike RESTful APIs, RPC-based APIs define procedures (or methods) using an Interface Definition Language (IDL). Tools are required to generate server and client stub classes from the interface definition that can be used in a variety of programming languages. For gRPC, Google chose to develop its own format called "Protocol Buffers" (or "protobufs" for short) which benefit from a very efficient wire serialization format designed for speed.
Demonstrating an address book using REST and gRPC
Let's take a look at some sample projects that demonstrate the distinctions between the REST and gRPC architectural styles. All of the projects we'll examine are implemented as a full-stack address book apps with a simple front-end UI built on Svelte and a NodeJS back-end API. You can find the sample project monorepos on GitHub at anthonydmays/grpc-vs-rest.
anthonydmays / grpc-vs-rest
Demo projects for the "gRPC vs REST" session at Hacking With The Homies Developers Conference 2023.
gRPC vs REST Demos
Description
This repo contains three full-stack monorepos demonstrating the REST and gRPC API architectural styles. See the README in each of the subprojects for instructions on how to run each demo.
Author
Anthony D. Mays (@anthonydmays) - Twitter | LinkedIn | Github
Projects
The following is a description of the projects you can demo.
Project | Description |
---|---|
rest-api-app | Address book app built using a REST API built on Express and a Svelte fronted. |
grpc-api-app | Address book app built using a gRPC API. |
grpc-rest-app | Address book app built using a gRPC API with a REST endpoint using an Envoy reverse proxy. |
Resources
- gRPC vs REST: Comparing API Styles in Practice by Anthony D. Mays
- gRPC vs REST: Understanding gRPC, OpenAPI and REST and when to use them in API design by Martin Nally
- Architectural Styles and the Design of Network-based Software Architectures by Roy Fielding
- Use gRPC with…
To keep things simple, I only have a few features I've implemented along with several key constraints.
- Implement basic CRUD operations. Nothing special here in the way of functional requirements. We just want to be able to list all of our contacts and create, update, or delete a single contact.
- Full-stack type safety. We should be able to ensure that our entire codebase is type-safe and supports modern IDE niceties like autocomplete.
- Browser-consumable API. We want to allow any clients to use our API from the browser environment.
- Unit-testable code. It should be fairly trivial to write unit tests for our API to ensure that we have the behavior we expect.
- Minimal diffs. In order to highlight the differences when moving between styles, I've done my best to minimize differences between the sample projects at the expense of some best practices. These demos are not designed to show you how to write production-ready systems, but rather to help you understand each style sufficiently.
Examining the REST API app
First, let's get the API and client up and running. Clone the anthonydmays/grpc-vs-rest repo to your machine, open a terminal, and change into the rest-api-app directory. Follow the README instructions to install dependencies and build the apiTypes
package. The commands are shown below for your convenience:
$ git clone https://github.com/anthonydmays/grpc-vs-rest
$ cd rest-api-app
$ npm install
$ npm run build:apiTypes
In separate terminal windows, run the server by executing the npm run dev:api
command and the client using npm run dev:client
. You should be able to now browse to http://localhost:5173
to see the basic UI.
Let's turn our attention to the API for a moment. I'll use curl
to retrieve a single contact in order to demonstrate what makes our API conformant with REST principles. We'll see from the response how our API satisfies REST architectural constraints.
$ curl -iX GET http://localhost:9090/v1/contacts/1
HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8
Content-Length: 349
ETag: W/"15d-bXqO1UHKOU8wml7G/sca1xVRseU"
Date: Mon, 20 Feb 2023 22:54:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"resource":{"uri":"contacts/1","firstName":"Hedda","lastName":"Ready","email":"hready0@ftc.gov","phoneNumber":"919-521-1661","_links":{"self":{"href":"http://localhost:9090/v1/contacts/1","type":"GET"},"allContacts":{"href":"http://localhost:9090/v1/contacts","type":"GET"},"delete":{"href":"http://localhost:9090/v1/contacts/1","type":"DELETE"}}}}
- Consistent identification of resources: Our API identifies resources by URL. For any resource available to us via the API, there is a unique URL that we can use to access or act upon it.
-
Manipulation of resources through representation: We can see a list of contacts by issuing an HTTP GET request to
http://localhost:9090/v1/contacts
. If we issue an OPTIONS request, we'll see that we can alsoPOST
to this resource to create a new contact if we wish. By sending the JSON-representation of a contact, we'll be able to update properties of that contact. -
Self-descriptive messages: The
contacts
resource is represented using theapplication/json
hypermedia format. We can see exactly the fields available to us and their basic types (string, numeric, array, and anything else JSON supports). - Hypermedia as the engine of application state (HATEOAS): Importantly, the resource also provides us with links that tell us what other operations we can perform on this resource or other related resources.
- Cacheability: Since we're just using HTTP, we already have all the mechanisms we need to indicate whether a resource can be cached or not. We can also use ETags to convey this same information.
- Stateless: Interactions between the client and our API server are stateless. Everything needed for the server to understand a request is provided in the request itself. The server does not store any state about previous requests or responses in order to fulfill new requests.
Now if we look at our client app, we'll see something a bit unusual about the front-end implementation. Since our aim is to abide by RESTful constraints, we are required to use the links provided by our hypermedia resource in order to do anything with the API. It's important to note that there is no URL construction happening in our client. We are simply using whatever we're given from the API.
<!-- packages/client/src/routes/+page.svelte -->
<!-- Paging controls for navigating our list of contacts -->
<nav>
{#if data._links?.firstPage}
<a href="?url={encodeURIComponent(data._links.firstPage.href)}">{'<<'}</a> |
{/if}
{#if data._links?.previousPage}
<a href="?url={encodeURIComponent(data._links.previousPage.href)}">{'<'}</a>
|
{/if}
{#if data._links?.nextPage}
<a href="?url={encodeURIComponent(data._links.nextPage.href)}">{'>'}</a> |
{/if}
{#if data._links?.lastPage}
<a href="?url={encodeURIComponent(data._links.lastPage.href)}">{'>>'}</a>
{/if}
</nav>
/** file: packages/client/src/routes/+page.ts */
import { env } from '$env/dynamic/public';
import type { ListContactsResponse } from '@grpc-vs-rest/api-types';
import type { PageLoad } from './$types';
/** Handles loading data for the page. */
export const load = (async ({ url }) => {
// Anchor links in the UI configure the URL parameter based
// on whatever is provided by the API.
const apiEndpoint =
url.searchParams.get('url') ||
env.PUBLIC_API_ENDPOINT ||
'http://localhost:9090/v1/contacts';
const res = (await (
await fetch(`${apiEndpoint}`)
).json()) as ListContactsResponse;
return res;
}) satisfies PageLoad;
Taking a look at gRPC
Switching it up a bit, let's now examine the gRPC API implementation which you'll find in the grpc-api-app directory. The structure of this project is identical to our rest app, so I'll just focus on the essential bits that have changed. Let's start with the apiTypes
project.
In order to define the contract by which the server and client will communicate with one another, we define a proto with the services, functions, parameters, and model definitions we need. We have a single proto defined in our apiTypes
project under the namespace proto.contacts.v1
.
/** file: packages/apiTypes/proto/contacts/v1/contacts.proto */
// An API for managing a collection of contacts.
service ContactsService {
// List all available contacts.
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse);
// Retrieves a specific contact.
rpc GetContact(GetContactRequest) returns (GetContactResponse);
// Updates a contact with the provided information.
rpc UpdateContact(UpdateContactRequest) returns (UpdateContactResponse);
// Deletes a contact with the provided id.
rpc DeleteContact(DeleteContactRequest) returns (google.protobuf.Empty);
// Creates a contact.
rpc CreateContact(CreateContactRequest) returns (CreateContactResponse);
}
The second big difference is that we now have auto-generated client and server stubs. For this task, I chose to use buf and the protobuf-ts plugin in order to generate idiomatic Typescript classes and objects. Not only do these classes describe the types we'll use in the server and client, but also includes the actual gRPC implementations used to serialize and send messages back and forth across the wire.
# /proto/buf.gen.yaml
version: v1
plugins:
- name: ts
out: src/
opt: generate_dependencies,long_type_string,server_generic,client_generic
Next comes the server. The implementation is fairly trivial, thanks to the straightforward service interface produced by protobuf-ts
. Note that, for this demo, we've ditched Express
as the server and will instead host our service via the built-in node:http2
module.
/** file: packages/api/src/index.ts */
import api = require('@grpc-vs-rest/api-types');
import { Server, ServerCredentials } from '@grpc/grpc-js';
import { adaptService } from '@protobuf-ts/grpc-backend';
import { ServerCallContext } from '@protobuf-ts/runtime-rpc';
import {
createContact,
deleteContact,
getContact,
getContacts,
getContactsCount,
updateContact,
} from './contacts.js';
export class ContactsService implements api.IContactsService {
async listContacts(
request: api.ListContactsRequest,
context: ServerCallContext,
): Promise<api.ListContactsResponse> {
let { pageNumber, pageSize, orderBy } = request;
pageSize = pageSize || 25;
pageNumber = pageNumber ?? 0;
const contacts = getContacts({ pageNumber, pageSize, orderBy });
return {
contacts,
pageNumber,
pageSize,
orderBy,
totalCount: getContactsCount(),
};
}
// Implement remaining server methods.
}
const port = process.env.PORT || 9090;
const server = new Server();
server.bindAsync(
`0.0.0.0:${port}`,
ServerCredentials.createInsecure(),
() => {
server.start();
server.addService(
...adaptService(api.ContactsService, new ContactsService()),
);
console.log(`server is running on 0.0.0.0:${port}`);
},
);
If we now run our client and inspect requests to the API using a packet sniffer such as Wireshark, you'll see that the response for the same contact retrieval method we implemented in REST is substantially smaller in size at 132 bytes compared to 349 bytes of JSON that was delivered previously.
There is one more significant change to note. What might not be obvious is that the gRPC client code is required to run server-side (note that the load function for the main page is named +page.server.ts
to denote a server-side rendered page). That's because the base gRPC client library for JavaScript (grpc/grpc-node) used by protobuf-ts
needs to run on NodeJS.
Browser support for HTTP/2 is still limited at the moment. That means that, unfortunately, our API is not browser-accessible as we set out to design it initially.
Combining the gRPC and REST styles
But what if we could make some changes to enable clients to interact with our gRPC API as though it were a REST service? Our service won't be RESTful since it will still be RPC under the covers, but we will get to access it via HTTP/1.1, URLs, and JSON. Thankfully, we can use an Envoy proxy server to easily accomplish this feat without having to write our own code manually!
All the required changes can be viewed in our last demo, the grpc-rest-app implementation. First, we need to update our proto service interface to help the proxy service make our gRPC service methods available at the right URLs and for the correct HTTP operations. To do this, the Google API HTTP library provides annotations we can add to our proto to describe the correct mappings. The buf
tool allows us to include the googleapis
dependency as a plugin in our buf.yaml file).
/** file: packages/apiTypes/proto/contacts/v1/contacts.proto */
// An API for managing a collection of contacts.
service ContactsService {
// List all available contacts.
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {
option (google.api.http) = {
get: "/v1/contacts"
};
}
// Updates a contact with the provided information.
rpc UpdateContact(UpdateContactRequest) returns (UpdateContactResponse) {
option (google.api.http) = {
put: "/v1/contacts/{id}"
body: "contact"
};
}
// Deletes a contact with the provided id.
rpc DeleteContact(DeleteContactRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {
delete: "/v1/contacts/{id}"
};
}
// Other methods excluded for brevity.
}
As you can see in the example, we use the google.api.http
annotation to indicate for each method what HTTP verb and URL path we want to make it accessible under.
We can now stand up an Envoy
reverse proxy server instance that will be able to read these bindings and generate a service endpoint that will accept requests using the specified URIs. You'll find the implementation in the packages/proxy subfolder complete with a fully configured Docker Compose image. We'll require three things for this to work:
- Envoy. I chose to use the Docker image for simplicity, but you can also install via your favorite package manager and run the provided yaml on your own with a one-line change to the config (see file comments for details).
- gRPC-JSON transcoder plugin. We need to configure this plugin as a filter in Envoy in order to intercept HTTP requests with JSON payloads and transform them into gRPC encoded messages in binary proto format.
-
The proto descriptor file for our service. Using the
buf build
command line option in ourapiTypes
package script, we're able to generate a proto descriptor file that will be used by the transcoder plugin to understand what services and operations are available.
In addition to starting our API and client servers as before, we'll now need to run our proxy server using the docker compose up
command. The client code is also updated to send fetch requests to our proxy service instead of to our backend API directly.
/** file: packages/client/src/routes/+page.ts */
import { env } from '$env/dynamic/public';
import type { ListContactsResponse } from '@grpc-vs-rest/api-types';
import type { PageLoad } from './$types';
/** Handles loading data for the page. */
export const load = (async ({ url }) => {
const baseUrl = env.PUBLIC_API_ENDPOINT || 'http://localhost:8080';
const pageNumber = Number(url.searchParams.get('pageNumber')) || 0;
const orderBy = url.searchParams.get('orderBy');
const apiUrl = `${baseUrl}/v1/contacts?pageSize=25&pageNumber=${pageNumber}&orderBy=${orderBy}`;
const res = (await (await fetch(apiUrl)).json()) as ListContactsResponse;
return res;
}) satisfies PageLoad;
The flexibility of this new approach is that services that want to speak to our API using native gRPC are free to do so, while clients that need to communicate via JSON and HTTP/1.1 won't be left out in the cold.
Since we're using Envoy, there's one more neat trick that we can employ. It turns out that Envoy also support gRPC-Web out of the box, a JavaScript client designed to support gRPC communication from the browser! That means that we can send gRPC messages over HTTP/1.1 as base64 encoded strings or as binary protobufs. Messages will be sent through our proxy and on to our backend service. The advantage of this is smaller and more efficient wire communication which should lead to better performance.
Conclusion
We've covered a lot in this post. I encourage you to take a look at the GitHub repo a bit more and play around to see how everything fits together.
Because things are still evolving in the gRPC ecosystem, new tools and capabilities are being added all the time to make things easier for devs. I recently discovered that Buf
has introduced a new library for supporting gRPC apps that includes browser support for gRPC, gRPC-Web, and their own Connect Protocol for RPC communication. Consider checking it out if you are looking to explore gRPC for your next API.
Designing great APIs is hard. Hopefully, this article has presented you with some options for building APIs that scale well and support your use cases.
Further reading
- gRPC vs REST: Understanding gRPC, OpenAPI and REST and when to use them in API design by Martin Nally
- Architectural Styles and the Design of Network-based Software Architectures by Roy Fielding
- Use gRPC with Node.js and Typescript by Aria Azadi Pour
About the Author
Hey there! I'm Anthony D. Mays, 20 year software engineer who spent 8 years at Google. Now, I'm a career coach and founder of Morgan Latimer Consulting and a senior advisor for Brilliant Black Minds, offering free mock technical interviews to Black software engineers in the US.
Learn about how I went from foster kid in Compton to software engineer at Google in this short documentary on BuzzFeed.
You can find me on Twitter and LinkedIn at @anthonydmays, or check me out as a speaker and blogger at anthonydmays.com.
Top comments (3)
This article is very informative. Thanks!
Thanks for reading!
We also did our fair benchmark with Go examples to compare Protocol Buffers over gRPC vs. JSON over HTTP/1 and HTTP/2. The results are very interesting - packagemain.tech/p/protobuf-grpc-v...