# GET Requests and Query Parameters

When searching/filtering a resource or list or when changing the default behavior of an endpoint or its return value, the parameters for the request should be included in the query. Although the specification is ambiguous as to whether a GET request may have a body, it is best/standard practice to avoid sending a body in these requests and instead use query parameters.

GET http://api.com/item?assigneeId=1234&status=incomplete

Oftentimes, when requesting a result set be filtered on a particular field with multiple potential values, a user may want to include results for more than one of those values. In those circumstances, a query param may be specified more than once to indicate the parameter is an array.

GET http://api.com/transaction?status=PENDING&status=POSTED


# Pagination

Our standard approach to pagination for all endpoints that require it. In the query parameters, clients may send a pageNumber and/or pageSize param to manipulate the response. The endpoint or server should have default pagination settings if one or both of these params are not provided by the client, we typically use 1 and 10 for the pageNumber and pageSize respectively.

From the API perspective, paginated JSON responses should always include the following:

  • A pageInfo property that is an object providing information about the collection with at least the following properties formatted as integers
    • pageSize
    • pageNumber
    • numberOfPages
    • numberOfRecords
  • Another property results that includes the paginated data
    • For example, if the resource path is /application/smarthub/user then the results property should be an array of users

# Pagination Example 1

Request:

GET /application/smarthub/user

Response:

{
  "pageInfo": {
    "pageSize": 10,
    "pageNumber": 1,
    "numberOfPages": 123,
    "numberOfRecords": 1230
  },
  "results": [
    {
      "firstName": "",
      "lastName": "",
      "username": ""
    }
  ]
}

# Pagination Example 2

Request:

GET /application/smarthub/user?pageSize=5&pageNumber=2

Response:

{
  "pageInfo": {
    "pageSize": 5,
    "pageNumber": 2,
    "numberOfPages": 246,
    "numberOfRecords": 2460
  },
  "results": []
}

# URL Naming Conventions

# Use singular nouns in endpoints

In order to limit confusion and avoid irregular plurals, use singular nouns in URL schemas when representing collections.

Don't: /animals/octopuses, /animals/octopi, /animals/octopodes

Do: /animal/octopus, /animal/octopus/123

# Use lower kebab case

In order to improve readability and eliminate potential issues with case sensitivity, URL paths should always be in lower kebab case.

Don't: /Users/ByGUID

Do: /users/by-guid


# URL Structuring - Flat vs Nested

Nesting within URLs can be used to represent relationships between resources that may not be easily conveyed otherwise.

Say you have an api that lists roles belonging to a user within an application. Structuring the request as GET /application/:applicationRef/user/:userRef/role makes it clear that you are requesting roles that belong to or are a part of a specific user within a specific application. If this request were handled using querystring parameters it might look like this: /role?application={applicationRef}&user={userRef}. Using querystring parameters in this way does not communicate the role to user to application relationship as unambiguously as nesting them within the URL structure does.

This URL structuring pattern is most commonly applicable when you have a series of nested many-to-many relationships within the data of an application.

Handling the GET verb for this URL should be simple enough in most cases that it can be the default approach for representing these nested relationships. However, the implementation of this pattern may not be so simple when considering the POST, PUT, and PATCH verbs.

For example, when PUT-ing a role at a URL such as /application/:applicationRef/role/:roleRef the request will have a body that may or may not include a duplicate parameter for the applicationRef and roleRef fields in the URL path arguments. The request handler (or some other validation layer) for this request should now validate that the URL parameters match the corresponding parameters in the request body. In some application frameworks it may be trivial to bind request data from multiple sources and enforce that these fields match, but it may not be in others. Depending on the underlying application and/or framework, it may be much simpler to simply accept state changing requests for this example at /role/:roleRef where all of the fields needed to process the request come from the request body.

