REST API Design Best Practices for Parameter and Query String Usage
When we’re designing APIs the goal’s to give our users some amount of power over the service we provide. While HTTP verbs and resource URLs allow for some basic interaction, oftentimes it’s necessary to provide additional functionality or else the system becomes too cumbersome to work with.
An example of this is pagination: we can’t send every article to a client in one response if we have millions in our database.
A way to get this done is with parametrization.
What is Parametrization
Generally speaking, parametrization is a kind of request configuration.
In a programming language, we can request a return value from a function. If the function doesn’t take any parameters, we can’t directly affect this return value.
Same goes with APIs, especially stateless ones like REST APIs. Roy Fielding said this eloquently:
All REST interactions are stateless. That is, each request contains all of the information necessary for a connector to understand the request, independent of any requests that may have preceded it.
There are many ways in HTTP to add parameters to our request: the query string, the body of POST, PUT and PATCH requests, and the header. Each has its own use-cases and rules.
The simplest way to add in all parameter data is to put everything in the body. Many APIs work this way. Every endpoint uses POST and all parameters are in the body. This is especially true in legacy APIs that accumulated more and more parameters over a decade or so, such that they no longer fit in the query string.
While this is more often the case than not, I’d consider it an edge case in API design. If we ask the right questions up front, we can prevent such a result.
What kind of parameter do we want to add?
The first question we should ask ourselves is what kind of parameter we want to add?
Maybe it’s a parameter that is a header field already standardized in the HTTP specification.
There are many standardized fields. Sometimes we can reinvent the wheel and add the information to another place. I’m not saying we can’t do it differently. GraphQL, for example, did what I’d consider crazy things from a REST perspective, but it still works. Sometimes it’s just simpler to use what’s already there.
Take for example the Accept
header. This allows us to define the format, or media type, the response should take. We can use this to tell the API that we need JSON
or XML
. We can also use this to get the version of the API.
There is also a Cache-Control
header we could use to prevent the API from sending us a cached response with no-cache
, instead of using a query string as cache buster (?cb=<RANDOM_STRING>
)
Authorization could be seen as a parameter as well. Depending on the detail of authorization of the API, different responses could result from authorized or unauthorized. HTTP defines an Authorization
header for this purpose.
After we check all the default header fields, the next step is to evaluate if we should create a custom header field for our parameter, or put it into the query string of our URL.
When should we use the query string?
If we know the parameters we want to add don’t belong in a default header field, and aren’t sensitive, we should see if the query string is a good place for them.
Historically the use of the query string was, as the name implies, to query data. There was a <isindex>
HTML element that could be used to send some keywords to a server and the server would respond with a list of pages that matched the keywords.
Later the query string was repurposed for web-forms to send data to a server via a GET request.
Therefore, the main use-case of the query string is filtering and specifically two special cases of filtering: searching and pagination. I won’t go into detail here, because we’ve already tackled them in this article.
But as repurposing for web-forms shows, it can also be used for different types of parameters. A RESTful API could use a POST or PUT request with a body to send form data to a server.
One example would be a parameter for nested representations. By default, we return a plain representation of an article. When a ?withComments
query string is added to the endpoint, we return the comments of that article in-line, so only one request is needed.
Should such a parameter go into a custom header or the query string is mostly a question of developer experience.
The HTTP specification states that header fields are kind of like function parameters, so they are indeed thought of as the parameters we want to use. However, adding a query string to an URL is quickly done and more obvious than creating a customer header in this case.
These fields act as request modifiers, with semantics equivalent to the parameters on a programming language method invocation.
Parameters that stay the same on all endpoints are better suited for headers. For example, authentication tokens get sent on every request.
Parameters that are highly dynamic, especially when they’re only valid for a few endpoints, should go in the query string. For example filter parameters are different for every endpoint.
Bonus: Array and Map Parameters
One question that often crops up is what to do about array parameters inside the query string?
For example, if we have multiple names we want to search.
One solution is the use of square brackets.
/authors?name[]=kay&name[]=xing
But the HTTP specification states:
A host identified by an Internet Protocol literal address, version 6[RFC3513] or later, is distinguished by enclosing the IP literal within square brackets (“[” and “]”). This is the only place where square bracket characters are allowed in the URI syntax.
Many implementations of HTTP servers and clients don’t care about this fact, but it should be kept in mind.
Another solution that is offered is simply using one parameter name multiple times:
/authors?name=kay&name=xing
This is a valid solution but can lead to a decrease in developer experience. Oftentimes clients just use a map-like data structure, that goes through a simple string conversion before being added to the URL, potentially leading to overriding the following values. A more complex conversion is needed before the request can be sent.
Another way is to separate the values with ,
characters, which are allowed unencoded inside URLs.
/authors?name=kay,xing
For map-like data structures, we can use the .
character, which is also allowed unencoded.
/articles?age.gt=21&age.lt=40
It is also possible to URL-encode the whole query string, so that it can use whatever characters or format we want. It should be kept in mind that this can also decrease developer experience quite a bit.
When shouldn’t we use the query string?
The query string is part of our URL, and our URL can be read by everyone sitting between the clients and the API, so we shouldn’t put sensitive data like passwords into the query string.
Also, developer experience suffers greatly if we don’t take URL design and length seriously. Sure, most HTTP clients will allow a five-figure length of characters in an URL, but debugging such kinds of strings is not very pleasant.
Since anything can be defined as a resource, sometimes it can make more sense to use a POST endpoint for heavy parameter usage. This lets us send all the data in the body to the API.
Instead of sending a GET request to a resource with multiple parameters in the query string, that could lead to a really long un-debuggable URL, we could design it as a resource (e.g. search-resource). Depending on the things our API needs to do to satisfy our request, we could even use this to cache our computation results.
We would POST a new request to our /searches
endpoint, that holds our search configuration/parameters in the body. A search ID is returned, which we can use later to GET the results of our search.
Conclusion
As with all best practices, our job as API designers and architects isn’t to follow one approach as “the best solution” but to find out how our APIs are used.
The most frequent use cases should be the simplest to accomplish and it should be really difficult for a user to do something wrong.
Thus, it’s always important to analyze our API usage patterns right from the start - the earlier we have data, the easier it is to implement changes if we messed up our design. Moesif’s analytics service can help with that.
Moesif is the most advanced API analytics service used by thousands of platforms to measure usage patterns of their customers.
If we go one way because it’s simpler to grasp or easier to implement, we have to look at what we get out of it.
As nested resources can be used to make URLs more readable, they can also become too long and unreadable if we nest too many. Same goes for parameters. If we find ourselves creating one endpoint that has a huge query string, it might be better to extract another resource out of it and send the parameters inside the body.