DEV Community

Cover image for Documenting REST APIs with OpenAPI
Nik Begley for Doctave

Posted on • Originally published at doctave.com

Documenting REST APIs with OpenAPI

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"
Enter fullscreen mode Exit fullscreen mode


(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
        ...
Enter fullscreen mode Exit fullscreen mode

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
  • Path parameters
    • parameters in the path section of the URI

Here's a (slighty contrived) example operation for querying the history of a pet:

/pets/{petId}/history?page=2
      ───┬───         ───┬──
  path ──┘               │
 query ──────────────────┘
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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",
                              },
                            ],
                        },
                      ],
                  }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 and summary 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)