A schema is only useful if it decides correctly in both directions: it has to accept every legitimate document, including ones nobody has shown you yet, and refuse the ones the rules forbid. Copying the shape of one example gets the first half wrong, because an example proves what exists and not what is allowed. This guide goes through the keywords that decide those two outcomes in JSON Schema 2020-12, then through what changes when the schema is placed inside an OpenAPI 3.1 document (the migration notes), and finishes with a test built to fail.

Start with the rules, then the examples

Before writing a keyword, point to the sentence in the requirements that calls for it. A key present in every sample is probably required, while a key absent from even one sample is optional, and a lone sample proves nothing about requirement. Two observed values of a field do not make a closed set, and numbers that happen to be whole do not make an integer, since a price that shows 10 today may show 10.50 tomorrow. The specification is plain about what required means: an object is valid when every listed name is a property of the instance, and omitting the keyword is the same as an empty list (JSON Schema validation). Nothing in it infers a requirement from what was seen.

The keywords that decide acceptance

type names the kind of value, and an array of names allows several. A field that may hold null is ["string", "null"]. The core document notes that the data model makes no distinction between integers and other numbers, so integer is a constraint a validator applies to the value, not a separate JSON type (JSON Schema core).

enum and const close a set. enum accepts an instance that equals one of its elements, and elements can be of any type including null; const is the same with a single value (JSON Schema validation). Reach for them when the requirements close the set, never because a few values turned up in samples.

additionalProperties applies only to the names that properties and patternProperties do not cover in the same schema object, and omitting it behaves like an empty schema, meaning anything extra passes (JSON Schema validation). Switch it to false only if unknown fields are explicitly forbidden. Real APIs add fields over time, and a closed schema turns that into a rejection.

unevaluatedProperties exists for the case where additionalProperties cannot see far enough. The first one looks only at its siblings; the second also uses the annotations produced by adjacent in-place applicators such as allOf, so a closed object assembled from several parts can be closed in one place (JSON Schema core). If an allOf of two shapes with additionalProperties: false in each rejects everything, that is the reason.

format is where schemas most often promise more than they deliver. In 2020-12 the keyword is collected as an annotation, and implementations may also treat it as an assertion, but they must provide switches for that and it must be off by default (JSON Schema validation). So when a malformed date must be refused, add a pattern too, or configure the validator to assert formats.

Tuples changed. A fixed-position array is now written with prefixItems, which takes a list holding one schema for each slot, and items applies to whatever comes after that prefix. The keyword does not limit the length by itself, so items: false plus minItems makes a pair mean exactly two (JSON Schema core). The release notes explain the reason: the old items was used both for arrays and for tuples, and people defined a tuple when they meant an array and saw only the first element checked (release notes). In 2019-09 and earlier, an items array did the work that prefixItems does now.

{
  "type": "object",
  "required": ["id", "status"],
  "properties": {
    "id": { "type": "integer" },
    "status": { "enum": ["open", "closed"] },
    "note": { "type": ["string", "null"] },
    "point": {
      "type": "array",
      "prefixItems": [{ "type": "number" }, { "type": "number" }],
      "items": false,
      "minItems": 2
    }
  },
  "additionalProperties": false
}

What OpenAPI 3.1 changes

OpenAPI 3.0 used its own cut of JSON Schema, a subset with additions. In 3.1 the specification calls the Schema Object a superset of JSON Schema draft 2020-12 and says unspecified behaviour follows JSON Schema (OpenAPI 3.1.0). The migration notes from the OpenAPI Initiative list the practical effects (migration guide):

  • nullable was removed instead of deprecated. A 3.0 file said type: string plus nullable: true; the 3.1 form is a type array holding string and null, and a branch of anyOf or oneOf whose type is null also works (migration notes). A tool that follows 3.1 skips a leftover nullable, so null values get refused.
  • exclusiveMinimum and exclusiveMaximum are numbers now, not booleans that modify minimum.
  • A single example inside a schema gives way to the JSON Schema keyword examples, a list. Media types and parameters keep their own example and examples fields.
  • File uploads are described with contentEncoding and contentMediaType, not with format: binary.
  • $schema is allowed, so a schema file can declare another dialect.

