No Description

Sam Jaffe b9d4c96bfb refactor: clang-tidy 1 week ago
include b9d4c96bfb refactor: clang-tidy 1 week ago
src b9d4c96bfb refactor: clang-tidy 1 week ago
tests b9d4c96bfb refactor: clang-tidy 1 week ago
thirdparty e2b307fada chore: update to Test-JSON-Schema-Acceptance-1.037 2 weeks ago
.clang-format 08d68f1ff7 feat: initial design of schema parsing and constraints 1 year ago
.clang-tidy b9d4c96bfb refactor: clang-tidy 1 week ago
.gitignore 08d68f1ff7 feat: initial design of schema parsing and constraints 1 year ago
.gitmodules e2b307fada chore: update to Test-JSON-Schema-Acceptance-1.037 2 weeks ago
CMakeLists.txt 71beb5c1a0 test: implement test cases to reach 95% 2 weeks ago
LICENSE.md 282f600d8b Add 'LICENSE.md' 3 weeks ago
README.md c4832202dc docs: indenting 2 weeks ago

README.md

JSON Validator

A Header-Only JSON Schema Validator library written in C++20. Provides json schema validation compliant with the json schema specification.
Is compatible with the following draft versions:

Building

cmake -S . -B build [options...]
make -C build
ctest --test-dir build/tests

If, for example, you have ICU provided via homebrew, you will need to add the homebrew paths to your cmake command.

For example: -DICU_ROOT=/opt/homebrew/opt/icu4c

Usage

There are three main components for json validation, plus an additional customization point:

  1. The adapter between jvalidate and the user's JSON type: jvalidate::Adapter
  2. The schema object: jvalidate::Schema
  3. The validator object: jvalidate::Validator
  4. User-defined constraints: jvalidate::extension::ConstraintBase and jvalidate::extension::Visitor.

Creating an Adapter for custom JSON Types

An adapter represents the following interface/contract for a json implementation MyJsonType:

  • A free function with the signature bool load_stream(std::istream &, MyJsonType &, std::string &error) noexcept
  • An implementation of jvalidate::adapter::AdapterTraits<MyJsonType>, which must provide the following:
    • template <typename T> using Adapter = MyJsonTypeAdapter<T>
    • using ConstAdapter = MyJsonTypeAdapter<MyJsonType const>
    • static MyJsonType const &const_empty() { ... }
  • An adapter class implementation that subclasses jvalidate::adapter::Adapter and fulfills the contract jvalidate::Adapter.
    • By convention, the adapter class should not have ownership of the underlying object.
  • An adapter class implementation that fulfills the contract jvalidate::ArrayAdapter
  • An adapter class implementation that fulfills the contract jvalidate::ObjectAdapter

Constructing a Schema

The schema class describes how to validate a json document, but does not contain the business logic to perform the validation.

The simplest constructor for a schema has the following signature, and uses jvalidate::adapter::AdapterTraits to deduce the adapter that should wrap the document.

MyJsonType schema_document = ...;
jvalidate::Schema schema(schema_document, jvalidate::schema::Version::Draft2020_12);

Schemas also support additional components, which can be provided in any order.

URI Resolver

A URIResolver is a free function with the following signature: bool (*)(jvalidate::URI const &, MyJsonType &, std::string &error) noexcept.

If no uri resolver is provided, then it is not possible to read schemas from the internet or from files. It is also not possible to read custom vocabularies.

A curl based resolver is provided in include/jvalidate/compat/curl.h.

Constraint Factory

A ConstraintFactory is the object that maps keywords to Constraint objects. Because it is possible to change schema versions when following a reference, ConstraintFactory MUST provide information on all schema drafts at once.

There are two ways to provide user-defined keywords:

  1. The constructor ConstraintFactory(std::initializer_list<std::pair<std::string_view, Versioned>> init)
  2. An append function ConstraintFactory::with_user_keyword(std::string_view, Versioned)

For example:

jvalidate::Schema schema(
    schema_document, jvalidate::schema::Version::Draft2020_12,
    jvalidate::ConstraintFactory()
      .with_user_keyword("my_keyword", create_my_constraint));

