Skip to content

OpenAPI 3.0 and 3.1 Support

Contractual’s built-in OpenAPI differ supports both OpenAPI 3.0 and 3.1 without any extra configuration. Point type: openapi at a spec file and all CLI commands — diff, breaking, changeset, version — work regardless of which version the spec declares.


Before comparing two specs, Contractual uses @redocly/openapi-core to load and dereference each document. This step:

  • Resolves all $ref pointers, including cross-file references
  • Normalizes multi-file specs into a single in-memory document
  • Handles both 3.0 and 3.1 parsing rules correctly
  • Produces a plain JavaScript object that the structural differ can walk

You do not need to install @redocly/openapi-core yourself. It ships as a dependency of @contractual/differs.openapi.


The differ system is split into three packages that compose together:

@contractual/differs.core
Walker, classifiers, ref-resolver, result assembly.
Shared by all differs.
@contractual/differs.json-schema
Thin wrapper over core. Same public API as before.
Handles standalone JSON Schema files.
@contractual/differs.openapi
Uses Redocly to resolve specs (3.0 and 3.1).
Structural layer: paths, operations, parameters, responses.
Delegates schema-level diffing to the core walker.

You interact with the differ through the Contractual CLI or GitHub Action. You only import the packages directly if you are integrating programmatically.


import { diffOpenApi } from '@contractual/differs.openapi';
const result = await diffOpenApi('old-spec.yaml', 'new-spec.yaml');
console.log(result.suggestedBump); // 'major' | 'minor' | 'patch' | 'none'
console.log(result.summary);
// { breaking: 1, nonBreaking: 0, patch: 0, unknown: 0 }
for (const change of result.changes) {
console.log(`[${change.severity}] ${change.message}`);
}

If you have already resolved the spec objects yourself (for example, from a custom bundler), use diffOpenApiObjects instead to skip the Redocly resolution step:

import { diffOpenApiObjects } from '@contractual/differs.openapi';
const result = diffOpenApiObjects(oldSpecObject, newSpecObject);

The structural layer compares the HTTP API surface — paths, operations, parameters, request bodies, and responses. Schema-level changes within those locations are delegated to the core walker.

ChangeExample
Path removedDELETE /orders/{id} path item removed
Operation removedGET /orders/{id} operation removed from existing path
Parameter removedstatus query parameter removed
Parameter required changedlimit changed from optional to required
Request body addedEndpoint that previously took no body now requires one
Response removed200 response removed from an operation
ChangeExample
Path addedNew /invoices path added
Operation addedDELETE /orders/{id} added to existing path
Parameter addedNew optional page query parameter added
Request body removedEndpoint no longer requires a body
Response addedNew 202 Accepted response added to an operation
Server changedservers[].url updated

Changes inside request body schemas, response schemas, and parameter schemas are classified by the core walker using full JSON Schema semantics:

  • Type narrowing and widening
  • Enum value additions and removals
  • Constraint tightening and loosening
  • Required field additions and removals
  • Property additions and removals
  • Composition keyword changes (anyOf, oneOf, allOf, not)

See Classifications for the complete rule set.


OpenAPI 3.1 aligns with JSON Schema 2020-12. The following 3.1-specific features are handled correctly by the core walker.

OpenAPI 3.0 expresses nullable types with the nullable: true keyword. OpenAPI 3.1 uses a type array instead:

amount:
type: number
nullable: true

The walker normalizes both forms before comparison. A change from type: number, nullable: true (3.0) to type: ["number", "null"] (3.1) with no other difference produces no diff. Removing "null" from the type array is classified as type-narrowed (breaking).

ChangeClassification
"null" added to type arrayNon-breaking (type widened)
"null" removed from type arrayBreaking (type narrowed)
Type changed across the arrayBreaking

Numeric exclusiveMinimum and exclusiveMaximum

Section titled “Numeric exclusiveMinimum and exclusiveMaximum”

OpenAPI 3.0 (following JSON Schema Draft-07) uses exclusiveMinimum: true as a boolean alongside minimum. OpenAPI 3.1 uses a numeric value directly:

price:
type: number
minimum: 0
exclusiveMinimum: true # means: value must be > 0

The walker understands both forms. Tightening the exclusive bound is classified as constraint-tightened (breaking). Loosening it is constraint-loosened (non-breaking).

OpenAPI 3.1 documents can declare a jsonSchemaDialect field at the top level. This is an informational annotation — changes to it are classified as patch.

Full JSON Schema 2020-12 keywords in schemas

Section titled “Full JSON Schema 2020-12 keywords in schemas”

OpenAPI 3.1 schemas may use any JSON Schema 2020-12 keyword. The core walker supports:

KeywordNotes
prefixItemsTuple validation; replaces array-form items from Draft-07
unevaluatedPropertiesChanges flagged as unknown (require review)
unevaluatedItemsChanges flagged as unknown (require review)
dependentRequiredBreaking when added, non-breaking when removed
dependentSchemasChanges flagged as unknown (require review)
minContains / maxContainsConstraint tightening/loosening rules apply
contentEncoding, contentMediaType, contentSchemaPatch
deprecated, readOnly, writeOnlyPatch

No configuration change is needed. Contractual detects the spec version from the document and routes it through the same pipeline:

contractual.yaml
contracts:
- name: orders-api
type: openapi # works for 3.0 and 3.1
path: ./specs/orders.openapi.yaml

A 3.1 spec starts with:

openapi: "3.1.0"
info:
title: Orders API
version: "1.0.0"

A 3.0 spec starts with:

openapi: "3.0.3"
info:
title: Orders API
version: "1.0.0"

Both are handled identically from the Contractual configuration perspective. All CLI commands and the GitHub Action work without modification.


When you migrate a spec from 3.0 to 3.1, you may change nullable: true to type arrays and update exclusiveMinimum/exclusiveMaximum from booleans to numbers. The differ understands both forms, so a semantically equivalent migration produces no breaking changes.

Take a snapshot before the migration so that the differ has a 3.0 baseline to compare against:

Terminal window
# While still on 3.0
contractual diff # should show no changes
# After migrating to 3.1
contractual diff # should still show no changes if semantics are equivalent

If the migration accidentally tightens or removes a constraint, contractual diff will surface it.