You expect input from the outside world in JSON format and want to make sure it has all the properties you expect? Great, say hello to JSON Schema. In this post I’ll go over defining a schema and using it to validate JSON input in a Ruby program using the json_schemer gem.

Modeling the Input

Validating input is rarely done for its own sake, but in order to check that the input fits to some kind of model. This model can specify presence or absence of attributes, allowed values, or integrity constraints across objects.

As example, let’s model events for a calendar. Events should have a title and a date. With this we can look into a calendar and quickly see what happens and when it happens. Further, we can add a longer description, and a time. If the event has a time, it can also have an end time, but the end time is not allowed if the (start) time is not set.

(You could also model this differently, e.g. not require a title, but instead a location, or not use an end time but a duration attribute.)

For example, the objects below both should be valid. The left one with minimal number of attributes, the right one with all attributes:

{
  "title": "Christmas",
  "date": "2019-12-24"
}
{
  "title": "Christmas Dinner",
  "date": "2019-12-24",
  "description": "We meet at my Mom's ↩
            house and enjoy the food.",
  "time": "18:00",
  "end-time": "23:00"
}

On the other hand, the following documents should not be accepted. The left document has two errors, as it lacks the title-attribute, and the date is a not in a date format. The right document’s attributes are fine by themselves, however the end-time is not allowed without a time.

{
  "address": "Carthage",
  "date": "Friday"
}
{
  "title": "Christmas Dinner",
  "date": "2019-12-24",
  "end-time": "23:00"
}

Expressing as JSON Schema

A JSON Schema description is an object that basically looks like this:

{
  "$id": "https://example.com/calendar-event.schema.json",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Calendar Event",
  "type": "object",
  "properties": { ... }
}

The $id-attribute contains a unique identifier of that schema, $schema indicates the variant of the schema that is followed. With title we can provide a readable title for the thing we actually want to model, and type is, well, the type of the thing. Finally, properties contains a description of the attributes of the object.

The properties’ keys are the attribute names of the described object, and the values describe how the attribute values should look like. The title attribute is of type string. Also, we don’t want empty titles, which we force by setting the minimal length to 1. For including documentation, we also add a description; and voila:

  "title": {
    "type": "string",
    "minLength": 1,
    "description": "The title of this event."
  }

For the date, the object should also contain a string. However, we can leverage the format property defined by JSON Schema, which allows only dates in the form of YYYY-MM-DD and not arbitrary strings:

  "date": {
    "type": "string",
    "format": "date",
    "description": "The date of this event."
  }

As the description is optional, we just define a type for it.

  "description": {
    "type": "string",
    "description": "A description of the event"
  }

The time should look like HH:MM. There is also a format for time, but this includes seconds and timezones, which is too much for this example. Another option, is to define a regular expression for that, that the string should match. This is done with pattern:

  "time": {
    "type": "string",
    "pattern": "^([0-9]|[01][0-9]|2[0-3]):[0-5][0-9]$",
    "description": "When the event starts."
  }

And "end-time" works the same.

Now we defined the properties of an event object. This means, an object that contains, e.g., "time": "midnight", is not a valid event. If the title attribute would be missing, it is not covered yet. For this, we additionally need to list the required attributes:

  "required": ["title", "date"]

Last thing we wanted, is that the end-time is allowed only if there is a time. This is done using a dependency. Dependencies are defined as “if this attribute is present, also this list of other attributes needs to be defined”:

  "dependencies": {
    "end-time": ["time"]
  }

Here’s the final schema.

Using the Schema with Ruby and json_schemer

We just feed the raw JSON Schema to JSONSchemer.schema which is the entry point for the json_schemer-gem:

require "json_schemer"
schema = JSONSchemer.schema(File.read("ex1_schema.json"))

Testing, if a schema is valid is done with the valid? method:

valid_doc = JSON.parse '{
  "title": "Christmas",
  "date": "2019-12-24"
}'
schema.valid? valid_doc
# => true

invalid_doc = JSON.parse '{
  "time": "20:00"
}'
schema.valid? invalid_doc
# => false

If you also want to tell the your user, what part of the schema is faulty, you can use the validate method. This returns an enumerator that contains all errors. Each object in the enumerator contains a data_pointer that indicates where the error is and a type that says what went wrong.

unless schema.valid?(document)
  puts "================================="
  puts "Document not valid:"
  schema.validate(document).each do |v|
      puts "- error type: #{v["type"]}"
      puts "  data: #{v['data']}"
      puts "  path: #{v["data_pointer"]}"
  end
end

For the first invalid example from above, this snippet outputs

Document not valid:
- error type: required
  data: {"address"=>"Carthage", "date"=>"Friday"}
  path:
- error type: format
  data: Friday
  path: /date

The first error comes from the requirement of the title attribute to be present. As this is validated on the entire object, it appears in the data field of the error, and the path is an empty string (pointing to the root of the object). The second error is, because the string found in the /date path with content Friday does not match the desired format.

To provide nicer errors to our users we can format them like this:

def nice_error verr
  case verr["type"]
  when "required"
    "Path '#{verr["data_pointer"]}' is missing keys: #{verr["details"]["missing_keys"].join ', '}"
  when "format"
    "Path '#{verr["data_pointer"]}' is not in required format (#{verr["schema"]["format"]})"
  when "minLength"
    "Path '#{verr["data_pointer"]}' is not long enough (min #{verr["schema"]["minLength"]})"
  else
    "There is a problem with path '#{verr["data_pointer"]}'. Please check your input."
  end
end

# validate a series of objects
(valid + invalid).each do |d|
  unless schema.valid?(d)
    puts "Document not valid:"
    schema.validate(d).each do |v|
      puts "- #{nice_error v}"
    end
  end
end

Conclusion

This was a quick start for when you want to make sure that the JSON documents you are reading fulfill certain properties. You can look at the schema, Ruby code, and valid examples 1 and 2 as well as invalid examples 1, 2, 3, and 4.

If you want to explore more of JSON Schema’s possibilities, look at this page. If you are not a Ruby person, the JSON Schema website has an extensive list of libraries in other language.