10
Designing a network-efficient API

This chapter covers

  • Web API network communication concerns
  • Using compression, caching, and conditional requests
  • Optimizing API design to make fewer calls and exchange less data

So far, we’ve focused on designing APIs that provide usable, secure, and evolvable representations of goals that make sense for consumers and hide internal concerns. But in reality, we’ve learned to design ideal laboratory APIs, ignoring most of the context in which they are used—especially the network context.

Network communication efficiency is an important topic that any API designer must be aware of. Indeed, communication efficiency is important in our day-to-day lives. When you have a conversation with someone, either by speaking or by instant message or email, sometimes you want the full story to get all the possible background information, and sometimes you want the person you communicate with to get straight to the point and tell you only the bit you need to know. If you get the full story, but you wanted just a specific bit of it, you will have to waste time listening to or reading all of it to get what you want. This can be frustrating, and it can even have serious consequences, like missing out on an opportunity.

Choosing the wrong way of communicating information can have negative impacts in our daily lives. The same is true for APIs that provide inefficient network communications, as shown in figure 10.1.

10-01.png

Figure 10.1 Network concerns influence API design

On mobile phones, network-inefficient APIs can have a significant impact on the developer experience, making it hard or even impossible to consume these without slowing the user interface and draining the device’s battery, which also negatively impacts the user experience. Even with modern devices and networks, this is less of a concern than it once was for server consumers. Such inefficient APIs can have a significant impact on network bandwidth usage. This can be problematic for on-premise infrastructures with limited capacity. There can be impacts on the provider side too. Providers can face network congestion on on-premise infrastructures or excessively large bills on cloud infrastructures.

Network communication efficiency can be a major concern, and as an API designer, you contribute to it. API designers must have some basic knowledge about network communication concerns. This includes an understanding of how to avoid or solve network problems by taking advantage of the API’s underlying protocol or by creating APIs that are network-efficient by design.

10.1 Overview of network communication concerns

This book is about remote APIs and web APIs, in particular. In section 1.1, you saw that such APIs allow consumers to interact with a provider over a network. We may forget it, but if network capabilities keep growing and growing, network communication efficiency can still be an important matter on both sides of the wire in certain contexts. As an API designer, you must be aware of network communication concerns because they can have an impact on your designs. To investigate this topic, we will analyze from a network perspective how the Awesome Banking App, a mobile application running on a mobile phone connected to a not-so-good 3G network, uses a slightly modified version of the Banking API.

10.1.1 Setting the scene

Let’s set the scene to begin. Figure 10.2 shows the goals provided by the Banking API, and figures 10.3 and 10.4 show how the three different screens of the Awesome Banking App use all of these.

10-02.png

Figure 10.2 The Banking API

The dashboard screen (figure 10.3) shows all owners whose accounts the user has access to and highlights the one corresponding to the user. For each owner, it shows their title and name, the combined balances of their checking and savings accounts, and the sum of all their account transactions for both types of accounts for the last three months (for simplicity’s sake, we’ll assume all transactions are withdrawals).

10-03.png

Figure 10.3 The Awesome Banking App consumes the Banking API’s goals.

10-04.png

Figure 10.4 The Awesome Banking App makes too many calls.

To do that, it first lists owners to get their IDs and names and the user flag, which indicates which owner corresponds to the user. For each owner listed, it uses the read owner goal to get their title. Then it lists accounts to get the IDs and balances of each owner’s accounts. Next, it uses the read account goal to get the account type for each account. And finally, it lists transactions for each account to get their amounts and sum them all.

When the user taps an owner line, the application switches to the owner’s accounts list screen. This screen shows the selected owner’s title, full name, home address, and owned accounts. For each account, it shows its type (checking or savings), ID (A1, for example), balance, and transaction sum for the last three months.

The application begins by using the read owner goal to get the account owner’s title and full name. It then lists addresses to get the owner’s home address. Next, it lists accounts to get the IDs and balances of each of the owner’s accounts and uses the read account goal to get the account type for each account. And finally, it lists transactions for each account to get the transaction amounts and sum them.

When the user taps an account line, the application switches to the account’s detailed information and transactions screen. This screen shows the account type, ID, balance, and owner(s), which the application gets by using the read owner and read account goals. It then lists all the recent transactions, precisely, for the last three months, using the list transactions goal and retrieving the transaction ID and balance for each. And finally, for the last 25 transactions only, it uses the read transaction goal to get the labels and dates. It displays the five most recent transactions (more transaction detail information will be fetched if users scroll through the list).

That’s many API calls—65 to be precise. Your API designer’s sense is probably already telling you that there is something wrong here. Indeed, the Awesome Banking App’s users complain that it is too slow, drains their battery, and uses too much of their data allowance. And it’s no better on the other side! The Banking Company providing the API is quite concerned about its cloud provider’s bills.

10.1.2 Analyzing the problems

Why do the Awesome Banking App’s users complain? Why is this application slow and inefficient? And why is the Banking Company concerned about its cloud provider’s bills? It all comes down to the number and frequency of network calls and the volume of data exchanged. In this section, we’ll analyze these problems.

Please bear in mind that what you see here is not an exact reflection of reality; it’s a simplified explanation of a specific use case in which all possible issues have been grossly emphasized in order to provide an overview of the network communication concerns that API designers must consider when designing APIs. The mobile application’s behavior and the API design are actually pretty dumb, and obviously no one would ever create such an abomination—I hope!