The main guidance here is to provide a consistent, predictable pattern for API consumers. It is acceptable to process state changing requests in a nested URL schema such as PUT /application/:applicationRef/role/:roleRef or to flatten the URL and handle a similar request at /role/:roleRef as long as the parameters removed from the URL are included in the body instead. Whichever of these approaches is chosen on a per-api basis it should be implemented and enforced consistently.


# Logging

# Request logging

We use the Apache combined log format for logging all API requests. The combined log format is preferred over the standard access log in support of API version dependency tracking and helps inform us on which UI applications are calling particular versions of an API endpoint.

This format can be viewed on the Apache website: https://httpd.apache.org/docs/2.4/logs.html#combined

Example:

10.200.19.145:54680 - test.automation@midfirst.com [17/07/2023:19:46:31 +0000] "GET /applications/smarthub/role-groups  HTTP/1.1" 200 749 "https://smarthub.qa.mfbdigital.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"

These access logs should be shipped to a centralized log aggregation service via a Nomad side-car task where they can be stored and later queried for endpoint dependency insights. This allows us to deprecate and remove endpoints as API development moves forward.


# API Versioning

For more common, shared APIs that service multiple user interfaces (such as the employee API) the entire API should be versioned as a whole. i.e. when a breaking change is introduced into a single endpoint, all endpoints version number should be incremented, not just the endpoint responsible for the change.

Application-specific APIs (such as CCTS) can be versioned at the noun/verb level as needed.


# Avoid Redundancy in Interface Properties

Consider the following:

interface Thing {
  thingID: number;
  thingLabel: string;
  thingValue: number;
  thingCreatedOn: string;
}

In the above example, using the thing prefix in the property names for type Thing is unnecessary.

Prefer an interface more like this:

interface Thing {
  id: number;
  label: string;
  value: number;
  createdOn: string;
}

# PUT vs PATCH for Updates

Consider the follow type returned from a user detail endpoint /v1/user/:id:

type User = {
  id: number;
  firstName: string;
  lastName: string;
  username: string;
  email: string;
  avatarColor: string | null;
  createdDate: string | null;
  createdBy: number | null;
  updatedDate: string | null;
  updatedBy: number | null;
};

Some of the fields (id, createdBy, createdDate, etc) returned when fetching this resource cannot be mutated through the API. These fields are considered static and may not be included in the request to update this object.

In these cases, we have a different type we use to update this object:

type UpdateUserArgs = {
  firstName: string;
  lastName: string;
  username: string;
  email: string;
  avatarColor: string | null;
};

The endpoint supports the mutation of these fields and these fields only. When determining whether to use PUT or PATCH as the verb to update the object we will consider only the fields that can be mutated and ignore fields that are static.

If the update endpoint requires every field that can be mutated be included in the request, then the verb should be PUT. If fields can be optionally updated or omitted then it should be a PATCH.

GET /v1/user/:userId -> User PUT /v1/user/:userId <- UpdateUserArgs PATCH /v1/user/:userId <- Partial<UpdateUserArgs>

We generally prefer to use PUT over PATCH because it removes any ambiguity about the intentions of the caller, particularly when we consider nullable fields vs empty values.


# Keep Property Names Consistent Across Use Cases

Given the Thing interface above:

For an endpoint GET /thing that returns a collection of Things and supports searching/filtering, avoid naming query parameters something other than their corresponding interface names.

Don't: GET /thing?name=myName&val=myVal

Do: GET /thing?label=myName&value=myVal

This applies to any other type of request, such as PATCH or PUT. Properties used for searching/filtering, updating, and creating objects should map 1:1 to their corresponding properties in GET requests.

You will frequently encounter cases where you are able to filter on properties that don't exist. A common example of this is "I want to see Things that were created or updated between these dates." In this scenario, it is perfectly acceptable to add support for a property in the querystring that does not exist on the Thing interface.


# Named value/ref objects

Generally speaking, objects at the API surface should be as flat as possible.

