Wednesday, December 8, 2021 · 8 min read

Validating OpenAPI and JSON Schema

Starting with the release of OpenAPI 3.1, the dialect of JSON Schema used in OpenAPI documents is configurable. By default, you get the OpenAPI 3.1 Schema dialect, but you can choose to use draft 2020-12 or any other dialect if you choose. This brings up the question of how to validate an OpenAPI 3.1 document if one of its components (JSON Schema) is open-ended. In this article, we'll cover how to configure the default JSON Schema dialect of an OpenAPI 3.1 document and how to validate that document, including JSON Schemas, no matter which dialect(s) you choose to use.

What is a JSON Schema dialect?

Because not everyone is familiar with the term "dialect" in this context, let's take a moment to define it before moving on. A JSON Schema dialect is any unique incarnation of JSON Schema. This includes any official release of JSON Schema such as draft-07 or draft 2020-12, but it also includes custom versions of JSON Schema. OpenAPI has effectively had three dialects of JSON Schema introduced with 2.0, 3.0, and 3.1. JSON Schema Dialects are compatible with the core architecture of JSON Schema but may add keywords, remove keywords, or modify the behavior of keywords.

The OpenAPI 3.1 Schema dialect

By default, schemas in OpenAPI 3.1 are assumed to use OpenAPI 3.1's custom JSON Schema dialect. This dialect includes full support for all draft 2020-12 features plus a few additional keywords and format values.

Validating with the default dialect

There are two schemas available for validating OpenAPI 3.1 documents. https://spec.openapis.org/oas/3.1/schema includes all the constraints for validating the document except for schemas. You aren't expected to validate your OpenAPI documents against this schema by itself. Think of this schema as an abstract schema that is intended to be extended to include schema validation support for the JSON Schema dialect you're using.

That's why there's also https://spec.openapis.org/oas/3.1/schema-base that extends the abstract schema with validation support for the OpenAPI 3.1 Schema dialect. If you're using plain out-of-the-box OpenAPI 3.1, this is the schema you want to validate your document against. If you want to use a different dialect, keep reading to see how to extend the main schema to get validation support for your chosen dialect.

This is made possible by dynamic references which were added in JSON Schema 2020-12. The details of how dynamic references work is out of scope for this article, but we'll cover enough for you to make your own concrete schemas for any dialect you choose to use in your OpenAPI 3.1 documents.

Examples

These examples use @hyperjump/json-schema to validate OpenAPI documents. Beware that dynamic references are a relatively new feature of JSON Schema and many validators don't yet support them, or have limited support, or have bugs.

Without schema validation

1import { validate } from "@hyperjump/json-schema/openapi-3-1";
2
3const validateOpenApi = await validate("https://spec.openapis.org/oas/3.1/schema");
4
5const example = YAML.parse(await readFile("./example.openapi.json"));
6const result = validateOpenApi(example);
7console.log(result);

With OpenAPI Schema dialect schema validation

1import { validate } from "@hyperjump/json-schema/openapi-3-1";
2
3(async function () {
4  const validateOpenApi = await validate("https://spec.openapis.org/oas/3.1/schema-base");
5
6  const example = YAML.parse(await readFile("./example.openapi.json"));
7  const result = validateOpenApi(example);
8  console.log(result);
9}());

How does it work?

To get an idea about how this works, let's take a look at a few selections from of the OpenAPI 3.1 schemas.

This is where the Schema Object is defined. The $dynamicAnchor declares this sub-schema to be something that can be effectively overridden by another schema. If it's not overridden, the default behavior is to validate that the value is an object or a boolean. No other validation is performed on the schema.

1$defs:
2  schema:
3    $dynamicAnchor: meta
4    type:
5      - object
6      - boolean

When something in this schema wants to reference the Schema Object, instead of referencing #/$defs/schema like normal, it uses a dynamic reference to the "meta" dynamic anchor set in the previous selection. Now instead of always resolving to #/$defs/schema, another schema can potentially override where it resolves to.

1$defs:
2  components:
3    type: object
4    properties:
5      schemas:
6        type: object
7        additionalProperties:
8          $dynamicRef: '#meta'

Validating Schema Objects against the default dialect

With those vague building blocks in mind let's derive a schema that "extends" the abstract schema to create a schema that validates Schema Objects using the default dialect meta-schema.

The first step is to include the abstract schema.

1$schema: 'https://json-schema.org/draft/2020-12/schema'
2
3$ref: 'https://spec.openapis.org/oas/3.1/schema/latest'

Then we need to add a $dynamicAnchor that matches the one in the abstract schema to override where dynamic references to "meta" will resolve to. From there we can reference the meta schema for the default dialect.

1$schema: 'https://json-schema.org/draft/2020-12/schema'
2
3$ref: 'https://spec.openapis.org/oas/3.1/schema/latest'
4
5$defs:
6  schema:
7    $dynamicAnchor: meta
8    $ref: 'https://spec.openapis.org/oas/3.1/dialect/base'