In order to support multiple schema versions in a single instance of a ConstraintFactory, we need to be able to describe which version a keyword becomes part of the language vocabulary, and what (if any) version it leaves the vocabulary after.

To do this, we store an ordered map of Version enum onto a vocabulary Metadata object and then use std::map::lower_bound to determine which one is the most appropriate for the schema version being evaluated.

For example:

  • The "additionalProperties" constraint is the same across all versions, and so can be represented using only a function pointer.

    {"additionalProperties", &Self::additionalProperties}
    
  • The "const" constraint was not added until Draft06, so we include the version when constructing its constraint bindings like so:

    {"const", {schema::Version::Draft06, &Self::isConstant}}
    
  • The "divisibleBy" constraint was removed in favor of "multipleOf" in Draft04, and therefore is represented as:

    {"divisibleBy", {{schema::Version::Earliest, &Self::multipleOf},
                     {schema::Version::Draft04, Removed}}},
    {"multipleOf",  {schema::Version::Draft04, &Self::multipleOf}}
    
  • A small number of rare constraints change their meaning when moving from one draft version to another in such a significant way that it makes more sense to use different MakeConstraint functions for them.

    {"items", {{schema::Version::Earliest, &Self::itemsTupleOrVector},
               {schema::Version::Draft2020_12, &Self::additionalItems}}}
    
  • Reserved keywords that have no meaning by themselves can use the Literal rule:

    {"contains", {schema::Version::Draft06, &Self::contains}},
    {"maxContains", {schema::Version::Draft06, Literal}},
    {"minContains", {schema::Version::Draft06, Literal}},
    
  • Since some special words like "default", "examples", "enum", etc. may contain objects which should not be evaluated as JSON schemas for things like "$id" tokens, the rules Keyword and KeywordMap allow marking only those keywords that are expected to hold more json schemas to be evaluated:

    {"$defs", {schema::Version::Draft2019_09, KeywordMap}},
    {"additionalProperties", {{&Self::additionalProperties, Keyword}}},
    {"allOf", {schema::Version::Draft04, {&Self::allOf, Keyword}}},
    

Validating a Document

Validation is performed by constructing a Validator object, and calling the validate() function. The validator does not own the schema node that it operates on, but it does own a copy of the provided ExtensionVisitor (if any), and of the RegexEngine that it maintains internally. Reusing the Validator can allow for caching of regular expressions, if they are commonly used in the schema.

There are two main ways to call Validator::validate, with or without a jvalidate::ValidationResult object. If validate is called without a result, then the evaluation will end immediately after the first rejecting constraint. Otherwise, it will return all evaluation reasons.

Creating custom extensions

Custom extensions allow the implementation of user-specific constraints that either cannot naturally be described in the json schema specification or are used commonly enough that the DRY principle applies. Some virtual function magic is used to unwrap all of the type-erasure that occurs does not require the consumer to write any boilerplate.

A user constraint is defined as a simple struct with the following form:

struct MyCustomConstraint : jvalidate::extension::ConstraintBase<MyCustomConstraint> {
  MyCustomConstraint(...) { ... } // Required b/c we're a subclass

  // fields...
};

It is added to the Schema as a new keyword through the ConstraintFactory. Much like builtin constraints, context can be used to extract values from the schema json, or to evaluate child schemas.

jvalidate::ConstraintFactory factory{
  {"my_keyword", [](auto const & context) {
      return ExtensionConstraint::make<MyCustomConstraint>(...);
   }},
};

A validator is created as follows, and then is passed in as the ExtensionVisitor argument to Validator.

class Visitor : jvalidate::extension::Visitor<Visitor, MyCustomConstraint, ...> {
public:
  // One of these for every constraint in the template signature
  Status visit(MyCustomConstraint const & cons,
               jvalidate::Adapter auto const & document,
               auto const &validator) const;
};

In tests/extension_test.cxx, an example demonstrating a json schema for a graph which requires each edge's source and destination are nodes in the graph.