Most APIs today are designed as RESTful APIs. REST (Representational State Transfer) is an architectural style for designing networked applications. RESTful APIs use HTTP to expose resources and perform operations on them using standard HTTP verbs, such as POST
, GET
, and DELETE
.
In this post, we'll explore how to document RESTful APIs using OpenAPI, an industry-standard specification for describing, producing, consuming, and visualizing RESTful web services. We'll focus on documenting a single operation, its components, and the tools OpenAPI provides to create comprehensive documentation.
In this post we'll expect that you have a tool that is able to produce a documentation site for you given an OpenAPI spec. Doctave supports OpenAPI but these concepts are transferable to any tool.
What is in an operation?
In the context of a RESTful API, an operation refers to a single action you can perform. Each operation consists of the following components:
- HTTP Verb (such as
POST
,GET
,PUT
,UPDATE
,DELETE
) - A URI path (such as
/users
) - Parameters
- A request body
With these components, you can invoke the operation and receive a response. The response itself contains:
- A response body
- A status code
- A content type
OpenAPI operation model
OpenAPI provides a standardized model for defining operations in RESTful APIs. In an OpenAPI document, operations are organized under the paths field. The paths field defines all possible URI paths for the API, and each path can have operations associated with different HTTP verbs:
paths: # <- Top level `paths` field
/pets: # <- The URI path for the operation
get: # <- The HTTP verb
description: Returns all pets from the system
responses:
"200": # <- The response for a 200 OK status
description: A list of pets.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Pet"
(You can find all the examples used here in the official OpenAPI GitHub Repo)
Building on the this example, the /pets
path is defined to handle a GET
request. When a GET
request is issued, it returns a list of objects describing the available pets. We can see here the main components of an operation: the URI path, the HTTP verb, and the response. Notice that in this case, the operation does not require any parameters or a request body.
For a more in-depth understanding of the different parts of OpenAPI, you can refer to the official OpenAPI specification. While it may be verbose, it serves as a valuable reference for comprehending how OpenAPI components work together.
Where does the documentation go?
OpenAPI was luckily built with documentation in mind. After all, it's designed to be used for generating human readable documentation, among other use cases. Most OpenAPI objects include a description
field. You can see some in the example above! This field is your friend and a valuable tool for writing detailed explanations of the corresponding object
Additionally, you can use Markdown! The OpenAPI spec specifically states that description
should support the CommonMark Markdown flavor. This means you can include clickable links, lists, and anything else you can describe in Markdown.
Some objects also feature a summary
field, intended for concise descriptions of the operation's purpose.
Beyond these fields, it's important to consider clear and consistent naming for the objects and operations themselves in your API. It will help readers search and skim your docs quickly to find the sections they are looking for.
Parameters
If an operation is like a function in a programming language, parameters are like function arguments.
There are four of ways to pass parameters in an operation:
- Query parameters
- Path parameters
- Headers
- Cookies
Parameters are defined in the operation's parameters
field:
/pets/{petId}: # <- Note the URI path with a placeholder
get:
summary: Info for a specific pet
operationId: showPetById
tags:
- pets
parameters: # <- List of parameters for this operation
- name: petId
in: path
required: true
description: The id of the pet to retrieve
...
The above operation says you can request details of a specific pet by specifying the pet ID as a path parameter. The in
field for the parameter defines how to pass the parameter to the operation. It can be one of query
, path
, header
, or cookie
.
Additionally, each parameter can have an associated schema. Generally, these schemas are simple, as parameters are often IDs or short strings. More on these shortly!
Query and Path Parameters
Let's look at query and path parameters together. Since they are very similar.
-
Query parameters
- parameters at the end of the URI, after a
?
as a series of key value pairs
- parameters at the end of the URI, after a
-
Path parameters
- parameters in the
path
section of the URI
- parameters in the
Here's a (slighty contrived) example operation for querying the history of a pet:
/pets/{petId}/history?page=2
───┬─── ───┬──
path ──┘ │
query ──────────────────┘
The petId
path parameter is part of the URI path. Commonly these parameters are used when referencing a specific resource such as a user. Usually the parameter is in the form of a unique ID, such as an integer, or a unique username.
The query parameter in this case is the page=2
section. Query parameters are often used for pagination and filtering results. Although query parameters can be used to identify resources, it's not a common practice.
Headers
Headers can be thought of as metadata about the request. They are additional information sent along with the request, such as the User Agent, which determines what kind of client is making the request.
You will most likely encounter headers in reference to API authentication (see below), but there are other uses as well, such as specifying in what format the payload you are sending is, or what format you would like to receive.
Cookies
Cookies are less commonly used in HTTP APIs, but they may still appear in some cases. Similar to headers, cookies are often used by browsers for tasks like user authentication. However, in the context of APIs, their usage is relatively rare, as they go against the RESTful principle of statelessness.
OpenAPI schemas
Before moving on, let's address a key question:
How do you actually describe what the values of these various parameters should be? Can we specify that the page
query parameter has to be an integer greater than zero? This is where OpenAPI schemas come in.
If we look at one of the previous examples:
paths:
/pets:
get:
description: Returns all pets from the system
responses:
"200":
description: A list of pets.
content:
application/json:
schema: # <- NOTE the schema here
type: array
items:
$ref: "#/components/schemas/Pet"
On line 10, we state that the response should have a particular schema—an array of pets. The schema's item
points to another part of the spec to a shared pet schema component. Let's see what that looks like:
components:
schemas:
Pet:
type: object
required:
- id
- name
properties:
id:
type: integer
format: int64
name:
type: string
description: The name of the pet
tag:
type: string
Now we're talking! This is what a pet looks like.
The pet has a type
of object
. This means it has a number of properties, or fields. In this case we have id
, name
, and tag
properties for this pet object.
This is where OpenAPI schemas gets really cool: they can be nested. Pet
is a schema defining an object
, and the properties each define child schemas. The id
property is a schema with a type
of integer
.
This allows for the definition of complex, deeply nested objects.
Also, each schema can include the previously described description
field. Which means you can document every property as required.
You can also specify all kinds of other requirements and constraints in schemas. A non-exhaustive list includes:
- Maximum and minimum values (for numeric types)
- Maximum and minimum length (for string types)
- A list of possible values (enums)
- A regular expression pattern
I highly recommend reading the official OpenAPI guides on schemas for more examples.
Request Body
Ok, with the schema detour out of the way, lets move onto request bodies, where schemas play a big part!
POST
, PUT
, and UPDATE
HTTP requests usually contain a request body. This is the payload you want to send to the server along with the request. It will most likely be JSON encoded string, though other formats are possible.
As with parameters, the request body is in fact one large schema definition:
/pets:
post:
description: Creates a new pet in the store. Duplicates are allowed
requestBody:
description: Pet to add to the store
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewPet"
Here we again refer to a shared component schema to define the shape of the request body, this time called NewPet
:
components:
schemas:
NewPet:
type: object
required:
- name
properties:
name:
type: string
tag:
type: string
This looks very similar to the parameter examples. Turns out OpenAPI is just schemas all the way down.
One thing to note about request bodies is that they are placed under a content type. In this case, it's application/json
, meaning that the request body should be encoded as JSON.
Responses
Responses are what the operation returns to you under various conditions. Just like the request body, the response shape is defined using schemas.
Since the server can return multiple different statuses, you can define schemas for each of the HTTP status codes. For example, if you are creating a resource by calling the operation, you should return a 201 status. This in itself documents that something was created.
/pets:
post:
description: Creates a new pet in the store. Duplicates are allowed
operationId: addPet
requestBody:
description: Pet to add to the store
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewPet"
responses: # <- NOTE responses here
"201":
description: pet response
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
default:
description: unexpected error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
In this example, we see that creating a pet has two possible responses: a pet, or an error. A 201 status will return the pet, but any other status code (using the default
catch-all) will return the error.
Again, just as with request bodies, the schemas are located under the content type of the response.
Errors
It's worth talking for a moment about errors in general.
Proper error handling is important in order to have a developer-friendly API. If your API spews out a generic an error occurred, try again
message when the inputs are not just right, you will have a lot of frustrated users.
OpenAPI allows you to document error responses alongside your successful responses too. Errors work just like regular responses. Consider thinking carefully what your various 4xx
status responses return, and documenting them along with your happy paths.
It's also important to use proper status codes for the type of error you are returning. For example:
- 403 when accessing a resource the user is not authorized for
- 404 when the resource does not exist
- 418 when the server is a teapot
Consider also returning a human-readable description of the error in the body of the error response. It can actually limit your support load and let people debug their issues themselves.
Examples
Something we have not yet mentioned about request bodies and responses are examples. You can give concrete examples of what the body of a request should look like for each case.
Here's a (verbose) example (pun intended):
/:
get:
operationId: listVersionsv2
summary: List API versions
responses:
"200":
description: |-
200 response
content:
application/json:
examples: # <- Example responses for this status
foo:
value:
{
"versions":
[
{
"status": "CURRENT",
"updated": "2011-01-21T11:33:21Z",
"id": "v2.0",
"links":
[
{
"href": "http://127.0.0.1:8774/v2/",
"rel": "self",
},
],
},
{
"status": "EXPERIMENTAL",
"updated": "2013-07-23T11:33:21Z",
"id": "v3.0",
"links":
[
{
"href": "http://127.0.0.1:8774/v3/",
"rel": "self",
},
],
},
],
}
Here under the examples
field we can list one or more examples. As you can see, this works by including the literal response JSON in the spec.
(Fun fact: JSON is actually valid YAML.)
You can define multiple examples for a single status to illustrate different use cases or scenarios. In the above example we only have one example: the terribly named foo
example. But you could instead of have properly named examples that describe the different cases in your domain.
Authentication
Finally, let's talk about authentication. We previously mentioned that API authentication usually happens with header parameters. But because there are more complex authentication mechanisms, and since authentication is core to almost every API available, OpenAPI has specific methods for describing how to authenticate against your API.
In OpenAPI's terminology, these methods are called security schemes. They define the authentication process for your API. There are 4 types of security schemes:
- Basic Authentication
- API Keys / JWT Bearer Token
- OAuth2
- OpenID Connect
We won't go into the specifics of these schemes in this post. I will just mention that security schemes also support the description
field. Some of these authentication mechanism can be complicated and require multiple steps to configure. It is usually a good idea to refer to a longer tutorial in your description that you have written outside of the OpenAPI spec itself.
Authentication is often the first thing a new user has to deal with in order to get started with an API, so having solid documentation in this area is worth it.
OAuth2 Scopes
One thing to mention around operations is OAuth2 scopes. OAuth2 supports the concept of "scopes", which are similar to permissions around what the user can do once authenticated.
OpenAPI operations can specify which scopes they require in order to be called. If the authenticated caller does not have the required scopes, the operation should return an error and not give the called access.
Here's a brief example:
# First we define an OAuth2 security scheme
components:
securitySchemes:
OAuth2:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://example.com/oauth/authorize
tokenUrl: https://example.com/oauth/token
scopes:
read: Grants read access
write: Grants write access
admin: Grants access to admin operations
paths:
/billing_info: # Only admins can view billing info
get:
summary: Gets the account billing info
security:
- OAuth2: [admin] # <- Note the required admin scope
responses:
"200":
description: OK
"401":
description: Not authenticated
"403":
description: Access token does not have the required scope
/ping:
get:
summary: Checks if the server is running
security: [] # <- No scopes required. Everyone can access
responses:
"200":
description: Server is up and running
default:
description: Something is wrong
The scopes are defined as part of the OAuth2 security scheme. Each scope can have an associated description, which you should use to describe the purpose of the scope.
Note the descriptive error statuses for the /billing_info
operation. If your authentication token is missing, or you don't have the required scopes, you get an appropriate error.
Conclusions
OpenAPI is a fairly complicated specification, but it can be incredibly powerful for documenting your REST APIs.
Key points to remember when working with OpenAPI include:
- Understanding what goes into calling an operation
It's important to know what is required in to invoke the operations in your API: how to authenticate against it, and what parameters to provide.
These need to be made clear in your documentation.
- Using
description
andsummary
fields effectively
This will let your documentation generator produce effective and readable documentation sites. Also consider linking to longer explanations from your description
fields when required.
- Providing real examples for request bodies and responses
Nothing beats a real example. Even better if you can provide your user an example that they can copy-paste into their terminal to start playing around.
- Using HTTP correctly and returning approriate errors
HTTP itself can be quite descriptive. Work with the engineers designing the API to make sure you are using status codes and error messages effectively.
Top comments (0)