In my previous post I announced our adoption of OpenAPI v3 to describe the DNSimple API. I went into why we choose to adopt OpenAPI v3, the tools we used, and some of the challenges we faced. In this post I am diving into the details of our OpenAPI definition for the DNSimple API, showing how OpenAPI elements describe API functionality.

One quick note: throughout this post, whenever I write OpenAPI, I mean OpenAPI v3, the version of the OpenAPI specification that we are using.

Getting Started

The definition for our API is available in both JSON and YAML versions. We edit the YAML version of the document due to its simple and barebones syntax and the ease of editing in Swagger, and then we generate the JSON version.

Open the YAML version either in your web browser or in an editor. For this post, I will use the Swagger editor.

The basic structure of an OpenAPI definition defines the semantic version of OpenAPI first in an element openapi, followed by an info element that contains metadata about the API that is being described. The final required element is the paths element, which contains all of the available paths and operations for the API.

In addition to the required elements, there are a number of other top-level elements that can provide additional details about your API or help provide reusable elements for the API definition. I'll go over those a bit more later in the post. For now, here is what the top level of our definition looks like:

openapi: 3.0.0
info: ...
externalDocs: ...
security: ...
servers: ...
paths: ...
components: ...

Info

The required info element contains the title and version of our API, contact details for where you can get assistance for the API, our terms of service, and a textual description of the API.