Let’s start by decomposing an API network call made over the mobile network. Figure 10.5 shows what happens when the Awesome Banking App lists an account’s last three months of transactions.

10-05.png

Figure 10.5 Decomposing an API call made over a mobile network

The first step is to connect to the server hosting the Banking API. That consists of various actions involving the phone’s radio antenna, low-level network communication initiation, and encryption. We will assume that it always takes around 300 ms, but know that depending on the type of mobile network, the radio signal quality and what happened before this call can take up to several seconds. This time is often called the latency.

Once the connection is established, the API request can be sent. Because it is a simple GET /accounts/{accountId}/transactions request, its size is only around 100 bytes, so it takes far less than 1 ms to upload or send it to the API server. When the server receives the request, it has to process it, load the transactions from the database, and generate a JSON document. We’ll assume that this takes 20 ms, but the actual time depends on what is requested and the server’s capabilities.

Finally, the fourth step consists of downloading the server’s response. We’ll assume the generated JSON document’s size is around 32 KB, so it takes 200 ms to download it at 160 KB/s (which is advertised as 1.3 Mb/s). Like the latency, the download speed depends heavily on the type of network and the radio signal quality. The overall request takes 520 ms. It’s a bit long, but given the hostile network conditions, we have to live with that. This single request alone is not that bad, but let’s see what happens when the Awesome Banking App actually uses the API to populate the dashboard screen for the simplest use case: a user corresponding to a single owner who has a single account.

Figure 10.6 shows which API calls are made and how. To simplify the explanation, we will take it for granted that the network bandwidth is 160 KB/s, the latency is always 300 ms, sending the request always takes 0 ms, and it always takes 20 ms for the server to process a request.

10-06.png

Figure 10.6 A basic use case with a user who has access to a single account owner who owns a single account

To load the data shown on the dashboard screen, the Awesome Banking App lists the owners whose accounts the user is authorized to view; here, it returns a single owner corresponding to the user. Then it reads this owner’s detailed information and lists the owner’s accounts in parallel. Once the application has gotten the list, which consists of a single account in this case, it reads that account’s detailed information and lists its transactions, also in parallel.

The whole API call chain consists of five API calls, distributed in three steps thanks to the parallel calls. Unfortunately, it takes 1.2 s, which is above 500 ms, the top end of the acceptable latency range (the time above which a human brain tolerates latency). And this is for a simple use case! Now let’s see how it goes with a more complex one. Figure 10.7 shows what happens for another user who has access to four owners and their eight accounts.

10-07.png

Figure 10.7 A complex use case with a user who has access to four owners and their eight accounts

The app starts again by listing owners, but this time there are four of them. Because the mobile application is limited by the operating system to four concurrent HTTP connections, it reads the detailed information for each of the account owners using four parallel requests; after that it lists their accounts, also in parallel. Note that if the whole network bandwidth is 160 KB/s, each parallel request is made at a quarter of it, or 40 KB/s.

Next, the application reads the detailed information for the owners' eight accounts and their transactions in four steps of four parallel requests. So, in this case, there are 25 API calls done in seven sequential steps, taking 4.2 s and retrieving 310 KB of data. That’s 20 more calls and ~7.5 times more data than in the previous use case, and the whole chain takes 3.5 times longer. This is a very long delay, and even if the application shows a spinner, users will definitely get bored before the screen loads.

Each screen the application displays can have the same kind of problems. I’ll let you imagine what happens on the account screen when the application has to make individual calls to get detailed information for each transaction. And it gets worse. The users might navigate through the application to see all of their data, and this can raise some other problems, as shown in figure 10.8.

10-08.png

Figure 10.8 An average navigation through the Awesome Banking App

The Awesome Banking App team has dug into its analytics data, and it seems that an average user has access to two owners and that each owner has, on average, two accounts. The team has also determined that these average users typically navigate through all their data when using the app. Unfortunately, the app does not reuse previous API call responses because it does not know if the data can be reused. Therefore, new API calls are made on each screen even if the data has already been retrieved. This results in many API calls being made (around 250 per session, on average) and a considerable amount of data being exchanged (around 2 MB). Depending on how often users check their accounts, the Awesome Banking App could use around 60 to 90 MB of data per month.

Nowadays data use isn’t really a problem for most users in most countries, but that’s not true for all users everywhere. And for users who are traveling abroad, each KB can matter, because data is worth gold in that case. But beyond the data plan, the most annoying thing is that all the API calls made on each screen drain the device’s battery because they keep the radio up and running for a longer period of time. Clearly, the number of calls, their length, and the data volume exchanged can be of concern on the consumer’s side, but the same can be true on the provider’s side.

Let’s say the Banking API is hosted on the Barbrarian cloud service (which started life as an online book seller, but that’s another story). The API is implemented using the brand new serverless service called Functions. Each goal is coded independently as a function, and there is no more need to worry about servers, applications, and scaling. The systems handle everything; when calls arrive, each corresponding function is run. The pricing of the service is based on the number of calls received, the processing time, and the outgoing data volume. This means that the more API calls are made, and the bigger they are, the more the Banking Company will have to pay. So, optimizing all that can be important and even vital.

These are only two examples. APIs can have different contexts than those we have just seen, but the network efficiency dimensions will usually resolve around these factors: speed, data volume, and number of calls. As API designers, we have to be aware of that and try to find a balance between the need for efficiency and an ideal design. Next, we will investigate how we can seek this balance and optimize network communication at the protocol level.

