How to write JSON Schemas for your API Part 1: validating data

jcmosc

James Moschou

Posted on March 29, 2023

How to write JSON Schemas for your API Part 1: validating data

All APIs should treat data coming from the client as untrusted until it can be validated.

By validating incoming data, you can ensure that it is safe, and can be interpreted and processed correctly by your server. Your validation rules, therefore, need to be communicated to the client via documentation so that they know how to format their data properly.

A common challenge many API teams face is keeping their documentation up to date as their API evolves. By using JSON schema as both the validation engine and the communication tool, the two always stay in sync since there is only one source of truth.

This series of articles will explain what each JSON Schema keyword means with realistic examples so that you can communicate as much information to your customers as possible.

Background on JSON

JSON is typically used to describe data as a collection of labelled fields or key-value pairs.

This form

Form Data

May be represented as an object in JSON like this:

{
  "api_name": "Example API",
  "api_version": "1.0.0",
  "description": "An example API to demonstrate JSON Schema."
}
Enter fullscreen mode Exit fullscreen mode

JSON can also describe a sequence of unlabelled values called an array.

[
  {
    "message": "This is an object value in an arary."
  },
  "This is a plain string value in an array."
]
Enter fullscreen mode Exit fullscreen mode

JSON also refers to the data type of individual fields, that is strings (text), numbers (both whole numbers and fractional), boolean (yes/no) and empty values (null and undefined).

Objects and arrays are the compositional features of JSON. Objects can contain arrays and vice versa. This allows complex data structures to be composed of smaller structures.

General purpose validation keywords

type

JSON Schema answers the question: what type of JSON data do we expect?

We know that JSON data can be an object, array, string, number, boolean or empty value. This brings us to our first and most important validation keyword: type.

In most cases type literally says the type that we expect: "array", "boolean", "integer", "null", "number", "object", "string".

In practice, most JSON schemas that are used to validate requests and responses will specify that the payload must be an object. This is good practice from an API design point of view because it allows for the API to evolve incrementally by adding new fields without breaking existing clients.

This JSON Schema says that we always expect the JSON value to be an object.

{
  "type": "object"
}
Enter fullscreen mode Exit fullscreen mode

If the data is allowed to take more than one form we can also specify more than one type in an array.

A use case for this might be to describe a configuration option. For example, the user could specify the name of each setting individually or specify a preset via its name:

POST /print_jobs

{
  "printer_settings": "black_and_white"
}
Enter fullscreen mode Exit fullscreen mode
POST /print_jobs

{
  "printer_settings": {
    "paper_size": "A4",
    "color": true,
    "double_sided": true
  }
}
Enter fullscreen mode Exit fullscreen mode

The JSON Schema for the value of printer_settings would look like this:

{
  "type": ["string", "object"]
}
Enter fullscreen mode Exit fullscreen mode

enum

The printer example exposes a potential issue in the printer settings schema. What happens if the user specifies a paper size that doesn't exist?

We can describe all the possible values upfront using the enum keyword.

{
  "type": "string",
  "enum": ["A3", "A4", "A5", "US Legal", "US Letter"]
}
Enter fullscreen mode Exit fullscreen mode

Usually enum is used with string values, which translates well to many programming languages. However, JSON Schema is more flexible and the enum keyword can be used with any type of value.

const

Occasionally your API may have fields that, when specified, always have the same value no matter what. This can be described using const.

{
  "type": "string",
  "const": "must be this value"
}
Enter fullscreen mode Exit fullscreen mode

Using const is equivalent to using enum with only one value.

Why would you have this? A field where you already know the value doesn't convey any new salient information.

Constant values can be useful in requests where you want to the user to be explicit about the effect of the API call. They also allow you to expand the use cases of the field into an enum in the future.

For example, a POST /shareable_links API will make a resource available to anyone on the Internet. Calling this API would have drastic consequences if the the user never meant for everyone to be able to see the resource. Making the user explicit about the effect can reduce confusion and misuse of APIs.

POST /shareable_links