info:
  title: DNSimple API
  version: 2.0.0
  contact:
    name: DNSimple Support
    email: support@dnsimple.com
    url: 'https://dnsimple.com/contact'
  description: >-
    [DNSimple](https://dnsimple.com) provides DNS hosting and domain
    registration that is simple and friendly. We provide an extensive API and an
    easy-to-use web interface so you can get your domain registered and set up
    with a minimal amount of effort.
  termsOfService: 'https://dnsimple.com/terms'

The info section is pretty straightforward. Only the title and version are required, but including other fields may be helpful to developers working with your API.

Servers

The servers element lists servers that developers working with your API can connect to.

servers:
  - url: 'https://api.dnsimple.com/v2'
    description: DNSimple Production API
    variables: {}
  - url: 'https://api.sandbox.dnsimple.com/v2'
    description: >- 
      DNSimple Sandbox API. For more information on the purpose and use of the Sandbox API, see our [testing documentation](https://developer.dnsimple.com/v2/#testing)

We include both our production API as well as our sandbox API. Since the description element accepts markdown, we have the ability to link to other documentation, as is shown in the sandbox example, which explains how to properly test using the sandbox environment.

You can also use variables in your server URL and define those here, but we currently do not take advantage of this feature.

Security

The security element describes what kind of authentication methods are available to access the API. We provide two types of authentication in API v2: basic authentication with a username and password and token authentication with a bearer token.

security:
  - basicAuth: []
  - bearerAuth: []

Even though I have not gone through the components element yet (I will below) it's important to show how each of the named security methods are defined in components. Here is the relevant bit of the components element:

  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
    bearerAuth:
      type: http
      scheme: bearer

As you can see, each security item has a corresponding security scheme. Both of the scheme types are http, with one using the basic scheme and other the bearer scheme. You can find more on the various security scheme types in the OpenAPI spec.

One benefit of specifying your security methods is that the swagger generated documentation will actually allow consumers to connect to their account and experiment with the API through the documentation.

Paths and Components

Now that I've covered the elements of the definition that are high-level and that apply to the API, it's time to dig into the paths element (and along with that the components element). Paths hold the relative paths to the individual endpoints and their operations. This is where the meat of your definition is and where you will spend quite a bit of time if your API is large.

Let's take a look at the first API path almost anyone using the DNSimple API will touch: the whoami path. The result of a call to this API path returns a JSON document with the User and/or Account details for your account based on how you authenticated.

/whoami:
  get:
    description: >-
      Get details about the current authenticated entity used to access the API.
    parameters: []
    operationId: whoami
    tags:
      - identity
    responses:
      '200':
        description: Successful response with user or account.
        content:
          application/json:
            schema:
              type: object
              properties:
                data:
                  type: object
                  properties:
                    account:
                      $ref: '#/components/schemas/Account'
                    user:
                      $ref: '#/components/schemas/User'
      '401':
        $ref: '#/components/responses/401'
      '429':
        $ref: '#/components/responses/429'
      default:
        $ref: '#/components/responses/Error'

The first line specifies the relative path. Thus the full path is https://api.dnsimple.com/v2/whoami in the case of our production endpoint. Under the path you have the HTTP verbs that are supported for that path. In this case, only GET is supported. Note that get is lowercase. The get element holds details about the operation that is executed when that path is called with the given HTTP method. The operation can contain a bunch of details. We've opted to have a description for each operation, the collection of parameters (which may be empty, as in this case), an operationId which is a unique string used to identify the operation. We use the same string throughout our internal documentation. We also include a tags list to help group our API paths together in logical groups (such as identity or domains).

The only required element in an operation object is the responses element. This is a map of response codes to their expected response. In this example, the response code is 200 which indicates a "successful response with user or account." This response code is mapped to a response object that contains a description, headers, content, and links. In the case of our API, we have only specified a description and the content at the moment. Content maps mime types to media type objects, which can specify the schema (what we use) as well as examples and encoding.

At this point, it's probably best to dive into an example and see how this maps back to the structure of the OpenAPI definition.

Example

If I authenticate with a bearer Account token then I would receive something like the following:

{
   "data":{
      "user":null,
      "account":{
         "id":1,
         "email":"john.doe@example.com",
         "plan_identifier":"dnsimple-personal",
         "created_at":"2018-02-12T15:41:21Z",
         "updated_at":"2018-02-15T22:14:15Z"
      }
   }
}

Let's look again at the successful response object's definition:

'200':
  description: Successful response with user or account.
  content:
    application/json:
      schema:
        type: object
        properties:
          data:
            type: object
            properties:
              account:
                $ref: '#/components/schemas/Account'
              user:
                $ref: '#/components/schemas/User'

Given a 200 response from a call to /whoami using a GET verb, the content type is application/json, and the response is a JSON object. It has the properties data which in turn references either an account object and/or a user object. The User and Account object types are references to their definitions in the components element. By defining definitions of the various objects that are returned from our service, we can greatly reduce the amount of work for maintaining the definition and reduce the likelihood of making silly mistakes.

We also have documented many of the possible error responses.

'401':
  $ref: '#/components/responses/401'
'429':
  $ref: '#/components/responses/429'
default:
  $ref: '#/components/responses/Error'

Again, components help us by letting us only define each error definition once, in the components section.

components:
  securitySchemas: ...
  links: {}
  callbacks: ...
  parameters: ...
  schemas: ...
  responses: ...
  requestBodies: ...
  headers: ...

I've already gone through what's in securitySchemas, so I won't cover that again. Our links object is currently empty, however we may augment our definition by including this in the future. Let's have a look at the schemas, as we referenced these in the example above.

Schemas

Here is the Account schema:

Account:
  type: object
  properties:
    id:
      type: integer
    email:
      type: string
    plan_identifier:
      type: string
    created_at:
      $ref: '#/components/schemas/DatetimeISO8601'
    updated_at:
      $ref: '#/components/schemas/DatetimeISO8601'
  example:
    id: 1
    email: example-account@example.com
    plan_identifier: dnsimple-professional
    created_at: '2015-09-18T23:04:37Z'
    updated_at: '2016-06-09T20:03:39Z'

It specifies the type of the Account, which is an object, as well as its properties and an example. The properties is a map of property names to their types or references to other schemas. The created_at and updated_at properties refer to a schema called DatetimeISO8601 #/components/schemas/DatetimeISO8601. This type is currently defined as a string several lines before the Account type, but in the future we could change it to be something that better represents dates if the API is updated to reflect the new type. This is one of the benefits of using custom-defined types rather than just saying everything is a primitive type. It also makes it clear what it is semantically (an ISO 8601 date, not just a string). The example element provides example data. Note that the example overrides the schema definitions, so make sure that its keys match the keys in the schema.

Another interesting schema is the WebhookPayload schema. One of the challenges with webhooks is enumerating all of the different webhooks that may be sent. OpenAPI v3 makes it easy to do this by providing the enum attribute. The WebhookPayload name property uses the enum element to list all of the possible webhook names that may be sent. The data element uses the oneOf selector to indicate that the data schema in the webhook will be one of the various defined types. Again, by being able to enumerate all of the possible types it makes it much easier to show consumers what data may arrive.

Parameters, Request Bodies, and Responses

In addition to the schema element, we also use the parameters, request bodies, and responses elements in the components section. Defining these components in one location simplifies reuse and ensures consistency throughout our definition document. Parameters are used to define what data is passed when calling the API. Request bodies define the HTTP bodies that are expected for certain API calls. Finally, responses defines the structure of the response objects.

Conclusion

If you look through the OpenAPI 3 specification, and consider the complexity of our definition, you can see that there is a lot that can be defined. Your needs should dictate how much or how little of the OpenAPI specification you are interested in using. Regardless of how much you choose to implement, OpenAPI provides an excellent, comprehensive framework for defining your APIs.