Creating an OpenAPI Specification The Hard Way

OpenAPI (formerly Swagger) is an open-source tool to standardize specification of REST APIs. This standardization enables programmatic parsing of an API's structure and syntax to automatically generate documentation, client libraries, and even server code. Think of OpenAPI as the source of truth for your API; from schemas to endpoints to security configurations.

OpenAPI documents are most commonly created as YAML files. You can also use JSON. The resulting file serves as your fundamental text that streamlines production of documentation and client libraries. This saves time and minimizes errors from manually creating reference docs and API wrappers.

There are numerous ways to create an OpenAPI spec. OpenAPI.tools is probably the most comprehensive list of tools to help you. Spotlight and Postman are two of the best UI tools to create your spec.

You can also, of course, do it the hard way. In this guide, we'll take inspiration from Zed Shaw over at Learn Code The Hard Way by writing an OpenAPI YAML file by hand. This is obviously more time-consuming and error prone, but sometimes if you want to understand something, doing it the hard, manual way is the best way. Once you understand it at a deeper level, you become a better conductor of the tools that abstract some of the inner workings.

What We're Building

We need an idea for a simple API before we can get started. Let's imagine a simple, CRUD dictionary API that allows user to look up, create, and modify definitions of words. We'll need the following paths:

  • POST /definitions
  • GET /definitions/{word}
  • PUT /definitions/{word}
  • DELETE /definitions/{word}

The API will use JSON in request and response bodies.

For security, we'll use Basic Authentication for all endpoints except GET /definitions.

Initial Setup

Create a file called definitions.yaml. We'll start by defining the version of OpenAPI we're using (3.0.0) and same basic description info of our API. The info.version field refers to the version of our API.

# definitions.yaml
openapi: 3.0.0
info:
  title: Dictionary REST API
  description: An API to look up definitions of words.
  version: 1.0.0

Next we'll add our API's base URL under servers. You can add multiple servers if you want to support production and development instances. We'll stick to one for now.

We'll also define our security scheme of Basic Auth here as well under components. Doing this defines a security scheme that we can then apply to each endpoint that needs this security scheme.

# definitions.yaml
openapi: 3.0.0
info:
  title: Dictionary REST API
  description: An API to look up definitions of words.
  version: 1.0.0
servers:
  - url: https://sample-api.apimaestro.com
components:
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic

If we wanted to set Basic Auth as our global security scheme and apply it to all endpoints, we could do that under security like the example below. We'll leave this out of our spec to keep GET /definitions unprotected.

components:
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
security:
  - basicAuth: []

Schemas

Data schemas are defined under components. You can set a title, description, and type. This content will appear in generated reference documentation.

The type field can be a string, number, integer, boolean, array, or object. You can also define multiple types for a single field.

We'll create a definition schema of type object.

# definitions.yaml
openapi: 3.0.0
...

components:
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
  schemas:
    Definition:
    	title: Definition
        description: A definition is a statement that explains the meaning of a word or phrase.
        type: object

The object's schema will be referenced when we're setting request and response bodies for our endpoints. We can define the fields that belong to our object under properties. In the example below, I'll define the following:

  • id as a read-only integer
  • word as a string
  • created_at and updated_at as read-only strings in the date-time format
  • meanings as an array of objects

The meanings array will allow us to handle words with multiple definitions. We could split out meanings as its own schema model, but for now, we can define its schema inline with its parent object.

Our meanings object will have the following properties:

  • id as a read-only integer
  • part_of_speech as a string
  • meaning as a string
# definitions.yaml
openapi: 3.0.0
...

components:
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
  schemas:
    Definition:
      title: Definition
      description: A definition is a statement that explains the meaning of a word or phrase.
      type: object
      properties:
        id:
          type: integer
          example: 40125
          readOnly: true
        word:
          type: string
          example: tagliatelle
        created_at:
          type: string
          format: date-time
          example: 2022-12-06T04:55:36.887Z
          readOnly: true
        updated_at:
          type: string
          format: date-time
          example: 2022-12-06T04:55:36.887Z
          readOnly: true
        meanings:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 333
                readOnly: true
              part_of_speech:
                type: string
                example: noun
              meaning:
                type: string
                example: A type of pasta shaped into long, thin, flat strips

All set here. Now we can start writing our endpoints.

Endpoints

All of our endpoints will go under the paths section. For each path, we can set multiple methods. Our simple CRUD API has only 2 paths; /definitions for POST and /definitions{word} for GET, PUT, and DELETE.

# definitions.yaml
openapi: 3.0.0
...

paths:
  /definitions/{word}:
    get:
    put:
    delete:
  /definitions:
    post:

Get a Definition

First, we'll add our word look up endpoint.