10.2 Ensuring network communication efficiency at the protocol level

Network communication optimization begins at the protocol level. Indeed, by taking advantage of the underlying protocol, it’s possible to create a network-efficient API without having to tinker too much with your ideal design. For HTTP-based APIs, activating compression and persistent connections can reduce data volume and latency. Enabling caching (letting consumers know if they can save a response to reuse it and for how long) and conditional requests (allowing consumers to check if the data they have is still fresh enough to avoid retrieving it again) can reduce not only the data volume but also the number of calls.

10.2.1 Activating compression and persistent connections

For HTTP-based APIs, there are two fairly common optimizations that can be done without impacting design: activating compression and enabling persistent connections. Once compression is activated on the Banking API server, the 310 KB of data retrieved for the four account owners and their eight accounts in our second use case can be reduced to less than 2 KB. For the Awesome Banking App running on a mobile phone connected to a not-so-good 3G network, this means less data and shorter calls. Smaller responses will be downloaded faster, and the app will not use up the end user’s data allowance. Most if not all consumers using a standard HTTP library can take advantage of this feature without having to modify anything in their code.

This server modification also benefits the consumer side. If the Banking API is hosted on an on-premises infrastructure, less data means less risk of network congestion because the overall bandwidth used is lower and network connections will be open for less time. For cloud infrastructures, this simply means smaller bills.

Once persistent connections are enabled on the Banking API server, the use case that required 25 calls distributed in seven steps taking a total of 4.2 s can be reduced to 2.4 s by removing 6 × 300 ms of latency. When persistent HTTP connections are enabled, only the first API call will have to suffer the connection latency; the subsequent calls are made using the same connection. The connection stays open for a given number of calls or a given time, determined by the server’s configuration.

Switching to HTTP/2 can also be an option; out of the box, it offers efficient persistent connections, parallel requests, and binary transport, and the icing on the cake is that it is backward-compatible with HTTP/1.1! From an API design perspective, using HTTP/2 has roughly the same effect as activating compression and persistent connections. It is transparent and requires no modifications. In both cases, the idea is to reduce the latency and the exchanged data volume and, therefore, the length of the API calls.

It’s important for API designers to verify with the API team whether such optimizations have been considered prior to modifying an existing design because of network communication performance problems. Ideally, such optimizations must be done from the start, and optimizing communication for efficiency can have important effects on the design. Indeed, depending on the context, it could be wise to use different types of APIs and even different ways of communicating.

10.2.2 Enabling caching and conditional requests

A second way to optimize communications is simply to not communicate at all or, at least, to communicate less. In section 10.1.1, you saw that the Awesome Banking App was making many unnecessary calls because it did not reuse the responses of previous calls. In particular, to build the dashboard screen, the Awesome Banking App loaded a lot of data that could be reused when showing an owner’s accounts list. Figure 10.9 shows the API calls made without and with response caching.

10-09.png

Figure 10.9 Reducing the number of calls with caching

The scenario at the top of the figure shows a totally unoptimized Awesome Banking App that does not reuse API call responses. In the scenario at the bottom of the figure, the application caches the responses of all the requests made on the dashboard screen. By doing so, it avoids six API calls when showing an owner’s accounts list screen because it can get the needed data from the cache. This is good for the Awesome Banking App and its users: the application reacts faster when going from the dashboard to the accounts list screen, and it uses less of the network, the battery, and the data plan. It’s also good for the Banking Company that provides the API; by avoiding the six unnecessary API calls in this scenario, the traffic to the Banking API is cut by 46% for calls and 50% for data volume.

As you can see, caching can be a great help in making network communications more efficient. This is what any mobile developer would do without even thinking about it. Consumers can choose to cache any of an API’s responses, but the API can help them do it correctly and efficiently. The API designer is responsible for determining what data should be cached and for how long. Figure 10.10 shows how this can be done using the HTTP protocol.

10-10.png

Figure 10.10 Using the HTTP protocol’s caching features

When the Awesome Banking App gets the first account’s information with a GET /accounts/A1 HTTP request, the API returns a 200 OK response along with the data and the Cache-Control and ETag HTTP headers. The Cache-Control header’s value is max-age=300, which means that this response can be cached for 300 s (5 min). So if the application needs to show this account’s data again in the next five minutes, it will use the cached response instead of making a call to the API.

Once the five minutes have passed, the cache has expired; therefore, the application will have to make another GET /accounts/A1 HTTP request if it needs the first account’s information again. Rather than sending the exact same request as the first time, however, it can send a conditional request that basically says, “Give me account A1’s data only if it has been modified.” This is done by using the z567dff ETag value returned by the first call. An If-None-Match: "z567dff" header is sent along with the second request.

When the Banking API server receives the request, it uses the z567dff value to check if the account’s data has been modified. This value describes the state of the resource that was sent with the first request. It could be a hash of the data, a date or a version number, or any other value that allows the server to know which version of the resource was provided earlier. Consumers do not need to know what this value actually is.

If the data has not been modified, the server returns a 304 Not Modified response without any data and, therefore, avoids loading data unnecessarily. The application updates the cache expiration date, which can be different from the first call, thanks to the Cache-Control header returned with the 304 response. The ETag header is not modified because the data has not changed.

The same request made later might get a 200 OK response, which says, “Yes, the data has been updated, here it is.” This response contains the updated data along with the Cache-Control and ETag headers. In our example, the Cache-Control value is max-age=300, as before, but the ETag value is now x098trg. Its value has changed because the data of the resource has changed.