{
  "visibility": "public"
}
Enter fullscreen mode Exit fullscreen mode

Later on, a "private" visibility option could be added to the API request in a backwards compatible manner.

Validating textual values

minLength and maxLength

If your API expects textual data, you can specify the minimum and maximum number of characters that are allowed.

Some APIs may restrict text to a maximum length based on what the underlying storage layer has the capacity for.

An example of a minimum length might be a phone number with a full area code where there is always a certain number of digits. If there are not enough digits, you know that the supplied value is not a valid phone number.

pattern

The phone number field example highlights another way we can check the integrity of the data. Phone numbers only contain digits and perhaps spaces or punctuation marks. We can forbid unallowed characters by using pattern, which is a regular expression.

Regular expressions are too big a topic to go into here. The following schema specifies that the string value must be a series of zero or more characters, where each character is a digit, space, parenthesis or dash.

{
  "pattern": "^[\\d ()-]*$"
}
Enter fullscreen mode Exit fullscreen mode

Regular expressions can also go beyond describing what characters are allowed, and also specify what order they must appear in.

This example describes a US phone number formatted as (XXX) XXX-XXXX.

{
  "pattern": "^\\(\\d{3}\\) \\d{3}-\\d{4}$"
}
Enter fullscreen mode Exit fullscreen mode

format

There are common use cases where the text value actually has a specific, well-known meaning such as an email address. We can use the format keyword to describe this.

{
  "type": "string",
  "format": "email"
}
Enter fullscreen mode Exit fullscreen mode

This says that the text value must be an email address, not just any sequence of characters.

Validating numeric values

minimum, maximum, exclusiveMinimum and exclusiveMaximum

If a field can be set to a number, we can define the range that we expect the number to be within.

A very common use case is to disallow negative numbers:

{
  "type": "number",
  "minimum": 0
}
Enter fullscreen mode Exit fullscreen mode

The keywords minimum, maximum, exclusiveMinimum and exclusiveMaximum corresponds to the mathematical inequalities >=, <=, > and < respectively. You can use these keywords in any combination, though typically you would either use the exclusive or non-exclusive one as needed.

multipleOf

You can specify that the number must be a multiple of something using multipleOf.

Say your Calendar API accepts a meeting duration in minutes, but only in 15-minute increments, you can use multipleOf to disallow 20-minute meetings. This means your API can avoid having an awkward number_of_quarter_hours field and instead have an easier-to-use minutes field that still respects the constraints of your application.

{
  "type": "integer",
  "multipleOf": 30
}
Enter fullscreen mode Exit fullscreen mode

Validating arrays

items

When a field value is an array, you can specify the data structure of each item in the array using items.

Say your API allows adding multiple tags to an element, you can validate that each tag is a non-empty string.

{
  "type": "array",
  "items": {
    "type": "string",
    "minLength": 1
  }
}
Enter fullscreen mode Exit fullscreen mode

uniqueItems

Continuing with the tags example, it doesn't make sense for an element to have two tags that are identical. We can use uniqueItems to disallow duplicate items in an array.

{
  "type": "array",
  "items": {
    "type": "string",
    "minLength": 1
  },
  "uniqueItems": true
}
Enter fullscreen mode Exit fullscreen mode

minItems and maxItems

Just as we can limit the length of strings and numbers to a certain range, we can limit the number of items in an array to a certain range too.

Say in a survey builder we want to allow the creation of a new survey of questions, but each survey must have at least one question. We can disallow empty arrays by setting minItems to 1.

{
  "type": "array",
  "minItems": 1
}
Enter fullscreen mode Exit fullscreen mode

Similarly, we can set an upper limit on the number of questions using maxItems.

prefixItems

Say we want to introduce an additional constraint on new surveys, where the first few questions are always the same, but the rest of the questions can vary.

We can use prefixItems to specify a sequence of schemas that the first items in an array must satisfy.

This example requires that all surveys ask for name and address up front.