Similar to schemas, you can add summary and description content here that will appear in documentation.

I also recommend setting an operationId. This is used to set the method name in auto-generated client libraries. We'll set operationId: get_definition. We can also use camel case operationId: getDefinition.

OpenAPI is smart enough to accept either snake or camel case and convert appropriately. In Python or Ruby, the method would remain in snake case, api.get_definition(word). In camel case languages like Javascript, the method would be api.getDefinition(word).

# definitions.yaml
openapi: 3.0.0
...

paths:
  /definitions/{word}:
    get:
      summary: Get a word's definition.
      operationId: get_definition
      description: > # We can write a multi-line description using the `<` character.
        The GET /definitions endpoint accepts a word and returns its meaning.
        The endpoint returns multiple meanings if available.

Our GET /definitions endpoint will accept one path parameter. This will be the word we're looking up. We define this under parameters.

# definitions.yaml
openapi: 3.0.0
...

paths:
  /definitions/{word}:
    get:
      summary: Get a word's definition.
      operationId: get_definition
      description: > # We can write a multi-line description using the `<` character.
        The GET /definitions endpoint accepts a word and returns its meaning.
        The endpoint returns multiple meanings if available.
      parameters:
        - name: word
          in: path
          description: Word to look up
          schema:
            type: string
          required: true
          example: tortellini

Next, we define how this endpoint responds under responses. We can define multiple responses depending on the response code.

We need to define the response description and content. We set the schema of the response body by referencing the definition schema we created previously.

paths:
  /definitions/{word}:
    get:
      summary: Get a word's definition.
      operationId: get_definition
      description: > # We can write a multi-line description using the `<` character.
        The GET /definitions endpoint accepts a word and returns its meaning.
        The endpoint returns multiple meanings if available.
      parameters:
        - name: word
          in: path
          description: Word to look up
          schema:
            type: string
          required: true
          example: tortellini
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Definition'

Let's also add a 404 Not Found response.

paths:
  /definitions/{word}:
    get:
      summary: Gets a definition of a word.
      operationId: get_definition
      description: > # We can write a multi-line description using the `<` character.
        The GET /definitions endpoint accepts a word and returns its meaning.
        The endpoint returns multiple meanings if available.
      parameters:
        - name: word
          in: path
          description: Word to look up
          schema:
            type: string
          required: true
          example: tortellini
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Definition'
        '404':
          description: No definition found for the specified word.

Create a Definition

The structure for creating a POST request doesn't require much more than our GET request. We start again with the basic description information.

  /definitions:
    post:
      summary: Create a word's definition
      operationId: create_definition
      description: >
        POST /definitions creates a definition of a word.
        Requests to create a duplicate definitions for a word will be rejected with a 409 response code.
        Requests must be authorized with Basic Auth.

Remember we want to protect this endpoint with Basic Auth to prevent anyone from creating definitions. We can do that by adding a security specification set to basicAuth.

  /definitions:
    post:
      summary: Create a word's definition
      operationId: create_definition
      description: >
        POST /definitions creates a definition of a word.
        Requests to create a duplicate definitions for a word will be rejected with a 409 response code.
        Requests must be authorized with Basic Auth.
      security:
        - basicAuth: []

This time we don't need to set any parameters. Instead, we'll accept a request body. Similar to the response body we set in the GET request, we can reference our schema definition in the same way to set the request body.

  /definitions:
    post:
      summary: Create a word's definition
      operationId: create_definition
      description: >
        POST /definitions creates a definition of a word. 
        Requests to create a duplicate definitions for a word will be rejected with a 409 response code. 
        Requests must be authorized with Basic Auth.
      security:
        - basicAuth: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Definition'
        required: true
      responses:

Finally, we'll add a few response codes and specify that successful creations respond with the full definition.

  /definitions:
    post:
      summary: Create a word's definition
      operationId: create_definition
      description: >
        POST /definitions creates a definition of a word.
        Requests to create a duplicate definitions for a word will be rejected with a 409 response code.
        Requests must be authorized with Basic Auth.
      security:
        - basicAuth: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Definition'
        required: true
      responses:
        '201':
          description: Word created successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Definition'
        '400':
          description: Invalid definition data provided.
        '401':
          description: Unauthorized request.
        '409':
          description: Word already has a definition.
        '422':
          description: Unprocessable entity.

Update a Definition

Things get easy here because we don't need anything new. We can re-use what we set in the GET and POST requests to create our PUT request. We'll copy the POST request and then adjust the following:

  1. Change the method.
  2. Update labels and descriptions.
  3. Copy the parameter specs from the GET request.
  4. Update response codes.