If the application only wants to know if the first account’s data has changed but does not actually want to load the new data, it can send a HEAD /accounts/A1 request instead of GET /accounts/A1. The HEAD HTTP method is identical to GET except for the fact that the server does not return the resource content, only the headers.

By returning and accepting some metadata, but also by providing ways of getting only that metadata without the data, an API can enable the caching of its responses and propose conditional requests that greatly optimize network communications by reducing the number of calls and the volume of data returned. Caching also guarantees a certain freshness and accuracy of the data.

If your chosen protocol/API type provides these features natively, don’t hesitate to use them. Be warned, however, that just because a response from the API can be cached, this does not mean that consumers will actually cache it. Furthermore, caching features might not always be available (for example, at the time of this book’s writing, the gRPC framework does not provide these features). In such cases, if you decide caching is really important, you have two options. The first one is to reconsider your choice of protocol and API type; check if it was really the better one according to the context. The second is to recreate equivalent features in your API at the risk of providing a solution that is so custom or unusual (or inefficient) that consumers don’t use it at all.

When designing an HTTP-based REST API, caching seems relatively simple; we only need to add the appropriate HTTP method, status code, and headers to the API description. But there’s a little more to it than that. Where does the 5-minute cache duration come from? Why not 15 minutes or 2 days? Why is caching even allowed? The tricky thing about caching is that the caching possibilities must be evaluated for each goal and, more precisely, for each property returned by a goal.

10.2.3 Choosing cache policies

The data returned when a consumer requests an account’s information might be its creation date, its name, and its balance. The creation date never changes. The name could change, but this rarely happens. In contrast, the balance is updated whenever a transaction occurs for that bank account. Because balance is the property that will change more often than any other, it is its time to live that determines the cache duration of the bank account data returned by this goal. So, how do we determine the correct cache duration value? Well, it depends.

How long data can be cached can depend on how often it is updated. In the beginning, the Banking Company only updated an account’s transactions list and, therefore, the account balance a few times per day. So, a long caching duration of one hour was practicable. But the Banking Company has now improved its system: the transactions with other banks are still processed in batches a few times per day, but now all internal transactions are processed in real time. Therefore, in order to provide accurate data, the appropriate cache duration should be determined not only by how the banking system works but also by how people actually use their bank accounts.

The Banking Company has determined that statistically, a five-minute cache offers a good balance between accuracy and efficiency when getting account information. In the near future, when all interbank communications will be done in real time and people will be used to always getting their banking information that way, caching data might not be possible at all. In that case, the Cache-Control header’s value will be "0", but at least it will still be possible to make conditional requests using the ETag value in order to avoid loading unchanged data.

How data can be cached can also depend on other matters. For example, presenting an inaccurate balance, even through a third-party application, can cause some problems from a legal or security perspective. Therefore, the Banking API’s documentation might state that consumers must use fresh data. As with real-time balance information, in such a scenario, the API would provide a Cache-Control header with a value of "0" but can still make use of conditional requests.

Legal or security considerations might also prevent consumers from storing data, or there might be a middle ground where caching is allowed but not storage. In this scenario, the Cache-Control value could be "60", no-store", meaning data can be cached in a volatile storage for 60 s but cannot be stored in a non-volatile storage (no-store).

In a nutshell, as you’ve already seen in section 8.4.1, you might have to get some advice from the security people and the legal department when designing your API. So, although enabling conditional requests is quite simple, in order to be efficient and accurate some work is required to determine whether caching is actually possible and, if so, what is the best cache duration to choose.

These kinds of optimizations can be built in from the beginning, but they can also be implemented when the API is already being consumed without much impact on the interface contract or much work for the API designers. But API designers have far more responsibilities than just checking whether compression and persistent connections are activated and if consumers are actually using the cache. Indeed, the API design itself can be the cause of inefficient network communication between consumers and providers.

10.3 Ensuring network communication efficiency at the design level

Just because communication between consumers and providers can be optimized at the protocol level doesn’t mean we can be careless at the design level. Fundamentally, the design of an API dictates the number of calls consumers need to make to achieve their goals and the amount of data exchanged between consumers and providers. By applying what you learned in previous chapters, you can design APIs that provide accurate goals, optimized data granularity and organization, and enough flexibility to ensure communication efficiency. As a reminder, we will work on the Banking API, whose current state is shown in figure 10.11.

10-02.png

Figure 10.11 The Banking API goals

This design is quite easy to understand and seems, at least on the surface, relevant and well organized. But from a network communication efficiency perspective, it’s far from perfect. Let’s take a look at some strategies we can use to optimize the number of calls and the volume of data exchanged when the Banking API is used by the Awesome Banking App or any other consumer.

10.3.1 Enabling filtering

Providing filtering options is a good way to reduce the exchanged data volume because it allows consumers to get just what they really need. The list transactions goal always returns the last three months' worth of transactions, sorted from the latest (most recent) to the earliest (least recent). In the Awesome Banking App context, such data depth is needed when showing the cumulative sum of the transaction amounts on the dashboard and accounts list screens, even if users will probably never scroll through all these transactions. Thanks to caching possibilities, we can reuse this huge transactions list on the account screen. But unfortunately, when the cache expires, the application has to reload all three months' worth of transactions again, even if only one transaction has been added. It looks like this goal was tailor-made for the specific needs of the Awesome Banking App’s dashboard and accounts list screens, but the result is not really efficient.