Changing only the version number of a 3.0 file does not give a faithful 3.1 file: it still loads, yet null handling, exclusive limits and examples now mean something else (migration notes).

Parameters, request bodies and responses

The operation around the schema has rules of its own. Each parameter has a name and a location called in, with four possible values (query, header, path, cookie). For a path parameter the required flag must be present and true; for the others it defaults to false (OpenAPI 3.1.0). The reason is that a missing segment would change the URL. The example field of a parameter is mutually exclusive with examples, and the specification says a parameter definition named Accept, Content-Type or Authorization in a header is ignored, since security schemes and media types cover those.

A request body has a required content map keyed by media type, with required defaulting to false, so a body that must be sent needs the flag. Responses are keyed by status code, and the key must be a quoted string for compatibility between JSON and YAML; ranges such as 2XX are allowed, and an explicit code beats a range (OpenAPI 3.1.0). The specification expects the documentation to cover a successful response and known errors, not every possible code. Adding a 404 that nobody showed would assert something about the server that nobody observed.

A type also follows the rules and not the transport: a path value travels as text in a URL, but an identifier the rules call a whole number is an integer in the schema. For a query list, repeated names (ids=1&ids=2) are the default form style with explode true, while one comma-joined value needs explode: false. Both follow from reading the example literally rather than assuming another style.

A test built to fail

Validate a set of documents against the schema and make the check able to fail. Collect the real examples, which must pass, and add documents that each violate exactly one rule: an enum value outside the set, an extra field, a wrong tuple length, a missing required key, a malformed date. With a JavaScript validator (Ajv, using its 2020-12 build) a small run on the schema above accepted both good documents and rejected the unknown status, the extra field, the three-element point and the missing id. A created field with "not a date" and format: "date-time" also passed, because the validator ignored the format without its formats add-on. That last result is the useful one: the check could fail, and it showed that the schema did not enforce what it appeared to.

Repeat the idea for an OpenAPI document: use a validator that understands the 3.1 format, then check the examples embedded in it, because nothing validates those on its own (OpenAPI 3.1.0). For the schema, keep the deliberately bad file in the test suite; a suite that only holds good documents passes for any schema, including {}.

Before tightening a schema that clients already use, read which edits count as breaking in an OpenAPI spec, since a stricter rule on a response is not the same as a stricter rule on a request.

When this does not apply

The statements here come from the 2020-12 specification and the OpenAPI 3.1.0 text as published (OpenAPI 3.1.0). Older drafts behave differently in the places named, and OpenAPI 3.0 files, later 3.1 patch releases and validators with their own options were not compared. The validator run was a single small test, not a survey of tools, and different validators or versions may report differently, especially for formats. Schemas generated from examples can be made stricter than the rules ask for, and nothing here replaces a conversation about which fields are really mandatory.

Two tools for the schema and the operation

Our json-schema-from-example skill reads sample documents plus requirements written in words and answers with a bare schema in the newest dialect covered above. It marks a key mandatory only when all samples carry it or a requirement says so, closes an object or a value set only on request, writes arrays with fixed positions through prefixItems, and expresses a nullable field as a list of types. If a requirement contradicts a sample, the requirement wins.

For the operation, our openapi-operation-from-example skill takes a request, a response and the rules and returns a single operation object in the 3.1 format (OpenAPI 3.1.0). It marks path parameters as mandatory, writes nullable values as type lists, quotes the status keys, includes only the responses that were actually shown, and keeps an authorization header out of the parameter list. Declared as a parameter, that header would be ignored anyway.

Read this post as Markdown: /blog/json-schema-2020-12-openapi-3-1.md · Atom feed.