TestingAPIs is still being built. If something breaks, email support@testingapis.com.

How schema validation works

Types, required, nullable, additionalProperties, enum and $ref, with a validator small enough to read.

The schemas in an OpenAPI document are written in JSON Schema, a vocabulary for describing what a JSON value must look like. To check a response against the contract, a program walks the response and the schema together and reports every place they disagree. That program is called a validator. This lesson goes through the rules one at a time, then shows a validator small enough to read in a few minutes, the one the rest of the tutorial uses.

The rules, one at a time

The tests in this lesson use a small spec of their own, shaped like the API's, so each rule can be shown on its own. A product must have an id, a title, a price and a rating; it may have a category from a fixed list and an image that can be null; and it may have nothing else. First, a value that follows every rule:

test/validate.test.mjs
test("a value that matches has no problems", () => {
  assert.deepEqual(validate(product, good, spec), []);
});

An empty list of problems means the value matches. Now each rule, broken on purpose.

type: what kind of value

test/validate.test.mjs
test("type: the wrong kind of value", () => {
  assert.deepEqual(validate(product, { ...good, price: "249.99" }, spec), ['$.price: expected number, got string "249.99"']);
  assert.deepEqual(validate(product, { ...good, id: 1.5 }, spec), ["$.id: expected integer, got number 1.5"]);
});

A schema names the kind of value expected: string, number, integer, boolean, array or object. "249.99" in quotes is a string, however much it looks like a number, and a price sent that way breaks every client that does arithmetic with it. integer is stricter than number: 1.5 is a number but not an integer.

required: what must be present

test/validate.test.mjs
test("required: a missing property", () => {
  const noPrice = { ...good };
  delete noPrice.price;
  assert.deepEqual(validate(product, noPrice, spec), ['$: missing required property "price"']);
});

required lists the property names that must appear. Without it, every property is optional, and an empty object {} passes a schema with twenty properties. For a response schema, a short or missing required list is the most common way a contract ends up promising nothing.

nullable: whether null is allowed

test/validate.test.mjs
test("nullable: null only where the schema allows it", () => {
  assert.deepEqual(validate(product, { ...good, image: null }, spec), []);
  assert.deepEqual(validate(product, { ...good, title: null }, spec), ["$.title: is null, but null isn't allowed here"]);
});

Present and null is different from absent. required says the property must be there; nullable says whether its value may be null. A client that reads product.title.length crashes on null, so whether null is allowed matters. OpenAPI 3.0, which this API uses, has its own keyword for it; OpenAPI 3.1 lines up with standard JSON Schema and lists null as a type:

JSON
// OpenAPI 3.0: null is allowed with a separate keyword
"image": { "type": "string", "nullable": true }

// OpenAPI 3.1 and plain JSON Schema: null is one of the types
"image": { "type": ["string", "null"] }

additionalProperties: whether anything else is allowed

test/validate.test.mjs
test("additionalProperties: false means no surprises", () => {
  assert.deepEqual(validate(product, { ...good, discount: 10 }, spec), ['$: unexpected property "discount"']);
});

By default, an object may have properties the schema doesn't mention. additionalProperties: false turns that off, so the schema describes the object exactly. That catches typos and accidental leaks, such as a response that suddenly includes an internal field. Lesson 6 explains why it also needs care when the API grows.

enum: only certain values

test/validate.test.mjs
test("enum: only the listed values", () => {
  assert.deepEqual(validate(product, { ...good, category: "toys" }, spec), ['$.category: "toys" is not one of "electronics", "books"']);
});

$ref and nesting: schemas inside schemas

test/validate.test.mjs
test("$ref and nesting: the path says exactly where the problem is", () => {
  assert.deepEqual(validate(product, { ...good, rating: {} }, spec), ['$.rating: missing required property "rate"']);
});

A property can point at another schema with $ref, and the validator follows it. The path in the message, $.rating, says where the problem is: $ is the whole value, and each step adds a property name.

items: every element of an array

test/validate.test.mjs
test("arrays: every item is checked, and reported by index", () => {
  const list = { type: "array", items: product };
  assert.deepEqual(validate(list, [good, { ...good, id: "2" }], spec), ['$[1].id: expected integer, got string "2"']);
});

For a list, items is the schema every element must match, and a problem is reported with its index: the second product, $[1], has a string ID.