You learned in section 6.2.3 that always providing all the data might not be a good idea because consumers might not need all the data in all situations. Proposing filtering options makes an API more usable and more efficient by allowing consumers to request only the data they actually need—and this is what we desperately need here. Every byte saved improves communication efficiency in a hostile network context.

Based on what you have learned, you could add page and size query parameters to provide offset-based pagination features, but note that the goal can still return all three months' worth of transactions without these parameters to stay backward-compatible if the API is already being consumed. It seems that the account screen could make good use of this feature. A GET /accounts/A1/transactions?page=1&size=25 request would return only the latest 25 transactions for the A1 account. If users scroll down, the application can request the next page with GET /accounts/A1/transactions?page=2&size=25. But what happens if new transactions occur between these two requests? Some transactions from the first page will shift to the second one, so the second request will return already retrieved transactions. It will be up to the consumer to check if it has already gotten each transaction and to ignore the duplicate ones.

This might not happen that much, but it can lead to providing inaccurate information, which is not tolerable for the Banking Company. This way of paginating transactions does not work well in this use case and context, and it doesn’t solve the problem of reloading all three months' worth of transactions on the other screens. So what kinds of filters could we provide to solve this problem?

Consumers, whoever they are, basically need to be able to retrieve any transactions before or after a selected one to get exactly the data they want. Figure 10.12 shows how this could be done using cursor-based pagination.

10-12.png

Figure 10.12 Cursor-based pagination to retrieve transactions

The Awesome Banking App still sends an initial request to get the last three months' worth of transactions using GET /accounts/{accountsId}/transactions, but now the response contains pagination metadata. The before and after properties are cursor values that can be used to retrieve the transactions before or after the retrieved set. Their values are the IDs of the first (latest) and last (earliest) transactions of the set, respectively. To only retrieve transactions that occurred after the current transaction set was retrieved, the application has to send a second request like this:

GET /accounts/{accountId}/transactions?after={latestTransactionId}

The after value is the last known transaction ID, or the previously provided cursor. Better yet, consumers can use the ready-to-use before link. Because the response to this request contains only the new transactions, its size is far smaller. Such a design greatly diminishes the volume of data downloaded by the Awesome Banking App and also improves response time, both because there’s less data to download and because the requests take less time to process on the server side.

This solution also works on the account’s transactions list screen in the unlikely event of users scrolling beyond the three months' worth of cached transactions. In that case, this request using the before link

GET /accounts/{accountId}/transactions?before={earliestTransactionId}&size=25

retrieves the 25 transactions that took place before the one identified by earliestTransactionId. Providing filtering options is a good strategy to lessen the volume of data exchanged and improve usability. But in order to provide accurate and efficient filters, it’s important to consider the nature of the data and the contexts of use.

10.3.2 Choosing relevant data for list representations

Which data you choose to return in lists can have a big impact on communication efficiency. The Banking API is not as efficient as it could be because it does not provide all the relevant data in lists. As shown in figure 10.13, the Awesome Mobile Banking App shows account owners' titles and names on its dashboard screen.

10-13.png

Figure 10.13 Choosing relevant summarized representations in lists

If the owners' names can only be retrieved using the list owners goal, the summarized data does not provide the titles. To get this information, the application has to read each owner’s detailed information. This is an indicator of an incorrect balance between the summarized representation of resources, usually returned in lists, and the detailed one, usually returned when accessing a specific resource. By simply adding the title to the summarized version, we can avoid the calls to read owner.

The same goes for the list accounts goal, which does not return the account types and, therefore, requires extra calls to the read account goal to get this fundamental information. Adding the type property into the summarized representation returned by list accounts prevents any additional API calls.

We could modify the list transactions goal the same way, but we’ll go even further. As shown in figure 10.14, the account screen needs to call list transactions and then read transaction for each one.

10-14.png

Figure 10.14 Using full representations in lists

The summarized transaction representation returned in the list only contains an ID and a label; the amount and transaction type are missing. This requires the consumer to request detailed information about each transaction. Given the nature of transactions, which are usually numerous and reviewed in batches, the list transactions goal should return the complete representation of each transaction instead of just a summary.

Why not do the same modification for other lists, like the owner’s list? The owner resource contains much more data, and most of it is not relevant when working with a list. But returning all the data in the owner’s list would increase the data volume unnecessarily.

Choosing a relevant representation including the most representative and useful properties of a resource is the best way not only to create a usable API but also to avoid many API calls after getting the list’s data. Although requesting a list of elements usually returns a summarized version of each element, this is not an obligation. There are cases when returning a complete representation is more efficient.

10.3.3 Aggregating data

Fine-grained resources provide a flexible and precise way to get different subsets of data from a concept, but they can lead to many API calls when consumers need to get all the data. Without taking into account the previous optimizations we have made, figure 10.15 shows how the Awesome Banking App loads an owner’s data.

10-15.png

Figure 10.15 Aggregating subresources and the parent resource

The owner’s data is split between the owner resource, available via the read owner goal, and the addresses resource, available via the list addresses goal. Consumers can get one subset or the other, but this means two API calls are needed to get two closely related and quite small sets of data. This is cumbersome; and in a hostile network context, we can’t afford this additional call. We’ve already seen a similar use case in section 7.2. A better design would be to include the list of addresses with the rest of the owner’s data so a single call to the read owner goal would return all the required data.