Here's what the full PUT request should look like.

  /definitions/{word}:
    put:
      summary: Update a word's definition
      operationId: update_definition
      description: >
        PUT /definitions updates a definition of a word.
        Requests must be authorized with Basic Auth.
      security:
        - basicAuth: []
      parameters:
        - name: word
          in: path
          description: Word to look up
          schema:
            type: string
          required: true
          example: tortellini
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Definition'
        required: true
      responses:
        '204':
          description: Word updated successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Definition'
        '400':
          description: Invalid definition data provided.
        '401':
          description: Unauthorized request.
        '404':
          description: Word not found.
        '422':
          description: Unprocessable entity.

Delete a Definition

Setting the DELETE request is even more trivial than the PUT request. In this case, we copy the GET request and update the following:

  1. Change the method.
  2. Update labels and descriptions.
  3. Add Basic Auth.
  4. Update response codes.

Here's what we're left with.

  /definitions/{word}:
    delete:
      summary: Delete a word's definition
      operationId: delete_definition
      description: >
        DELETE /definitions/{word} deletes a definition of a word.
        Requests must be authorized with Basic Auth.
      security:
        - basicAuth: []
      parameters:
        - name: word
          in: path
          description: Word to look up
          schema:
            type: string
          required: true
          example: tortellini
      responses:
        '204'
          description: Word deleted successfully.
        '401':
          description: Unauthorized request.
        '404':
          description: Word not found.

Conclusion

Congratulations. You've created an OpenAPI document the hard way. Of course, you're not going to want to do this a regular exercise. Now that you have a foundation of how OpenAPI works, you're better prepared to use tools like Stoplight to simplify generating an OpenAPI file while also benefitting from their linter.

Finally, here's the full spec we created in a single code block:

openapi: 3.0.0
info:
  title: Dictionary REST API
  description: An API to look up definitions of words.
  version: 1.0.0
servers:
  - url: 'https://sample-api.apimaestro.com'
components:
  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
  schemas:
    Definition:
      title: Definition
      description: A definition is a statement that explains the meaning of a word or phrase.
      type: object
      properties:
        id:
          type: integer
          example: 40125
          readOnly: true
        word:
          type: string
          example: tagliatelle
        created_at:
          type: string
          format: date-time
          example: '2022-12-06T04:55:36.887Z'
          readOnly: true
        updated_at:
          type: string
          format: date-time
          example: '2022-12-06T04:55:36.887Z'
          readOnly: true
        meanings:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 333
                readOnly: true
              part_of_speech:
                type: string
                example: noun
              meaning:
                type: string
                example: 'A type of pasta shaped into long, thin, flat strips'
paths:
  /definitions/{word}:
    get:
      summary: Get a word's definition.
      operationId: get_definition
      description: |
        GET /definitions/{word} accepts a word and returns its meaning. The endpoint returns multiple meanings if available.
      parameters:
        - name: word
          in: path
          description: Word to look up.
          schema:
            type: string
          required: true
          example: tortellini
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Definition'
        '404':
          description: No definition found for the specified word.
    put:
      summary: Update a word's definition
      operationId: update_definition
      description: |
        PUT /definitions updates a definition of a word. Requests must be authorized with Basic Auth.
      security:
        - basicAuth: []
      parameters:
        - name: word
          in: path
          description: Word to look up
          schema:
            type: string
          required: true
          example: tortellini
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Definition'
        required: true
      responses:
        '204':
          description: Word updated successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Definition'
        '400':
          description: Invalid definition data provided.
        '401':
          description: Unauthorized request.
        '404':
          description: Word not found.
        '422':
          description: Unprocessable entity.
    delete:
      summary: Delete a word's definition
      operationId: delete_definition
      description: |
        DELETE /definitions/{word} deletes a definition of a word. Requests must be authorized with Basic Auth.
      security:
        - basicAuth: []
      parameters:
        - name: word
          in: path
          description: Word to look up
          schema:
            type: string
          required: true
          example: tortellini
      responses:
        '204':
          description: Word deleted successfully.
        '401':
          description: Unauthorized request.
        '404':
          description: Word not found.
  /definitions:
    post:
      summary: Create a word's definition
      operationId: create_definition
      description: >
        POST /definitions creates a definition of a word. 
        Requests to create a duplicate definitions for a word will be rejected with a 409 response code. 
        Requests must be authorized with Basic Auth.
      security:
        - basicAuth: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Definition'
        required: true
      responses:
        '201':
          description: Word created successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Definition'
        '400':
          description: Invalid definition data provided.
        '401':
          description: Unauthorized request.
        '409':
          description: Word already has a definition.
        '422':
          description: Unprocessable entity.
    parameters: []
You've successfully subscribed to Alex Barron
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.