Developer friendly API governance

Nicholas Lim 2023-10-12

Maintaining an API with thousands of consumers can be a lot of work, from designing the right schemas, monitoring response times and while keeping backwards compatibility. As developers, we rely on tools to make it easier to build a world-class API such as monitoring tools, linters and automated tests.

Similarly to static linters, API rules can be used to help guide developers in the right direction, such as enforcing a consistent casing schema in response schemas or preventing breaking changes. We’ve seen this help code reviewers focus on providing useful feedback and catching business logic bugs, rather than focusing on nitpicking API details.

Optic Rulesets help developers build and maintain a great API by providing an extremely flexible rule engine that lets you define your own API standards and allow you to write rules that only affect things that changed.

To explore this, we’ll implement a couple of rules that show how you can use Optic to define your company’s API program. The rules we’ll implement are:

  • Prevent adding a required query parameter (this is a breaking change)
  • Require all new response properties to be snake_case
  • Require all newly addedGET operations to include 200 responses that have a data key

Preventing new required query parameters

Adding a new required query parameter is a breaking change that might not be immediately obvious and is easy to miss, making it an ideal rule to automate. Optic’s rule engine works by computing changes between two versions of an OpenAPI spec, and then applying rules to those changes. Here we can write this rule by using an OperationRule and listening to parameter changes.

There are two things we need to check:

  • Whether a required query parameter was added
  • Whether a query parameter was made required (from optional)
const preventNewRequiredQueryParameter = new OperationRule({
  name: "prevent required query parameter",
  rule: (operationAssertions) => {
    // Prevent a new required query parameter from being added
    operationAssertions.queryParameter.added((parameter) => {
      if (parameter.value.required) {
        throw new RuleError({
          message: `cannot add required query parameter ${parameter.value.name} to an existing operation. This is a breaking change.`,
        });
      }
    });
 
    // Prevent an optional query parameter from becoming required
    operationAssertions.queryParameter.changed((before, after) => {
      if (!before.value.required && after.value.required) {
        throw new RuleError({
          message: `cannot make optional query parameter '${after.value.name}' required. This is a breaking change.`,
        });
      }
    });
  },
});

Using this rule now catches both cases where adds a required query parameter:

/api/users:
  get:
    parameters:
+     - in: query
+       name: page
+       required: true
+       schema:
+         type: string
      - in: query
        name: page_size
-       required: false
+		required: true
 
$ optic diff openapi-spec.yml --check --base main
x OpenAPI spec openapi-spec.yml
Operations: 1 operation changed
x  Checks: 17/19 passed
 
x GET /api/users:
  - query parameter page: added
 
    x [prevent required query parameter] cannot make optional query parameter 'page_size' required. This is a breaking change.
    at openapi-spec.yml:17:447
 
    x [prevent required query parameter] cannot add required query parameter page to an existing operation. This is a breaking change.
    at openapi-spec.yml:17:447
 
  - query parameter page_size:
    - /required changed

Require all new response properties to be snake_case

Another rule we might want to automate is ensuring consistent casing across our API, a very common nitpick in code reviews. Most API linters can run against the entire API and ensure it’s consistent. This is great if you either have a new API and have consistency across your APIs, however, I think the majority of us don’t have the luxury of working with a pristine API. If you have an old endpoint that uses camelCase, but have new endpoints in snake_case, you’d be unable to update your old endpoint without either making a breaking change, or creating a new version.

Instead, we can implement this rule in Optic using the change-based rules so that we only apply this rule to any new surface area, and let you change or deprecate old casing when it’s appropriate (or even keep around two versions of request / response properties).

Here, we’ll implement the change-based rules on response properties and ensure they are snake_case:

const snakeCaseRegExp = /^[a-z0-9]+(?:_[a-z0-9]+)*$/;
const requireSnakeCaseResponseProperties = new ResponseBodyRule({
  name: "require snake_case response property",
  rule: (responseAssertions) => {
    responseAssertions.property.added((property) => {
      if (!snakeCaseRegExp.test(property.value.key)) {
        throw new RuleError({
          message: `${property.value.key} is not snake_case`,
        });
      }
    });
  },
});