Taking this a step further, why not aggregate accounts and their transactions? This is not a good idea, however, for several reasons: there are likely to be many transactions for each account, consumers might want to filter transactions by type or date, and most importantly, the transactions list is regularly updated. Aggregating the list of addresses into the owner’s data was OK because the data volume is relatively low and the addresses do not change too frequently. Even if consumers need to select a given type of address, they just need to filter a list of 10 elements at most. And if the data changes, there won’t be too much data to retrieve. For account transactions, however, it’s better to keep dedicated access.

So, we can’t aggregate transactions into the account’s data, but what about trying a bigger aggregation on the other side of the tree? Why not get all the data except the transactions list with a single call? Figure 10.16 shows the impact of such an aggregation for the use case involving a user having access to four owners and their eight accounts.

10-16.png

Figure 10.16 Extended aggregation has an impact on communication.

Retrieving all account data except the transactions in one call would mean returning full account representations into the accounts list, aggregating all this data into the owner resource along with the list of addresses, and returning a complete representation of each owner in the owners list. What do we gain by replacing these 17 API calls (distributed in six steps, taking 2.2 s, and representing 58 KB of data) with a single call?

The latency time is reduced from 1,500 ms to 300 ms because there is a single step instead of six. Surprisingly, there is also less data downloaded: 52 KB instead of 58 KB. This is because the duplicated data returned in summarized lists is not downloaded anymore; the data is returned only once with the read owner or read account goal. The server processing time is still 120 ms; but in reality, it would probably be reduced too. The overall time is reduced from 2.2 s to 700 ms. Now, instead of several short calls, we have one longer one. That’s quite an impressive result; the response time is cut by almost 70%!

We could keep this new list of the aggregated owners goal and the list transactions goal and remove all the other goals in the Banking API. But keep in mind that the diminution mostly concerns the latency time; if persistent connections are enabled on the API server, the aggregation might not be quite as effective.

In certain contexts, aggregation can also hinder caching possibilities. The time-to-live of the aggregated data is the smallest value of all the individual properties (in this case, the account’s balance, which can change quite often). Therefore, when the balance of a single account changes, consumers will have to reload a lot of data. Although this might not seem like a big deal, in hostile network conditions having one very long call instead of several shorter ones can be problematic. The longer a request lasts on a 3G network, the higher the risk of losing the connection, and if the connection is lost when 95% of the download has been completed, the consumer will have to download all the data again.

Finally, in addition to performance, aggregation can have an impact on usability. It might not be easy for consumers to understand how an API works when providing only list owners and list transactions goals. So aggregating data can be a valid solution to possible communication performance problems, but it must be done carefully with a good view and understanding of all the implications. When designing resources and goals, choose their granularity wisely to ensure the API is not only usable but also efficient.

10.3.4 Proposing different representations

By wisely using aggregation or using more complete representations in lists, we can design a more efficient API. But that is quite a rigid solution; all consumers might not need all data in all cases. How can we make our API more adaptable and provide consumers with a way to choose the representation that best fits their needs?

You already know the answer to this question: we can use content negotiation, the capability of providing different representations of a resource, as you discovered in section 6.2.1. As shown in figure 10.17, we could provide three different levels of representations of our resources: summarized, complete, and extended.

10-17.png

Figure 10.17 Using content negotiation to get an appropriate representation

We are used to getting a complete representation when reading a specific resource, as with the read owner goal (GET /owners/{ownersId}). The summarized representation provides a subset of the complete representation’s data. It’s the one we are used to getting in lists using the list owners goal (GET /owners, for example). Finally, the extended representation is an aggregation of the resource’s and its subresources' data. Here, it provides the complete data for the owner resource along with its subresources' data—the complete representations of the accounts and addresses resources.

Now, when the Awesome Banking App requests to read owners on its dashboard screen, it can indicate that it wants the extended representation of each owner instead of the default summarized one by sending an Accept header whose value is application/vnd.bankingapi.extended+json. This way, it can avoid separate calls to read owners, list accounts, and read account.

The positive and negative impacts on speed, caching, and risk of lost connections are the same as those you saw in section 10.3.3; but now other consumers have the option of choosing to get only the summarized representation of each owner if that’s all they need. Also, to get an updated account balance, consumers can send a GET /accounts/{accountId} request along with an Accept: application/vnd.bankingapi.summarized+json header to get only the data required instead of the regular complete representation.

There is no standard way of handling this mechanism. The application/vnd.bankingapi.{representation}+json media types shown here are totally custom ones. Their names use the standard vnd prefix, which stands for vendor. The +json suffix is also standard and states that this custom media type basically is JSON data. Providing different representations of a resource can help to provide a more efficient and more flexible API, but we can do better.

10.3.5 Enabling expansion

Using content negotiation, we can design a much more flexible API providing, for example, three different representations of an owner. But that’s still a bit rigid. What if consumers only need to get summarized representations of owners along with their accounts but without their addresses? This isn’t possible unless we add a fourth, not-so-summarized, representation of an owner. Let’s try something else: a technique called resource expansion, illustrated in figure 10.18.

10-18.png

Figure 10.18 Expanding the owner’s accounts subresource in the owners list

On the left, we can see a list owners request (GET /owners), which returns a summarized representation of owners, and a list accounts request (GET /owners/01/accounts), which returns a summarized representation of accounts. Note that in this representation, we provide HAL links in the _link property (see section 6.3.2).