{
  "type": "array",
  "prefixItems": [
    {
      "type": "object",
      "properties": {
        "question": {
          "type": "string",
          "const": "What is your name?"
        }
      }
    },
    {
      "type": "object",
      "properties": {
        "question": {
          "type": "string",
          "const": "What is your address?"
        }
      }
    }
  ],
  "items": {
    "type": "object",
    "properties": {
      "question": {
        "type": "string"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

contains

We can use the contains keyword to specify that at least one item in the array must satisfy an additional constraint.

For example, we can specify that a team that consists of team members must contain at least one member who is an admin.

{
  "type": "array",
  "items": {
    "type": "object",
    "properties": {
      "name": {
        "type": "string"
      },
      "isAdmin": {
        "type": "boolean"
      }
    }
  },
  "contains": {
    "type": "object",
    "properties": {
      "isAdmin": {
        "type": "boolean",
        "const": true
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

maxContains and minContains

We can also specify the number of times the contains keyword would match using maxContains and minContains.

For example, if a team must have exactly one admin user, we can specify this.

{
  "type": "array",
  "items": {
    "type": "object",
    "properties": {
      "name": {
        "type": "string"
      },
      "isAdmin": {
        "type": "boolean"
      }
    }
  },
  "contains": {
    "type": "object",
    "properties": {
      "isAdmin": {
        "type": "boolean",
        "const": true
      }
    }
  },
  "minContains": 1,
  "maxContains": 1
}
Enter fullscreen mode Exit fullscreen mode

unevaluatedItems

The unevaluatedItems keyword of JSON Schema is used to define a schema that applies to items in an array that do not match any of the previous schema definitions.

A very simple use-case of this keyword is to disallow any unexpected values.

This schema describes an array with exactly two string values and nothing else.

{
  "type": "array",
  "prefixItems": [{ "type": "string" }, { "type": "string" }],
  "unevaluatedItems": false
}
Enter fullscreen mode Exit fullscreen mode

You typically wouldn't use items and unevaluatedItems together, since items will evaluate every value in the array.

Validating objects

A JSON object is made up of pairs of keys and values. Each pair is called a property, which can be separately validated using nested schemas.

properties

Validate each value in an object using properties.

This example describes a calendar event structure with a name, start time and end time:

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
    },
    "start_time": {
      "type": "string",
      "format": "date"
    },
    "end_time": {
      "type": "string",
      "format": "date"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

required

By default, all properties in an object are treated as optional. To make some properties mandatory, use required.

Let's update the calendar event structure to add an optional location property, while making the other properties mandatory.

{
  "type": "object",
  "required": ["name", "start_time", "end_time"],
  "properties": {
    "name": {
      "type": "string"
    },
    "start_time": {
      "type": "string",
      "format": "date"
    },
    "end_time": {
      "type": "string",
      "format": "date"
    },
    "location": {
      "type": "string"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

dependentRequired

Whether a property is required or not is not cannot always be decided upfront. The dependentRequired keywords allows you to specify scenarios where a propery becomes mandatory if another optional property is also included.

For example, you may have a Food Delivery API that allows specifying a delivery address when making a food delivery order. If an address is not specified it is a pick-up order. If the delivery address is specified, then an additional property specifying whether the courier has the authority to leave the delivery in a safe place must also be specified.

{
  "type": "object",
  "dependentRequired": {
    "delivery_address": ["authority_to_leave"]
  },
  "properties": {
    "delivery_address": {
      "type": "object"
    },
    "authority_to_leave": {
      "type": "boolean"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

additionalProperties

Sometimes your data structures require more flexibility than what is normally possible by specifying fixed fields upfront.

The additionalProperties keyword allows you to validate the values properties where you don't know the names upfront. This is useful to allow consumers of your API to define their own properties, such as with a custom metadata field.

This example lets clients include custom fields in a metadata object, but only for simple values like strings, numbers and booleans.

{
  "type": "object",
  "properties": {
    "metadata": {
      "type": "object",
      "additionalProperties": {
        "type": ["string", "number", "boolean"]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

patternProperties

While you don't always know the property names upfront, as with the custom metadata field, you can still constrain them in some way.

This is especially useful to group custom properties in a certain "namespace" to maintain forwards compatiblity with future versions.

This example allows additional custom properties on a contact record, but requires them to start with the user_defined_ prefix:

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
    },
    "email": {
      "type": "string",
      "format": "email"
    }
  },
  "patternProperties": {
    "^user_defined_": {}
  }
}
Enter fullscreen mode Exit fullscreen mode

propertyNames

If you just want to validate the names of properties, but allow any values, you can use propertyNames.

A very common use case is to only allow property names made up of certain characters, excluding spaces, punctuation and other non-alphanumeric characters.

{
  "type": "object",
  "propertyNames": {
    "pattern": "^[A-Za-z_][a-za-z0-9_]*$"
  }
}
Enter fullscreen mode Exit fullscreen mode

minProperties and maxProperties

For flexible data structures where you don't know all the properties up front, you can limit the number of custom properties to a certain range using minProperties and maxProperties.

This example describes a color pallete structure that contains between 3 and 5 color objects, keyed by their hex code.

{
  "type": "object",
  "patternProperties": {
    "#[0-9a-f]{6}": {
      "type": "object",
      "properties": {
        "name": "string"
      }
    }
  },
  "minProperties": 3,
  "maxProperties": 5
}
Enter fullscreen mode Exit fullscreen mode

unevaluatedProperties

A JSON Schema is a system of constraints. Everything is allowed by default, unless you explicitly disallow it.

You can specify a minimum set of constraints that all properties must satisfy, regardless of whether they are known or unknown properties, using unevaluatedProperties.

Of course, if you simply want to disallow unknown properties, set unevaluatedProperties to false.

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
    }
  },
  "unevaluatedProperties": false
}
Enter fullscreen mode Exit fullscreen mode

Validating binary data

contentMediaType

Sometimes the data your API works with cannot be expressed by JSON. However, to be consistent with the rest of the API, it may be encoded as a string and wrapped inside an outer JSON structure.

You can use contentMediaType to specify that a string value should be interpreted as different type of data.

For example, you may have a Content Management System that returns a post's content as HTML that can be rendered directly on the client's website.

The JSON schema might look like this:

{
  "type": "object",
  "properties": {
    "post_slug": {
      "type": "string"
    },
    "post_content": {
      "type": "string",
      "contentMediaType": "text/html"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

contentEncoding

With the embedded HTML example, the HTML text could be represented as a string using the same encoding as the enclosing JSON document, which would typically be UTF-8.

For other mime types, e.g. images, the binary data must be converted to a string representation using a different encoding, which can be specified using contentEncoding.

The following schema indicates that a string contains a PNG image, encoded using Base64:

{
  "type": "string",
  "contentEncoding": "base64",
  "contentMediaType": "image/png"
}
Enter fullscreen mode Exit fullscreen mode

contentSchema

There are some scenarios where a data structure can be expressed as a JSON data structure, but is nonetheless transmitted as an encoded string.

An example is JSON Web Tokens, which are used to store authentication and authorization information about a user. Different JWTs can make different claims about the user, so we can require certain claims to be present using contentSchema.

This example describes a JWT that requires the issuer (iss) and expiration time (exp) fields in its claim set.

{
  "type": "string",
  "contentMediaType": "application/jwt",
  "contentSchema": {
    "type": "array",
    "minItems": 2,
    "prefixItems": [
      {
        "const": {
          "typ": "JWT",
          "alg": "HS256"
        }
      },
      {
        "type": "object",
        "required": ["iss", "exp"],
        "properties": {
          "iss": { "type": "string" },
          "exp": { "type": "integer" }
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

We've seen how JSON Schema can be used to specify and communicate validation rules for JSON data, including text, numbers, arrays, objects and even binary data.

Future articles in this series will cover how you can use JSON Schema to include human-readable metadata for documentation purposes, specify dynamic data structures that vary based on certain conditions, and organize schemas into a library of reusable elements.

💖 💪 🙅 🚩
jcmosc
James Moschou

Posted on March 29, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related