Adding a new response field that isn’t snake_case will trigger this rule:

/api/my-user:
    get:
      responses:
        '200':
          description: Valid response
          content:
            application/json:
              schema:
                type: object
                description: user object
                properties:
                  id:
                    type: string
                    format: uuid
                    example: d5b640e5-d88c-4c17-9bf0-93597b7a1ce2
                  name:
                    type: string
                    nullable: true
                    example: Joe Optic
                  email:
                    type: string
                    example: optic@example.com
+                  dateOfBirth:
+                    type: string
                required:
                  - id
                  - email
$ optic diff openapi-spec.yml --check --base main
x OpenAPI spec openapi-spec.yml
Operations: 1 operation changed
x  Checks: 17/19 passed
 
x GET /api/my-user:
  - response 200:
    - body application/json:
      - property /schema/properties/dateOfBirth: added
 
        x [require snake_case response property] dateOfBirth is not snake_case
        at openapi-spec.yml:38:1143

Require all new GET operations to include 200 responses with a data key

We can also use Optic rulesets to implement API specifications such as JSONAPI[https://jsonapi.org/ (opens in a new tab)] or any other API standard your organization follows. In this example, we want to write a rule that requires any new GET operation to include a 200 response and the response must include a data key.

Using Optic Rulesets, this would be handled by writing two rules and combining them:

// This rule enforces any new GET operation to include a 200 status code
const requireNewGetOperationsToHave200 = new OperationRule({
  name: "new get operations must have 200 responses",
  // Here we uses the matches block to specify this rule should run
  // on `Operations` with GET methods
  matches: (operation, ruleContext) => operation.method === "get",
  rule: (operationAssertions) => {
    operationAssertions.requirement.hasResponses([{ statusCode: "200" }]);
  },
});
 
const require200ResponseShape = new ResponseBodyRule({
  name: "200 GET response shape",
  // Only matches 200 responses in GET operations
  matches: (response, ruleContext) =>
    ruleContext.operation.method === "get" && response.statusCode === "200",
  // Requires all newly added 200 responses in GET operations to have a schema
	// with a data key (`matches` checks for a partial match)
  rule: (responseAssertions) => {
    responseAssertions.body.added.matches({
      schema: {
        type: "object",
        properties: {
          data: {
            type: "object",
          },
        },
      },
    });
  },
});

Now whenever we add a new GET endpoint, these rules will ensure that we have a 200 response that has a consistent shape. For example, if we try to add a 200 response without a data key, we’ll get the following error:

+/api/status:
+  get:
+    responses:
+      '200':
+				description: 'successful response'
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                message:
+                  type: string
$ optic diff openapi-spec.yml --check --base main
x OpenAPI spec openapi-spec.yml
Operations: 1 operation changed
x  Checks: 18/19 passed
 
x GET /api/status: added
 
  - response 200:
    - body application/json:
      - property :
 
        x [200 GET response shape] Expected a partial match
        Expected Value:
        {
          "schema": {
            "type": "object",
            "properties": {
              "data": {
                "type": "object"
              }
            }
          }
        }
        Received Value:
        {
          "schema": {
            "type": "object",
            "properties": {
              "message": {
                "type": "string"
              }
            }
          }
        }
        at openapi-spec.yml:19:502

Write your own custom rules with Optic

These rules explore how we can implement different API standards and use them to help improve the quality of your API. Once you have these rules, you can configure Optic to run in your CI pipeline, checking API changes against your standards.

alt

Learn more about writing custom rules (opens in a new tab) and setting up Optic in CI (opens in a new tab).

Want to ship a better API?

Optic makes it easy to publish accurate API docs, avoid breaking changes, and improve the design of your APIs.

Try it for free