On the right, the list owners request includes an _embed=accounts query parameter, which means “Please embed all owners' accounts lists in the response.” The response actually includes this information in the _embedded.accounts property.1  If consumers send a request with an _embed=accounts, addresses query parameter, the returned owner representations include lists for both accounts and addresses under the _embedded property. This _embed parameter allows us to trigger subresource expansion or embedding. Again, drawbacks can include longer requests, bigger responses, and caching inefficiency.

1 This representation conforms to the HAL specification (https://tools.ietf.org/html/draft-kelly-json-hal-06#section-4.1.2).

There is no standard way of proposing such a mechanism; what is presented here is totally custom. The query parameter could be named embed, expand, or any other name you choose. Depending on how the data is organized and which hypermedia format is used (HAL, Siren, custom, and so forth), the way the subresources are included can vary.

Resource expansion is another way of reducing the number of calls consumers might have to make to retrieve a data tree. However, further economies are possible through querying.

10.3.6 Enabling querying

If every single byte and millisecond really matters, we can make our API even more adaptable by letting consumers query the data they want, property by property, in order to reduce the data volume and, possibly, the number of API calls. For example, a GET /owners?_fields=id request could return a list of owners; but for each owner, the consumer would get only the owner’s ID. There is no standard way of proposing such a mechanism with a REST API, but it’s usually done with a query parameter named fields or properties (or something similar), whose value is a list of property names (like title), or with JSON paths (like $.accounts[*].id, for example, to get all account IDs).

Alternatively, if more complex queries are needed, to reduce the data volume, you can consider another option: an existing query language. REST is not the only way of doing APIs. We’ve already briefly talked about gRPC, but here’s another style of API that might be of interest: GraphQL. Created by Facebook in 2012 and open-sourced in 2015, GraphQL is

“A query language for APIs and a runtime for fulfilling those queries with your existing data.”

https://graphql.org

This section is not intended to teach you how to build GraphQL APIs; it is only meant to provide an example of an existing API query language that you could use to let consumers query the data they want instead of creating your own. The following listing shows a basic GraphQL call that queries the owners list. It’s equivalent to a GET /owners?_fields=id request.

Listing 10.1 A GraphQL API call and its response

POST /graphql
 
{
 "query": "{ owners { id } }"
}
 
HTTP/1.1 200 OK
{
  "owners": [
    {"id": "01"},
    {"id": "02"},
    ...
  ]
}

A GraphQL API call consists of a POST request on a generic graphql path. Its body is a JSON document that, when reading data, contains a query string property. This property’s value is the actual GraphQL query that will be executed to retrieve data.

Don’t be fooled by the curly braces; this query is not written in JSON! The { owners { id } } query states that we only want each owner’s ID. The following listing shows a longer GraphQL query, which goes in the query property, retrieving some additional data about owners and their accounts.

Listing 10.2 Retrieving some owner and account data

{
  owners {
    id
    title
    firstName
    lastName
    accounts {
      id
      balance
    }
  }
}

This request returns a list of owners containing the selected data. To do this using the REST Banking API (not providing aggregated data), we would need to chain multiple API calls. We would first list owners with GET /owners and then list each owner’s accounts with a GET /owners/{ownerId}/accounts request.

Now imagine that the Banking API proposes a goal allowing us to retrieve a list of nearby ATMs. With a REST API, we would use a request like

GET /atms?latitude=48&longitude=2&distance=2

to get the ATMs within two miles of the specified location. But as shown in the next listing, we could run two queries using a single GraphQL API call to retrieve the owners and their accounts as well as the list of nearby ATMs.

Listing 10.3 Executing multiple queries

{
  owners {
    id
    title
    firstName
    lastName
    accounts {
      id
      balance
    }
  }
  atms (latitude: 48, longitude: 2, distance: 2) {
    address
    longitude
    latitude
  }
}

Consumers can easily select exactly the data they want and make multiple queries in a single call. But because GraphQL only uses the POST HTTP method, requests cannot be cached using HTTP’s standard caching mechanism, whereas a GET /atms?latitude=48&longitude=2&distance=2 request can.

At the time of this book’s writing, GraphQL does not propose any caching mechanism; it is up to the consumers to guess how long they can cache data. And as with data aggregation, caching the response as a whole might not make sense because it can contain heterogeneous data with very different time-to-live values. There are other implications that must be evaluated before choosing such a solution; we will talk a little bit more about these in section 11.3.1.

Enabling data querying might be appropriate in some scenarios, but not all. It can reduce the volume of data transferred and the number of API calls, but at the possible expense of caching possibilities.

10.3.7 Providing more relevant data and goals

As you’ve just seen, the Awesome Banking App could retrieve all the data needed for any of its screens in a single call using an API query language. But before we consider changing the Banking API’s type from REST to GraphQL, we should reconsider its design. Indeed, inefficient communication can be a symptom of a design that does not fulfill consumers' actual needs.

We’ve already seen in sections 10.3.2 and 10.3.3 that our choices about resource granularity and what data we include in summarized representations have an impact not only on communication efficiency but also, more importantly, on usability. But providing a design that is both usable and network-efficient requires more than just selecting which data to return in lists and how to carve up resources—providing relevant data and goals is the key to creating such a design.

When consumers need to get information about an account, they usually need its type, name, balance, and transaction history. The Banking API provides all of that, thanks to the read account and list transactions goals. But the balance of an account is modified every time a new transaction occurs.

Using the current design, updating this information can be done by requesting the latest transactions using cursor-based pagination (see section 10.3.1) or with a conditional request (see section 10.2.2). If there are new transactions, consumers then have to read the account again to get the updated balance, even though all the other account data has probably not changed at all. Banking API consumers will probably never use the transactions list without the account’s balance.

These are definitely closely related data: the balance is based on the transaction amounts. As shown in the following listing, adding the updated account balance to each transaction could simplify this.

Listing 10.4 Adding an updated balance to transactions

{
  "items": [
    {"id": "5601", "date": "2018-12-23", "amount": 20,   "balance": 202.3,
       ...},
    {"id": "5550", "date": "2018-12-23", "amount": 20,   "balance": 222.3,
      ...},
    {"id": "5548", "date": "2018-12-22", "amount": 23.7, "balance": 246,
      ...},
    ...
  ]
}

When retrieving new transactions, consumers will now automatically get the updated account balance each time without having to read the account again. As a bonus, this modification provides interesting historical information: consumers can see how the balance has changed over time. Note that it is not because the account’s balance has been added to transactions that it should be removed from the read account account goal; the balance is useful in both places.

Providing relevant data also means not providing all the available data. Indeed, focusing on the consumer’s perspective can help to limit data volumes (remember section 2.4.1). In our case, the Banking API’s owner and account resources could probably omit a few uninteresting properties that only matter for the API provider’s implementation.

At the root of the Banking API resources tree organization is the accessible owners list; all consumers have to pass by this root to do anything. This seems appropriate for the Awesome Banking App, whose screens show the data with the same organization as the API. But that means all consumers have to list owners with a GET /accounts/{ownerId}/accounts call to know which accounts are accessible. That could be annoying for those who don’t really care about the account owners. But when advisors want to get an overview of all accounts of all their customers, it could be useful to add a GET /accounts to the API, which would return all the accounts that can be accessed by current users (whomever they are).

Also, among the owners returned by the list owners goal, one corresponds to the end user. With the current design, consumers only wanting to get data about the end user have to list owners and search for the one having the endUser flag set to true in the returned list. By using a magic resource ID such as me, consumers could directly read the end user’s information using the read owner goal with a GET /owners/me request without having to list owners first to determine the user’s ID.

As you can see, adding more goals providing different access to the same resources or more direct access can also improve usability and efficiency in different contexts. For example, the way the Awesome Banking App builds its main dashboard screen could lead to the addition of data to existing goals or even the creation of more specific goals. The aggregation of transaction amounts and account balances by owner could be done by the API’s implementation and added to the owner’s data. The aggregation of transaction amounts could also be added to the data returned by read account and list transactions, alongside the account’s balance.

If it makes sense for other consumers, we could also consider adding a read dashboard goal accessible via a GET /dashboards/me request that would return the data needed by the Awesome Banking App’s dashboard screen. If many consumers are likely to benefit from such a modification, it should be added to the API.

Also be aware that consumers will probably use your APIs in unexpected ways. Whether because of blatant holes in the initial design or because some consumers simply have ideas you would never have dreamed of, it’s wise to analyze such unexpected uses and modify the design as needed in order to provide the most efficient experience. Indeed, it’s crucial for API designers to evaluate the efficiency of their API designs.

As you saw in section 10.1.2, depending on the use case, the Banking API’s efficiency varies greatly: loading the dashboard data could take 5 API calls completing in 1.2 s or 25 calls completing in 4.2 s. When evaluating communication efficiency, you must not think only about basic use cases. An API’s goals flows can look perfect with a very basic hypothetical use case but become nightmares when confronted with reality or edge cases. API designers must always test their designs with actual use cases in order to truly evaluate their efficiency.

10.3.8 Creating different API layers

Trying to optimize APIs for network communication efficiency is a good thing, but API designers must know when to say no. Optimizing an API design in order to provide efficient communication must not be done at the expense of usability and reusability. Trying to please all consumers by making specific modifications here and there or adding multiple highly specific goals will probably lead to a complex API that will not be reusable. Fortunately, by using the various techniques described in this chapter, you should be able to design an efficient API, and this should give you the confidence to push back when necessary.

If consumers really have specific needs, they should build their own APIs on top of the provider’s. In the mobile app and website world, such a component is called a BFF (not “best friends forever” but “backend for frontend”). The Awesome Banking App’s developer team could, for example, build a GraphQL-based BFF relying upon the Banking API. Doing so is quite simple; there are GraphQL libraries that can help developers do this without having to code much.

Providers can also provide such APIs themselves, creating a new API layer in their systems. Such specialized APIs are sometimes called experience APIs (regardless of their type—REST, GraphQL, or whatever), and their design is optimized for a specific context of use from a functional or technical (network, usually) perspective.

Below the experience APIs, you might find original/not specialized APIs. These are APIs whose design is consumer-oriented but that are not really confined to a specific context of use. And below this layer, you might find system APIs providing access to core systems. If you remember the microwave oven example in section 2.1, such an API would give access to the magnetron.

In the next chapter, we will fully explore the context surrounding the API from both the consumer’s and provider’s sides in order to design APIs that are more fully usable and implementable.

Summary

  • API designers have a role to play in network communication efficiency.
  • The very first step of network optimization is at the protocol level, not the design level.
  • API granularity and adaptability have impacts on network efficiency.
  • Network efficiency problems can be a sign of missing or inadequate goals in the API.
  • API design optimizations must not be done at the expense of usability and reusability; providing different API layers can help to avoid such booby traps.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset