[The API Patterns] Introduction. Authorization and Authentication. Synchronization Strategies.
With this post, I’m starting publishing the v2 of my book dedicated to APIs. In addition to the existing sections (“The API Design,” “The Backwards Compatibility,” and “The API Product”), v2 will comprise three more sections: “The API Patterns,” “The HTTP API & REST,” and “The SDK & UI libraries.” If you like this book, please rate it on GitHub, Amazon, or Goodreads and
Chapter 15. On Design Patterns in the API Context
The concept of “Patterns” in the field of software engineering was introduced by Kent Beck and Ward Cunningham in 1987 and popularized by “The Gang of Four” (Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides) in their book “Design Patterns: Elements of Reusable Object-Oriented Software,” which was published in 1994. According to the most widespread definition, a software design pattern is a “general, reusable solution to a commonly occurring problem within a given context.”
If we talk about APIs, especially those to which developers are end users (e.g., frameworks or operating system interfaces), the classical software design patterns are well applicable to them. Indeed, many examples in the previous Section of this book are just about applying some design patterns.
However, if we try to extend this approach to include API development in general, we will soon find that many typical API design issues are high-level and can't be reduced to basic software patterns. Let's say, caching resources (and invalidating the cache) or organizing paginated access are not covered in classical writings.
In this Section, we will specify those API design problems that we see as the most important ones. We are not aiming to encompass every problem, let alone every solution, and rather focus on describing approaches to solving typical problems with their pros and cons. We do understand that readers familiar with the works of “The Gang of Four,” Grady Booch, and Martin Fowler might expect a more systematic approach and greater depth of outreach from a section called “The API Patterns,” and we apologize to them in advance.
NB: the first such pattern we need to mention is the API-first approach to software engineering, which we described in the corresponding chapter.
The Fundamentals of Solving Typical API Design Problems
Before we proceed to the patterns, we need to understand first, how developing APIs differs from developing other kinds of software. Below, we will formulate three important concepts, which we will be referring to in the subsequent chapters.
The more distributed and multi-faceted systems are built and the more general-purpose channels of communication are used, the more errors occur in the process of interaction. In the most interesting case of distributed many-layered client-server systems, raising an exception on the side of a client (like losing context as a result of app crash and restart), server (the pipeline of executing a query threw at some stage), communication channel (connection fully or partially lost), or any other interim agent (intermediate web-server hasn't got a response from backend and returned a gateway error) is a norm of life, and all systems must be designed in a manner that in a case of an exception of any kind, API clients must be able to restore their state and continue operating normally.
The more partners use the API, the more chance is that some of the mechanisms of the expected workflow are implemented wrongly. In other words, not only genuine errors related to network or server overload should be expected, but also logical ones caused by improper API usage (and, in particular, there should be safeguards to avoid errors in one partner's code leading to a denial of service for other partners).
Any part of the system might introduce unpredictable latencies when serving requests, and these latencies could be quite high, up to seconds and tens of seconds. Even if you have full control over the execution environment and network, client apps may hinder themselves due to suboptimal code or execution on low-performing or overloaded devices. As a result, it is important to ensure that proper API design does not rely on critical operations being executed quickly. This includes:
If carrying out a task through the API requires making a sequence of calls, there should be a mechanism in place to resume the operation from the current step if needed, instead of restarting it from the beginning.
Operations that affect shared resources should have locking mechanisms in place for the duration of the operation.
Chapter 16. Authenticating Partners and Authorizing API Calls
Before we proceed further to discussing technical matters, we feel obliged to provide an overview of the problems related to authorizing API calls and authenticating clients. Based on the main principle that “an API serves as a multiplier to both your opportunities and mistakes,” organizing authorization and authentication (AA) is one of the most important challenges that any API vendor faces, especially when it comes to public APIs. It is rather surprising that there is no standard approach to this issue, as every big vendor develops its own interface to solve AA problems, and these interfaces are often quite archaic.
If we set aside implementation details (for which we strongly recommend not reinventing the wheel and using standard techniques and security protocols), there are basically two approaches to authorizing an API call:
Introducing a special “robot” type of account into the system, and carrying out the operations on behalf of the robot account.
Authorizing the caller system (backend or client application) as a single entity, using API keys, signatures, or certificates for the purpose of authenticating such calls.
The difference between the two approaches lies in the access granularity:
If an API client is making requests as a regular user of the system, then it can only perform operations allowed for a specific user, which often means it might have access only to a partial dataset within the API endpoint.
If the caller system is authorized, it implies that it has full access to the endpoint and can supply any parameters, i.e., might operate the full dataset exposed through the endpoint.
Therefore, the first approach is more granular (the robot might be a “virtual employee” with access only to a limited dataset) and is a natural choice for APIs that are supplemental to an existing service for end users (and thus can reuse the existing AA solutions). However, this approach has some disadvantages:
The need to develop a process for securely fetching authorization tokens for the robot user (e.g., via having a real user generate tokens in the web UI), as regular login-password authentication (especially multi-factored) is not well-suited for API clients.
The need to make exceptions for robot users in almost every security protocol:
Robots might make many more requests per second than real users and might perform several queries in parallel (possibly from different IP addresses located in different availability zones).
Robots do not accept cookies and cannot solve captchas.
Robots should not be logged out or have their token invalidated (as it would impact the partner's business processes), so it is usually necessary to invent specific long-lived tokens for robots and/or token renewal procedures.
Finally, you may encounter significant challenges if you need to allow robots to perform operations on behalf of other users (as you will have to either expose this functionality to all users or, vice versa, hide its existence from them).
If the API is not about providing additional access to a service for end users, it is usually much easier to opt for the second approach and authorize clients with API keys. In this case, per-endpoint granularity can be achieved (i.e., allowing partners to regulate the set of permitted endpoints for a key), while developing more granular access can be much more complex and because of that rarely see implementations.
Both approaches can be morphed into each other (e.g., allowing robot users to perform operations on behalf of any other users effectively becomes API key-based authorization; allowing binding of a limited dataset to an API key effectively becomes a user account), and there are some hybrid systems in the wild (where the request must be signed with both an API key and a user token).
Chapter 17. Synchronization Strategies
Let's proceed to the technical problems that API developers face. We begin with the last one described in the introductory chapter: the necessity to synchronize states. Let us imagine that a user creates a request to order coffee through our API. While this request travels from the client to the coffee house and back, many things might happen. Consider the following chain of events:
The client sends the order creation request
Because of network issues, the request propagates to the server very slowly, and the client gets a timeout;
therefore, the client does not know whether the query was served or not.
The client requests the current state of the system and gets an empty response as the initial request still hasn't reached the server:
const pendingOrders = await
api.getOngoingOrders(); // → []
The server finally gets the initial request for creating an order and serves it.
The client, being unaware of this, tries to create an order anew.
As the operations of reading the list of ongoing orders and of creating a new order happen at different moments of time, we can't guarantee that the system state hasn't changed in between. If we do want to have this guarantee, we must implement some synchronization strategy. In the case of, let's say, operating system APIs or client frameworks we might rely on the primitives provided by the platform. But in the case of distributed client-server APIs, we would need to implement such a primitive of our own.
There are two main approaches to solving this problem: the pessimistic one (implementing locks in the API) and the optimistic one (resource versioning).
NB: generally speaking, the best approach to tackling an issue is not having the issue at all. Let's say, if your API is idempotent, the duplicating calls are not a problem. However, in the real world, not every operation is idempotent; for example, creating new orders is not. We might add mechanisms to prevent automatic retries (such as client-generated idempotency tokens) but we can't forbid users from just creating a second identical order.
API Locks
The first approach is to literally implement standard synchronization primitives at the API level. Like this, for example:
let lock;
try {
// Capture the exclusive
// right to create new orders
lock = await api.
acquireLock(ORDER_CREATION);
// Get the list of current orders
// known to the system
const pendingOrders = await
api.getPendingOrders();
// If our order is absent,
// create it
if (pendingOrders.length == 0) {
const order = await api
.createOrder(…)
}
} catch (e) {
// Deal with errors
} finally {
// Unblock the resource
await lock.release();
}
Rather unsurprisingly, this approach sees very rare use in distributed client-server APIs because of the plethora of related problems:
Waiting for acquiring a lock introduces new latencies to the interaction that are hardly predictable and might potentially be quite significant.
The lock itself is one more entity that constitutes a subsystem of its own, and quite a demanding one as strong consistency is required for implementing locks: the
getPendingOrders
function must return the up-to-date state of the system otherwise the duplicate order will be anyway created.As it's partners who develop client code, we can't guarantee it works with locks always correctly. Inevitably, “lost” locks will occur in the system, and that means we need to provide some tools to partners so they can find the problem and debug it.
A certain granularity of locks is to be developed so that partners can't affect each other. We are lucky if there are natural boundaries for a lock — for example, if it's limited to a specific user in the specific partner's system. If we are not so lucky (let's say all partners share the same user profile), we will have to develop even more complex systems to deal with potential errors in the partners' code — for example, introduce locking quotas.
Optimistic Concurrency Control
A less implementation-heavy approach is to develop an optimistic concurrency control system, i.e., to require clients to pass a flag proving they know the actual state of a shared resource.
// Retrieve the state
const orderState =
await api.getOrderState();
// The version is a part
// of the state of the resource
const version =
orderState.latestVersion;
// An order might only be created
// if the resource version hasn't
// changed since the last read
try {
const task = await api
.createOrder(version, …);
} catch (e) {
// If the version is wrong, i.e.,
// another client changed the
// resource state, an error occurs
if (Type(e) == INCORRECT_VERSION) {
// Which should be handled…
}
}
NB: an attentive reader might note that the necessity to implement some synchronization strategy and strongly consistent reading has not disappeared: there must be a component in the system that performs a locking read of the resource version and its subsequent change. It's not entirely true as synchronization strategies and strongly consistent reading have disappeared from the public API. The distance between the client that sets the lock and the server that processes it became much smaller, and the entire interaction now happens in a controllable environment. It might be a single subsystem in a form of an ACID-compatible database or even an in-memory solution.
Instead of a version, the date of the last modification of the resource might be used (which is much less reliable as clocks are not ideally synchronized across different system nodes; at least save it with the maximum possible precision!) or entity identifiers (ETags).
The advantage of optimistic concurrency control is therefore the possibility to hide under the hood the complexity of implementing locking mechanisms. The disadvantage is that the versioning errors are no longer exceptional situations — it's now a regular behavior of the system. Furthermore, client developers must implement working with them otherwise the application might render inoperable as users will be infinitely creating an order with the wrong version.
NB: which resource to select for making versioning is extremely important. If in our example we create a global system version that is incremented after any order comes, users' chances to successfully create an order will be close to zero.
This is Chapters 15-17 of “The API” book being written by Sergey Konstantinov. I also have a book on the history of beer and historical beer styles, a Telegram channel on interesting classical music recordings, and a travel photo blog on Unsplash.