That's enough to get the Schema Object validation we were after, but there are a few loose ends we'll want to tie up as well. The jsonSchemaDialect field in the OpenAPI 3.1 document can be used to change the dialect used. Since this schema only supports the default dialect, we want to restrict people from changing that to something else. If they need to change it, they'll need a different schema to validate against. We also don't want people using the $schema keyword to change the dialect of individual schemas.

1$schema: 'https://json-schema.org/draft/2020-12/schema'
2
3$ref: 'https://spec.openapis.org/oas/3.1/schema'
4properties:
5  jsonSchemaDialect:
6    $ref: '#/$defs/dialect'
7
8$defs:
9  dialect:
10    const: 'https://spec.openapis.org/oas/3.1/dialect/base'
11  schema:
12    $dynamicAnchor: meta
13    $ref: 'https://spec.openapis.org/oas/3.1/dialect/base'
14    properties:
15      $schema:
16        $ref: '#/$defs/dialect'

With that, we have exactly what you'll find in the official https://spec.openapis.org/oas/3.1/schema-base schema.

Supporting multiple dialects

With the adoption of JSON Schema 2020-12 came support for the $id and $schema keywords, which together allows us to override the default JSON Schema dialect for a schema. Let's assume we have an OpenAPI 3.1 document where we use JSON Schema 2020-12 by default, but we also have some legacy JSON Schema draft-07 schemas that we want to use as well.

1jsonSchemaDialect: 'https://json-schema.org/draft/2020-12/schema'
2components:
3  schemas:
4    foo:
5      type: object
6      properties:
7        foo:
8          $ref: '#/components/schemas/baz'
9      unevaluatedProperties: false
10    bar:
11      $id: './schemas/bar'
12      $schema: 'http://json-schema.org/draft-07/schema#'
13      type: object
14      properties:
15        bar:
16          $ref: '#/definitions/number'
17      definitions:
18        number:
19          type: number
20    baz:
21      type: string

What's going on here

First, we use the jsonSchemaDialect field to set the default dialect for the document. By setting the default dialect to JSON Schema 2020-12, by default, schema will not understand the keywords added in the OpenAPI 3.1 vocabulary such as discriminator. Only standard JSON Schema 2020-12 keywords will be recognized.

The /components/schemas/foo schema is understood to be interpreted as JSON Schema 2020-12 because that's what we set to be the default.

The /components/schemas/bar schema changes the dialect of that schema to be JSON Schema draft-07. There are a couple of things working together to make this possible. The $schema keyword sets the dialect for the schema, but $schema is only allowed at the root of the document it appears in. That's why we also need to include the $id keyword. The $id keyword effectively makes that schema a separate document with its own identifier and that location as the root. It's an independent document embedded inside the OpenAPI 3.1 document. You can think of it like an iframe in HTML.

A consequence of this is that /components/schemas/bar can't use a local reference like #/components/schemas/foo to reference another schema in /components/schemas because it's now technically in a different document. There are two ways to get around this. One option is to use an external reference to the OpenAPI 3.1 document, such as myapi.openapi.yml#/components/schemas/foo. The other option is to give /components/schemas/foo an $id as well and reference that instead, ./schemas/foo.

Validating

Now that we understand how this works, let's derive the schema to validate an OpenAPI 3.1 document with JSON Schema 2020-12 as the default dialect and JSON Schema draft-07 as an allowed alternative.

1$schema: 'https://json-schema.org/draft/2020-12/schema'
2
3$ref: 'https://spec.openapis.org/oas/3.1/schema'
4properties:
5  jsonSchemaDialect:
6    const: 'https://json-schema.org/draft/2020-12/schema'
7required:
8  - jsonSchemaDialect
9
10$defs:
11  schema:
12    $dynamicAnchor: meta
13    properties:
14      $schema:
15        enum:
16          - 'https://json-schema.org/draft/2020-12/schema'
17          - 'http://json-schema.org/draft-07/schema#'
18    allOf:
19      - if:
20          properties:
21            $schema:
22              const: 'https://json-schema.org/draft/2020-12/schema'
23        then:
24          $ref: 'https://json-schema.org/draft/2020-12/schema'
25      - if:
26          type: object
27          properties:
28            $schema:
29              const: 'http://json-schema.org/draft-07/schema#'
30          required:
31            - $id
32            - $schema
33        then:
34          $ref: 'http://json-schema.org/draft-07/schema'

The first change is that the jsonSchemaDialect field is now required because we are no longer using the default.

Next, we have to update the schema definition to only allow $schema values for the dialects we want to allow.

The first if/then will validate the schema as a JSON Schema 2020-12 schema if there is no $schema keyword used, or $schema is set to JSON Schema 2020-12. Of course it's unnecessary to use $schema in this case, but it is allowed.

The second if/then will validate the schema as a JSON Schema draft-07 schema if there is an $id and a $schema indicating draft-07.

You can extend this pattern for any number of dialects you want to support.

Photo by Gonzalo Facello on Unsplash