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 added
GET
operations to include200
responses that have adata
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.
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