hyperjump-io / json-schema

JSON Schema Validation, Annotation, and Bundling. Supports Draft 04, 06, 07, 2019-09, 2020-12, OpenAPI 3.0, and OpenAPI 3.1
https://json-schema.hyperjump.io/
MIT License
216 stars 22 forks source link
json-schema

Hyperjump - JSON Schema

A collection of modules for working with JSON Schemas.

Install

Includes support for node.js/bun.js (ES Modules, TypeScript) and browsers (works with CSP unsafe-eval).

Node.js

npm install @hyperjump/json-schema

TypeScript

This package uses the package.json "exports" field. TypeScript understands "exports", but you need to change a couple settings in your tsconfig.json for it to work.

    "module": "Node16", // or "NodeNext"
    "moduleResolution": "Node16", // or "NodeNext"

Versioning

The API for this library is divided into two categories: Stable and Experimental. The Stable API follows semantic versioning, but the Experimental API may have backward-incompatible changes between minor versions.

All experimental features are segregated into exports that include the word "experimental" so you never accidentally depend on something that could change or be removed in future releases.

Validation

Usage

This library supports many versions of JSON Schema. Use the pattern @hyperjump/json-schema/* to import the version you need.

import { registerSchema, validate } from "@hyperjump/json-schema/draft-2020-12";

You can import support for additional versions as needed.

import { registerSchema, validate } from "@hyperjump/json-schema/draft-2020-12";
import "@hyperjump/json-schema/draft-07";

Note: The default export (@hyperjump/json-schema) is reserved for the stable version of JSON Schema that will hopefully be released in near future.

Validate schema from JavaScript

registerSchema({
  $schema: "https://json-schema.org/draft/2020-12/schema",
  type: "string"
}, "http://example.com/schemas/string");

const output = await validate("http://example.com/schemas/string", "foo");
if (output.valid) {
  console.log("Instance is valid :-)");
} else {
  console.log("Instance is invalid :-(");
}

Compile schema

If you need to validate multiple instances against the same schema, you can compile the schema into a reusable validation function.

const isString = await validate("http://example.com/schemas/string");
const output1 = isString("foo");
const output2 = isString(42);

Fetching schemas

Schemas that are available on the web can be loaded automatically without needing to load them manually.

const output = await validate("http://example.com/schemas/string", "foo");

When running on the server, you can also load schemas directly from the filesystem. When fetching from the file system, there are limitations for security reasons. You can only reference a schema identified by a file URI scheme (file:///path/to/my/schemas) from another schema identified by a file URI scheme. Also, a schema is not allowed to self-identify ($id) with a file: URI scheme.

const output = await validate(`file://${__dirname}/string.schema.json`, "foo");

If the schema URI is relative, the base URI in the browser is the browser location and the base URI on the server is the current working directory. This is the preferred way to work with file-based schemas on the server.

const output = await validate(`./string.schema.json`, "foo");

You can add/modify/remove support for any URI scheme using the plugin system provided by @hyperjump/browser.

OpenAPI

The OpenAPI 3.0 and 3.1 meta-schemas are pre-loaded and the OpenAPI JSON Schema dialects for each of those versions is supported. A document with a Content-Type of application/openapi+json (web) or a file extension of openapi.json (filesystem) is understood as an OpenAPI document.

Use the pattern @hyperjump/json-schema/* to import the version you need. The available versions are openapi-3-0 for 3.0 and openapi-3-1 for 3.1.

import { validate } from "@hyperjump/json-schema/openapi-3-1";

// Validate an OpenAPI document
const output = await validate("https://spec.openapis.org/oas/3.1/schema-base", openapi);

// Validate an instance against a schema in an OpenAPI document
const output = await validate("./example.openapi.json#/components/schemas/foo", 42);

YAML support isn't built in, but you can add it by writing a MediaTypePlugin. You can use the one at lib/openapi.js as an example and replace the JSON parts with YAML.

Media types

This library uses media types to determine how to parse a retrieved document. It will never assume the retrieved document is a schema. By default it's configured to accept documents with a application/schema+json Content-Type header (web) or a .schema.json file extension (filesystem).

You can add/modify/remove support for any media-type using the plugin system provided by @hyperjump/browser. The following example shows how to add support for JSON Schemas written in YAML.

import YAML from "yaml";
import contentTypeParser from "content-type";
import { addMediaTypePlugin } from "@hyperjump/browser";
import { buildSchemaDocument } from "@hyperjump/json-schema/experimental";

addMediaTypePlugin("application/schema+yaml", {
  parse: async (response) => {
    const contentType = contentTypeParser.parse(response.headers.get("content-type") ?? "");
    const contextDialectId = contentType.parameters.schema ?? contentType.parameters.profile;

    const foo = YAML.parse(await response.text());
    return buildSchemaDocument(foo, response.url, contextDialectId);
  },
  fileMatcher: (path) => path.endsWith(".schema.yml")
});

API

These are available from any of the exports that refer to a version of JSON Schema, such as @hyperjump/json-schema/draft-2020-12.

Type Definitions

The following types are used in the above definitions

Bundling

Usage

You can bundle schemas with external references into a single deliverable using the official JSON Schema bundling process introduced in the 2020-12 specification. Given a schema with external references, any external schemas will be embedded in the schema resulting in a Compound Schema Document with all the schemas necessary to evaluate the given schema in a single JSON document.

The bundling process allows schemas to be embedded without needing to modify any references which means you get the same output details whether you validate the bundle or the original unbundled schemas.

import { registerSchema } from "@hyperjump/json-schema/draft-2020-12";
import { bundle } from "@hyperjump/json-schema/bundle";

registerSchema({
  "$schema": "https://json-schema.org/draft/2020-12/schema",

  "type": "object",
  "properties": {
    "foo": { "$ref": "/string" }
  }
}, "https://example.com/main");

registerSchema({
  "$schema": "https://json-schema.org/draft/2020-12/schema",

  "type": "string"
}, "https://example.com/string");

const bundledSchema = await bundle("https://example.com/main"); // {
//   "$schema": "https://json-schema.org/draft/2020-12/schema",
//
//   "type": "object",
//   "properties": {
//     "foo": { "$ref": "/string" }
//   },
//
//   "$defs": {
//     "string": {
//       "$id": "https://example.com/string",
//       "type": "string"
//     }
//   }
// }

API

These are available from the @hyperjump/json-schema/bundle export.

Experimental

Output Formats

Change the validation output format

The FLAG output format isn't very informative. You can change the output format used for validation to get more information about failures. The official output format is still evolving, so these may change or be replaced in the future.

import { BASIC } from "@hyperjump/json-schema/experimental";

const output = await validate("https://example.com/schema1", 42, BASIC);

Change the schema validation output format

The output format used for validating schemas can be changed as well.

import { validate, setMetaSchemaOutputFormat } from "@hyperjump/json-schema/draft-2020-12";
import { BASIC } from "@hyperjump/json-schema/experimental";

setMetaSchemaOutputFormat(BASIC);
try {
  const output = await validate("https://example.com/invalid-schema");
} catch (error) {
  console.log(error.output);
}

Custom Keywords, Vocabularies, and Dialects

In order to create and use a custom keyword, you need to define your keyword's behavior, create a vocabulary that includes that keyword, and then create a dialect that includes your vocabulary.

Schemas are represented using the @hyperjump/browser package. You'll use that API to traverse schemas. @hyperjump/browser uses async generators to iterate over arrays and objects. If you like using higher order functions like map/filter/reduce, see @hyperjump/pact for utilities for working with generators and async generators.

import { registerSchema, validate } from "@hyperjump/json-schema/draft-2020-12";
import { addKeyword, defineVocabulary, Validation } from "@hyperjump/json-schema/experimental";
import * as Browser from "@hyperjump/browser";

// Define a keyword that's an array of schemas that are applied sequentially
// using implication: A -> B -> C -> D
addKeyword({
  id: "https://example.com/keyword/implication",

  compile: async (schema, ast) => {
    const subSchemas = [];
    for await (const subSchema of Browser.iter(schema)) {
      subSchemas.push(Validation.compile(subSchema, ast));
    }
    return subSchemas;

    // Alternative using @hyperjump/pact
    // return pipe(
    //   Browser.iter(schema),
    //   asyncMap((subSchema) => Validation.compile(subSchema, ast)),
    //   asyncCollectArray
    // );
  },

  interpret: (implies, instance, ast, dynamicAnchors, quiet) => {
    return implies.reduce((valid, schema) => {
      return !valid || Validation.interpret(schema, instance, ast, dynamicAnchors, quiet);
    }, true);
  }
});

// Create a vocabulary with this keyword and call it "implies"
defineVocabulary("https://example.com/vocab/logic", {
  "implies": "https://example.com/keyword/implication"
});

// Create a vocabulary schema for this vocabulary
registerSchema({
  "$id": "https://example.com/meta/logic",
  "$schema": "https://json-schema.org/draft/2020-12/schema",

  "$dynamicAnchor": "meta",
  "properties": {
    "implies": {
      "type": "array",
      "items": { "$dynamicRef": "meta" },
      "minItems": 2
    }
  }
});

// Create a dialect schema adding this vocabulary to the standard JSON Schema
// vocabularies
registerSchema({
  "$id": "https://example.com/dialect/logic",
  "$schema": "https://json-schema.org/draft/2020-12/schema",

  "$vocabulary": {
    "https://json-schema.org/draft/2020-12/vocab/core": true,
    "https://json-schema.org/draft/2020-12/vocab/applicator": true,
    "https://json-schema.org/draft/2020-12/vocab/unevaluated": true,
    "https://json-schema.org/draft/2020-12/vocab/validation": true,
    "https://json-schema.org/draft/2020-12/vocab/meta-data": true,
    "https://json-schema.org/draft/2020-12/vocab/format-annotation": true,
    "https://json-schema.org/draft/2020-12/vocab/content": true
    "https://example.com/vocab/logic": true
  },

  "$dynamicAnchor": "meta",

  "allOf": [
    { "$ref": "https://json-schema.org/draft/2020-12/schema" },
    { "$ref": "/meta/logic" }
  ]
});

// Use your dialect to validate a JSON instance
registerSchema({
  "$schema": "https://example.com/dialect/logic",

  "type": "number",
  "implies": [
    { "minimum": 10 },
    { "multipleOf": 2 }
  ]
}, "https://example.com/schema1");
const output = await validate("https://example.com/schema1", 42);

Custom Meta Schema

You can use a custom meta-schema to restrict users to a subset of JSON Schema functionality. This example requires that no unknown keywords are used in the schema.

registerSchema({
  "$id": "https://example.com/meta-schema1",
  "$schema": "https://json-schema.org/draft/2020-12/schema",

  "$vocabulary": {
    "https://json-schema.org/draft/2020-12/vocab/core": true,
    "https://json-schema.org/draft/2020-12/vocab/applicator": true,
    "https://json-schema.org/draft/2020-12/vocab/unevaluated": true,
    "https://json-schema.org/draft/2020-12/vocab/validation": true,
    "https://json-schema.org/draft/2020-12/vocab/meta-data": true,
    "https://json-schema.org/draft/2020-12/vocab/format-annotation": true,
    "https://json-schema.org/draft/2020-12/vocab/content": true
  },

  "$dynamicAnchor": "meta",

  "$ref": "https://json-schema.org/draft/2020-12/schema",
  "unevaluatedProperties": false
});

registerSchema({
  $schema: "https://example.com/meta-schema1",
  type: "number",
  foo: 42
}, "https://example.com/schema1");

const output = await validate("https://example.com/schema1", 42); // Expect InvalidSchemaError

API

These are available from the @hyperjump/json-schema/experimental export.

Instance API (experimental)

These functions are available from the @hyperjump/json-schema/instance/experimental export.

This library uses JsonNode objects to represent instances. You'll work with these objects if you create a custom keyword.

This API uses generators to iterate over arrays and objects. If you like using higher order functions like map/filter/reduce, see @hyperjump/pact for utilities for working with generators and async generators.

Annotations (experimental)

JSON Schema is for annotating JSON instances as well as validating them. This module provides utilities for working with JSON documents annotated with JSON Schema.

Usage

An annotated JSON document is represented as a (JsonNode)[#/instance-api-experimental] AST. You can use this AST to traverse the data structure and get annotations for the values it represents.

import { registerSchema } from "@hyperjump/json-schema/draft/2020-12";
import { annotate } from "@hyperjump/json-schema/annotations/experimental";
import * as AnnotatedInstance from "@hyperjump/json-schema/annotated-instance/experimental";

const schemaId = "https://example.com/foo";
const dialectId = "https://json-schema.org/draft/2020-12/schema";

registerSchema({
  "$schema": dialectId,

  "title": "Person",
  "unknown": "foo",

  "type": "object",
  "properties": {
    "name": {
      "$ref": "#/$defs/name",
      "deprecated": true
    },
    "givenName": {
      "$ref": "#/$defs/name",
      "title": "Given Name"
    },
    "familyName": {
      "$ref": "#/$defs/name",
      "title": "Family Name"
    }
  },

  "$defs": {
    "name": {
      "type": "string",
      "title": "Name"
    }
  }
}, schemaId);

const instance = await annotate(schemaId, {
  name: "Jason Desrosiers",
  givenName: "Jason",
  familyName: "Desrosiers"
});

// Get the title of the instance
const titles = AnnotatedInstance.annotation(instance, "title", dialectId); // => ["Person"]

// Unknown keywords are collected as annotations
const unknowns = AnnotatedInstance.annotation(instance, "unknown", dialectId); // => ["foo"]

// The type keyword doesn't produce annotations
const types = AnnotatedInstance.annotation(instance, "type", dialectId); // => []

// Get the title of each of the properties in the object
for (const [propertyNameNode, propertyInstance] of AnnotatedInstance.entries(instance)) {
  const propertyName = AnnotatedInstance.value(propertyName);
  console.log(propertyName, AnnotatedInstance.annotation(propertyInstance, "title", dialectId));
}

// List all locations in the instance that are deprecated
for (const deprecated of AnnotatedInstance.annotatedWith(instance, "deprecated", dialectId)) {
  if (AnnotatedInstance.annotation(deprecated, "deprecated", dialectId)[0]) {
    logger.warn(`The value at '${deprecated.pointer}' has been deprecated.`); // => (Example) "WARN: The value at '/name' has been deprecated."
  }
}

API

These are available from the @hyperjump/json-schema/annotations/experimental export.

AnnotatedInstance API (experimental)

These are available from the @hyperjump/json-schema/annotated-instance/experimental export. The following functions are available in addition to the functions available in the Instance API.

Contributing

Tests

Run the tests

npm test

Run the tests with a continuous test runner

npm test -- --watch