If an object has some sub-object on it that consists only of an id/ref and value then, typically, only one of those should be returned as a single property on the outer object (rather than a nested object).

  • When the property is used for display purposes only, then the value/label should be returned.
  • When the property is to be used in a dropdown, form field, or some other dynamic context, then returning the id/ref may be best.
    • In the majority of these circumstances, the UI should also retrieve the full list of available options in a separate API call.

Edge cases for this general rule would be if the full list of options is particularly large (thousands of options). These types of edge cases should be handled at the discretion of the team.


# Request/Response Headers

A request body should be a representation of a resource in its current state or the state we are requesting that it be in. So any additional information or metadata about a request is best put into a request or response header. Examples of this would include a request id or transaction id, authorization information, client information, anything related to a request but not directly belonging to the resource in question.


# Bypassing of filters (default or supplied) via include

For collections that have default filters or allow client supplied filters via query params or other mechanisms AND require the ability to override those filters, an include parameter should be supported. This parameter provides a client the ability to guarantee that objects matching the id(s) supplied are included in the response for the collection regardless of other filter values.

For paginated responses, the supplied include values should not be moved into the current page but, rather, appear in whatever position they would otherwise be in. For example, in the request GET /user?pageSize=10&pageNumber=1&include=1234&include4321 if user of id 1234 would naturally appear on page 3 of the results (considering any other filters applied) then that user should not appear on page 1 of results just because the inclusion of that object is overridden by the use of include.

# Summary vs detail endpoints for collections

Endpoints returning collections should always return the minimum possible interface to fulfill the requirements of all clients (except when they shouldn't).

Sometimes there are special cases that may require a new endpoint that returns a larger, more detail-like, interface when returning a collection. In these scenarios, append the word detail to the noun in the URL. For example, if the list /user returns a minimal interface and you need a list that returns a much more detailed interface, the new list should be named user-detail. UserDetail interface should be an extension of User.

Other times, the default endpoint for a collection may need to be the larger, more detailed interface. If a smaller interface is needed for something like a quick search feature, a new endpoint that fulfills this role should append the word summary. For example, if the list /user returns a large interface and you need a list that returns a much smaller interface, the new list should be named user-summary.

In either of these cases, the most common scenario of fetching a collection for any given noun should be the undecorated one and we should ALWAYS prefer the default to be the minimum possible interface to cover most use cases.

In both of these cases, the single item "detail" endpoint should be at the default, e.g. /noun/{identity} and this endpoint should return the complete interface for GET/PUT/PATCH requests.

# Fetching a single record by unique identifier that is not the default identifier

Oftentimes a record in a database will have fields that guarantee uniqueness in columns other than the default identity field such as externalId or guid/uuid. This is particularly true when the data has some system of record other than the current system in question (vendor data such as Okta). When working with data across systems, you may need to fetch a single record by a field that is not the default identity field and receive a 404 if that record does not exist.

In these circumstances, a new endpoint should be created to retrieve the record by this field. These endpoints should exist as sub-folders to the collection/list in question and be named in kebob-case after the field being used to retrieve the record.

Example:

GET /applications returns a list of applications while GET /applications/1234 returns a single application by id and a 404 if an application of id=1234 was not found. You want to retrieve an application with an externalId of mfb1234. In this scenario, a new endpoint should be created to retrieve applications by externalId. The endpoint should be /application/by-external-id/{externalId}. With this new endpoint, a GET call to /application/by-external-id/mfb1234 should return the application having an externalId of mfb1234 or a 404 if it does not exist.

# Globally unique sub-resources

Oftentimes, a sub-resource that is normally fetched in the URL context of another resource will itself be globally unique.

Consider:

GET /application/1234/role

Here, we are saying that role is a sub-resource of application. However, it is possible that role is a globally unique item unto itself outside of the context of the application to which it belongs.

Normally, the most RESTful approach would be to always create, read, update, and delete items at the same level of a URL schema. However, in circumstances such as this (where the uniqueness of a role is globally guaranteed), it is acceptable to create roles at POST /role and update or delete roles at /role/{roleId}.