KeywordSays
typeThe kind of value: string, number, integer, boolean, array, object
requiredWhich properties must be present
propertiesThe schema for each named property
additionalPropertiesWhether, or how, other properties are allowed
nullable (OpenAPI 3.0)Whether null is allowed
enumThe only values allowed
formatA finer type, such as int32 or date-time
itemsThe schema for every element of an array
$refUse the schema defined somewhere else

A validator you can read

Every rule above, in one function:

src/validate.mjs
// Checks a value against an OpenAPI 3.0 schema and returns what's wrong, as a list of messages. An empty list means
// the value matches. It covers the keywords this API's schemas use; a full validator, such as Ajv, covers the rest.

import { resolveRef } from "./openapi.mjs";

export function validate(schema, value, spec, at = "$") {
  if (schema.$ref) return validate(resolveRef(spec, schema.$ref), value, spec, at);

  if (value === null) return schema.nullable ? [] : [`${at}: is null, but null isn't allowed here`];
  if (value === undefined) return [`${at}: is missing`];

  if (schema.type && !hasType(value, schema.type)) return [`${at}: expected ${schema.type}, got ${describe(value)}`];

  const errors = [];

  if (schema.enum && !schema.enum.includes(value)) {
    errors.push(`${at}: ${JSON.stringify(value)} is not one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`);
  }

  if (schema.format === "int32" && (value < -(2 ** 31) || value > 2 ** 31 - 1)) {
    errors.push(`${at}: ${value} doesn't fit in a 32-bit integer`);
  }

  if (Array.isArray(value) && schema.items) {
    value.forEach((item, i) => errors.push(...validate(schema.items, item, spec, `${at}[${i}]`)));
  }

  if (schema.type === "object" || schema.properties) {
    for (const name of schema.required ?? []) {
      if (!(name in value)) errors.push(`${at}: missing required property "${name}"`);
    }
    for (const [name, propertyValue] of Object.entries(value)) {
      const property = schema.properties?.[name];
      if (property) errors.push(...validate(property, propertyValue, spec, `${at}.${name}`));
      else if (schema.additionalProperties === false) errors.push(`${at}: unexpected property "${name}"`);
    }
  }

  return errors;
}

function hasType(value, type) {
  switch (type) {
    case "integer":
      return Number.isInteger(value);
    case "number":
      return typeof value === "number";
    case "string":
      return typeof value === "string";
    case "boolean":
      return typeof value === "boolean";
    case "array":
      return Array.isArray(value);
    case "object":
      return typeof value === "object" && !Array.isArray(value);
    default:
      return true;
  }
}

function describe(value) {
  if (Array.isArray(value)) return "array";
  if (typeof value === "number") return Number.isInteger(value) ? `integer ${value}` : `number ${value}`;
  if (typeof value === "string") return `string ${JSON.stringify(value.length > 30 ? `${value.slice(0, 30)}...` : value)}`;
  return typeof value;
}
  • It follows $ref first, so everything after works on the real schema.
  • It handles nulland missing values before anything else, since they can't have a type.
  • A wrong type stops there: once a price is a string, complaining that it's also below some minimum would only add noise.
  • For arrays and objects it calls itself on each element or property, adding to at, which is how messages end up with paths like $[1].id.

It returns all the problems it finds rather than stopping at the first, so one test run shows everything wrong with a response. That's how good validators behave.

How strict should a schema be?

A loose schema, with nothing required, everything nullable and extra properties allowed, passes almost anything, so it protects no one. A strict schema catches more, but has to change whenever the API grows. For response schemas used as a contract, lean strict: list every field the API always sends as required, mark null only where it really happens, and forbid extras. This API's response schemas are written that way, and lesson 5 shows what they catch.

Check your understanding

  1. 1.A schema has properties but no required list. Does an empty object pass?Show answer

    Yes. Without required, every property is optional.

  2. 2.What's the difference between a property being missing and being null?Show answer

    Missing means the key isn't there; required controls that. Null means the key is there with the value null; nullable controls that.

  3. 3.The price arrives as "249.99". Which rule catches it?Show answer

    type: the schema says number, and a value in quotes is a string.

  4. 4.What does additionalProperties: false do?Show answer

    It forbids properties the schema doesn't list, so the schema describes the object exactly.

  5. 5.How does OpenAPI 3.1 say a value can be null?Show answer

    By listing null as one of the types: "type": ["string", "null"].

Exercise

Add support for minimum and maxLength to validate, with a test for each, then add "minimum": 0 to the price in the test spec and check that a negative price is reported. Finally, write a test for a value that breaks three rules at once and check that all three problems are reported.

Practise and go further