Detecting breaking changes in complex schemas

Nicholas Lim 2023-11-1

Breaking changes in APIs can be incredibly frustrating for developers. As API maintainers, we do our best to avoid making breaking changes but sometimes they’re easy to miss. For example, adding a new query parameter to an existing endpoint is fine, but making it required is a breaking change. Optic includes a breaking change ruleset that helps you to catch breaking changes (opens in a new tab) during code reviews.

In general, a breaking change in a request (a header, query parameter, request body) is increasing the minimum set of required data (e.g. adding a new required query parameter) and in a response it is decreasing the minimum set of required data (e.g. removing a required response property). In other words, a request must not be expanded and a response must not be narrowed.

Testing for breaking changes in a polymorphic schema (oneOf or anyOf) is a little more tricky. For example, take the request schema below.

schema:
	oneOf:
    - type: object
      properties:
        id:
          type: string
      required: ['id']
    - type: object
      properties:
        name:
          type: string
      required: ['name']

This request schema has two variants (an object that includes an id and an object that includes a name). A valid change must mean that the following requests should continue to work:

curl http://localhost:3000/endpoint -d '{"id": "123"}'
curl http://localhost:3000/endpoint -d '{"name": "my-name"}'

Merging the oneOf items with required fields would cause these requests to fail.

schema:
	type: object
  properties:
    id:
      type: string
		name:
			type: string
  required: ['id', 'name'] # Both oneOf branches would fail with their minimum set

But making the fields optional would work.

schema:
	type: object
  properties:
    id:
      type: string
		name:
			type: string

We can extend our breaking change rules to take into account polymorphic schemas.

In requests, for every possible item in the before schema, there must be at least one item in the after schema that does not contain breaking changes. In responses, for every possible item in the after schema, there must be at least one item in the before schema that does not contain a breaking change.

The reason we treat requests and responses separately is because adding a new oneOf variant to the after schema would be an acceptable change in a request (regardless of the other oneOf schemas), but would be a breaking change in a response unless there was an overlapping schema in the before oneOf schemas.

For example, if the oneOf schema that is either { id: string } or { name: string } is in a request, the after schema must have a schema that accepts only { id: string } and { name: string }. However in a response, the after schema must have a schema that has at minimum both { id: string } and { name: string }

schema:
	oneOf:
    - type: object
      properties:
        id:
          type: string
      required: ['id']
    - type: object
      properties:
        name:
          type: string
      required: ['name']
 
# Valid in a request (we can loosen the schema)
schema:
	type: object
	properties:
		id:
			type: string
		name:
			type: string
 
# Valid in a response
schema:
	type: object
	properties:
		id:
			type: string
		name:
			type: string
	required: ['id', 'name']

In the request example, both branches of the oneOf schema have a valid transition (sending a request with only id or name still work). In the response example, both id and name are returned, which for responses is valid.

In pseudocode, this would be:

const before = [Schema1, Schema2]
const after = [Schema3, Schema4]
 
const isBreakingChangeInRequest = !before.every(
	beforeSchema => after.some(afterSchema =>
		!diff(beforeSchema, afterSchema).isExpanded
	)
)
 
const isBreakingChangeInResponse = !after.every(
	afterSchema => before.some(beforeSchema =>
		!diff(beforeSchema, afterSchema).isNarrowed
	)
)

This is how we’ve implemented breaking change checks in Optic to respect polymorphism and refactors between OpenAPI types. You can see the full implementation here (opens in a new tab).

Try it out

Try it out locally by downloading Optic and running a diff between OpenAPI specs (opens in a new tab) with --check.

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