diff --git a/.gitignore b/.gitignore index 47062da0..593a560e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ jsonschema-*.txt relative-json-pointer.html relative-json-pointer.pdf relative-json-pointer.txt +proposals/*.html # For the Python enviornment .venv diff --git a/README.md b/README.md index af091291..d57a233b 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,11 @@ Labels are assigned based on [Sensible Github Labels](https://github.com/Releque ## Authoring and Building ### Specification -To build the spec files to HTML from the Markdown sources, run `npm run build`. -You can also build each individually with `npm run build-core` and `npm run -build-validation`. +To build the spec files to HTML from the Markdown sources, run `npm run +build-all`. +You can also build each individually with `npm run build -- filename.md` +(Example: `npm run build -- jsonschema-core.md`). You can also use wildcards to +build multiple specs at the same time: `npm run build -- jsonschema-*.md`. The spec is built using [Remark](https://remark.js.org/), a markdown engine with good support for plugins and lots of existing plugins we can use. diff --git a/build/build.js b/build/build.js index e8b3f0ef..550b0008 100644 --- a/build/build.js +++ b/build/build.js @@ -1,5 +1,7 @@ import dotenv from "dotenv"; import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, basename } from "node:path"; +import { argv } from "node:process"; import { reporter } from "vfile-reporter"; import { remark } from "remark"; import remarkCodeTitles from "./remark-code-titles.js"; @@ -18,15 +20,24 @@ import rehypeStringify from "rehype-stringify"; dotenv.config(); -(async function () { - const md = readFileSync(0, "utf-8"); +const build = async (filename) => { + const md = readFileSync(filename, "utf-8"); const html = await remark() .use(remarkPresetLintMarkdownStyleGuide) .use(remarkGfm) .use(remarkHeadingId) .use(remarkHeadings, { startDepth: 2, - skip: ["Abstract", "Note to Readers", "Table of Contents", "Authors' Addresses", "\\[.*\\]", "draft-.*"] + skip: [ + "Abstract", + "Status", + "Note to Readers", + "Table of Contents", + "Authors' Addresses", + "Champions", + "\\[.*\\]", + "draft-.*" + ] }) .use(remarkReferenceLinks) .use(remarkFlexibleContainers) @@ -34,14 +45,22 @@ dotenv.config(); .use(remarkTorchLight) .use(remarkTableOfContents, { startDepth: 2, - skip: ["Abstract", "Note to Readers", "\\[.*\\]", "Authors' Addresses", "draft-.*"] + skip: [ + "Abstract", + "Note to Readers", + "Authors' Addresses", + "Champions", + "\\[.*\\]", + "draft-.*" + ] }) .use(remarkValidateLinks) .use(remarkRehype) .use(rehypeStringify) .process(md); - writeFileSync(1, ` + const outfile = `${dirname(filename)}/${basename(filename, ".md")}.html`; + writeFileSync(outfile, ` @@ -149,4 +168,17 @@ dotenv.config(); `); console.error(reporter(html)); +}; + +(async function () { + const files = argv.slice(2); + if (files.length === 0) { + console.error("WARNING: No files built. Usage: 'npm run build -- filename.md'"); + } + + for (const filename of files) { + console.log(`Building: ${filename} ...`); + await build(filename); + console.log(""); + } }()); diff --git a/jsonschema-core.md b/jsonschema-core.md index 700b90fa..8f184591 100644 --- a/jsonschema-core.md +++ b/jsonschema-core.md @@ -1655,7 +1655,7 @@ User-Agent: product-name/5.4.1 so-cool-json-schema/1.0.2 curl/7.43.0 Clients SHOULD be able to make requests with a "From" header so that server operators can contact the owner of a potentially misbehaving script. -## A Vocabulary for Applying Subschemas +## A Vocabulary for Applying Subschemas {#applicatorvocab} This section defines a vocabulary of applicator keywords that are RECOMMENDED for use as the basis of other vocabularies. @@ -1793,7 +1793,7 @@ successfully validates against its subschema. Implementations MUST NOT evaluate the instance against this keyword, for either validation or annotation collection purposes, in such cases. -##### `dependentSchemas` +##### `dependentSchemas` {#dependent-schemas} This keyword specifies subschemas that are evaluated if the instance is an object and contains a certain property. @@ -1807,21 +1807,6 @@ property. Omitting this keyword has the same behavior as an empty object. -##### `propertyDependencies` - -This keyword specifies subschemas that are evaluated if the instance is an -object and contains a certain property with a certain string value. - -This keyword's value MUST be an object. Each value in the object MUST be an -object whose values MUST be valid JSON Schemas. - -If the outer object key is a property in the instance and the inner object key -is equal to the value of that property, the entire instance must validate -against the schema. Its use is dependent on the presence and value of the -property. - -Omitting this keyword has the same behavior as an empty object. - ### Keywords for Applying Subschemas to Child Instances Each of these keywords defines a rule for applying its subschema(s) to child diff --git a/package.json b/package.json index e5e2a18f..fe675c71 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,8 @@ "main": "index.js", "scripts": { "lint": "eslint build/", - "build": "npm run build-core && npm run build-validation && npm run build-output", - "build-core": "node build/build.js < jsonschema-core.md > jsonschema-core.html", - "build-validation": "node build/build.js < jsonschema-validation.md > jsonschema-validation.html", - "build-output": "node build/build.js < jsonschema-validation-output-machines.md > jsonschema-validation-output-machines.html" + "build-all": "node build/build.js jsonschema-*.md proposals/*.md", + "build": "node build/build.js" }, "license": "MIT", "dependencies": { diff --git a/proposals/propertyDependencies-adr.md b/proposals/propertyDependencies-adr.md new file mode 100644 index 00000000..315357b6 --- /dev/null +++ b/proposals/propertyDependencies-adr.md @@ -0,0 +1,291 @@ +# Add New Keyword: `propertyDependencies` + +* Status: proposed +* Deciders: @gregsdennis, @jdesrosiers, @relequestual +* Date: 2022-04-07 + +Technical Story: + +- Issue discussing feature - https://github.com/json-schema-org/json-schema-spec/issues/1082 +- PR to add to the spec - https://github.com/json-schema-org/json-schema-spec/pull/1143 +- ADR to extract from the spec and use feature life cycle - https://github.com/json-schema-org/json-schema-spec/pull/1505 + +## Context and Problem Statement + +A common need in JSON Schema is to select between one schema or another to +validate an instance based on the value of some property in the JSON instance. +There are a several patterns people use to accomplish this, but they all have +significant [problems](#problems). + +OpenAPI solves this problem with the `discriminator` keyword. However, their +approach is more oriented toward code generation concerns, is poorly specified +when it comes to validation, and is coupled to OpenAPI concepts that don't exist +is JSON Schema. Therefore, it's necessary to define something new rather than +adopt or redefine `discriminator`. + +## Decision Drivers + +- Ease of use +- Readability +- Coverage of most common use cases +- Coverage of all use cases +- Ease of implementation + +## Considered Options + +All of the following options have the same validation result as the following schema. + +```json +{ + "if": { + "properties": { + "foo": { "const": "aaa" } + }, + "required": ["foo"] + }, + "then": { "$ref": "#/$defs/foo-aaa" } +} +``` + + +### Option 1 + +The `dependentSchemas` keyword is very close to what is needed except it checks +for the presence of a property rather than it's value. This option builds on +that concept to solve this problem. + +```json +{ + "propertyDependencies": { + "foo": { + "aaa": { "$ref": "#/$defs/foo-aaa" } + } + } +} +``` + +* Good, because it handle the most common use case: string property values +* Good, because all property values are grouped together +* Good, because it's less verbose +* Bad, because it doesn't handle non-string property values + +### Option 2 + +This version uses an array of objects. Each object is a collection of the +variables needed to express a property dependency. This doesn't fit the style of +JSON Schema. There aren't any keywords remotely like this. It's also still too +verbose. It's a little more intuitive than `if`/`then` and definitely less error +prone. + +```jsonschema +{ + "propertyDependencies": [ + { + "propertyName": "foo", + "propertySchema": { "const": "aaa" }, + "apply": { "$ref": "#/$defs/foo-aaa" } + }, + { + "propertyName": "foo", + "propertySchema": { "const": "bbb" }, + "apply": { "$ref": "#/$defs/foo-bbb" } + } + ] +} +``` + +* Good, because it supports all use cases +* Bad, because properties are not naturally grouped together +* Bad, because it's quite verbose +* Bad, because we have no precedent for a keyword which explicitly defines its own properties. This would be new operational functionality, which we try to avoid if we can. + +### Option 3 + +A slight variation on that example is to make it a map of keyword to dependency +object. It's still too verbose. + +```jsonschema +{ + "propertyDependencies": { + "foo": [ + { + "propertySchema": { "const": "aaa" }, + "apply": { "$ref": "#/$defs/foo-aaa" } + }, + { + "propertySchema": { "const": "bbb" }, + "apply": { "$ref": "#/$defs/foo-bbb" } + } + ] + } +} +``` + +* Good, because it supports all use cases +* Good, because all property values are grouped together +* Bad, because it's quite verbose +* Bad, because we have no precedent for a keyword which explicitly defines its own properties. This would be new operational functionality, which we try to avoid if we can. + +### Option 4 + +This one is a little more consistent with the JSON Schema style (poor keyword +naming aside), but otherwise has all the same problems as the other examples. + +```jsonschema +{ + "allOf": [ + { + "propertyDependencyName": "foo", + "propertyDependencySchema": { "const": "aaa" }, + "propertyDependencyApply": { "$ref": "#/$defs/foo-aaa" } + }, + { + "propertyDependencyName": "foo", + "propertyDependencySchema": { "const": "bbb" }, + "propertyDependencyApply": { "$ref": "#/$defs/foo-bbb" } + } + ] +} +``` + +* Good, because it supports all use cases +* Bad, because properties are not naturally grouped together +* Bad, because it's very verbose +* Bad, because it introduces a lot of inter-keyword dependencies, which we'd have to exhaustively define + +### Option 5 + +This one is a variation of `if` that combines `if`, `properties`, and `required` +to reduce boilerplate. It's also essentially a variation of the previous example +with better names. This avoids to error proneness problem, but it's still too +verbose. + +```jsonschema +{ + "allOf": [ + { + "ifProperties": { + "foo": { "const": "aaa" } + }, + "then": { "$ref": "#/$defs/foo-aaa" } + }, + { + "ifProperties": { + "foo": { "const": "bbb" } + }, + "then": { "$ref": "#/$defs/foo-aaa" } + } + ] +} +``` + +* Good, because it supports all use cases +* Good, because it's a familiar syntax +* Bad, because properties are not naturally grouped together +* Bad, because it's very verbose +* Bad, because `ifProperties` is very niche. Will this spawn a new series of `if*` keywords? How would it interact with `if`? + +### Option 6 + +All of the previous alternatives use a schema as the discriminator. This +alternative is a little less powerful in that it can only match on exact values, +but it successfully addresses the problems we're concerned about with the +current approaches. The only issue with this alternative is that it's not as +intuitive as the chosen solution. + +```jsonschema +{ + "propertyDependencies": { + "foo": [ + ["aaa", { "$ref": "#/$defs/foo-aaa" }], + ["bbb", { "$ref": "#/$defs/foo-bbb" }] + ] + } +} +``` + +* Good, because it supports all use cases +* Bad, because it's an unintuitive syntax and easy to get wrong +* Bad, because properties are not naturally grouped together + +## Decision Outcome + +Option 1 was chosen because it satisfies the most common use cases while being +sufficiently readable and easy to implement, even though it does not satisfy +_all_ use cases, such as those where the property value is not a string. As +these cases are significantly less common, the requirement to support all use +cases carried a lower priority. + +### Positive Consequences + +- Some level of built-in support for a `discriminator`-like keyword that aligns + with the existing operation of JSON Schema. + +### Negative Consequences + +- Properties with non-string values cannot be supported using this keyword and + the `allOf`-`if`-`then` pattern must still be used. + +## [Appendix] Problems With Existing Patterns {#problems} + +### `oneOf`/`anyOf` + +The pattern of using `oneOf` to describe a choice between two schemas has become +ubiquitous. + +```jsonschema +{ + "oneOf": [ + { "$ref": "#/$defs/aaa" }, + { "$ref": "#/$defs/bbb" } + ] +} +``` + +However, this pattern has several shortcomings. The main problem is that it +tends to produce confusing error messages. Some implementations employ +heuristics to guess the user's intent and provide better messaging, but that's +not wide-spread or consistent behavior, nor is it expected or required from +implementations. + +This pattern is also inefficient. Generally, there is a single value in the +object that determines which alternative to chose, but the `oneOf` pattern has +no way to specify what that value is and therefore needs to evaluate the entire +schema. This is made worse in that every alternative needs to be fully validated +to ensure that only one of the alternative passes and all the others fail. This +last problem can be avoided by using `anyOf` instead, but that pattern is much +less used. + +### `if`/`then` + +We can describe this kind of constraint more efficiently and with with better +error messaging by using `if`/`then`. This allows the user to explicitly specify +the constraint to be used to select which alternative the schema should be used +to validate the schema. However, this pattern has problems of it's own. It's +verbose, error prone, and not particularly intuitive, which leads most people to +avoid it. + +```jsonschema +{ + "allOf": [ + { + "if": { + "properties": { + "foo": { "const": "aaa" } + }, + "required": ["foo"] + }, + "then": { "$ref": "#/$defs/foo-aaa" } + }, + { + "if": { + "properties": { + "foo": { "const": "bbb" } + }, + "required": ["foo"] + }, + "then": { "$ref": "#/$defs/foo-bbb" } + } + ] +} +``` diff --git a/proposals/propertyDependencies.md b/proposals/propertyDependencies.md new file mode 100644 index 00000000..757b5652 --- /dev/null +++ b/proposals/propertyDependencies.md @@ -0,0 +1,135 @@ +# JSON Schema Proposal: The `propertyDependencies` Keyword + +## Abstract + +The `propertyDependencies` keyword is a more friendly way to select between two +or more schemas to validate an instance against than is currently supported by +JSON Schema. + +## Current Status + +This proposal is complete and awaiting integration into the specification. + +As some additional context, this proposal has been written prior to the stable +specification's initial release. As such, it will not be integrated until at +least the spec's second release at the earliest. It is also operating as a +proving grounds, of sorts, for the SDLC's Feature Life Cycle. + +## Note to Readers + +The issues list for this document can be found at +. + +For additional information, see . + +To provide feedback, use this issue tracker or any of the communication methods +listed on the homepage. + +## Table of Contents + +## Conventions and Terminology + +All conventions and terms used and defined by the [JSON Schema Core +specification](../jsonschema-core.html) also apply to this document. + +## Overview + +### Problem Statement + +A common need in JSON Schema is to select between one schema or another to +validate an instance based on the value of some property in the JSON instance. +There are a several patterns people use to accomplish this, but they all have +significant [problems](propertyDependencies-adr.md#problems). + +OpenAPI solves this problem with the `discriminator` keyword. However, their +approach is more oriented toward code generation concerns, is poorly specified +when it comes to validation, and is coupled to OpenAPI concepts that don't exist +is JSON Schema. Therefore, it's necessary to define something new rather than +adopt or redefine `discriminator`. + +### Solution + +The `dependentSchemas` keyword is very close to what is needed except it checks +for the presence of a property rather than it's value. The chosen solution is to +build on that concept to solve this problem. + +```json +{ + "propertyDependencies": { + "foo": { + "aaa": { "$ref": "#/$defs/foo-aaa" } + } + } +} +``` + +The validation result is equivalent to the following schema. + +```json +{ + "if": { + "properties": { + "foo": { "const": "aaa" } + }, + "required": ["foo"] + }, + "then": { "$ref": "#/$defs/foo-aaa" } +} +``` + +### Limitations + +The problem of choosing an alternative based on a property value could apply for +a value of any JSON type, but `propertyDependencies` only solves this problem +when the value is a string. One of the main goals of this keyword is to define +something that's intuitive enough and easy enough to use that people will +actually use it rather than fallback to `oneOf` because it's simple. Achieving +those goals means that some trade-offs need to be made. + +## Change Description + +The `propertyDependencies` keyword will be added to the `https://json-schema.org/vocab/applicator` [applicator +vocabulary](../jsonschema-core.html#applicatorvocab). + +1. The following will be added to the JSON Schema Core specification as a +subsection of "Keywords for Applying Subschemas Conditionally". + > ### `propertyDependencies` + > + > This keyword specifies subschemas that are evaluated if the instance is an + > object and contains a certain property with a certain string value. + > + > This keyword's value MUST be an object. Each value in the object MUST be an + > object whose values MUST be valid JSON Schemas. + > + > If the outer object key is a property in the instance and the inner object key + > is equal to the value of that property, the entire instance must validate + > against the schema. Its use is dependent on the presence and value of the + > property. + > + > Omitting this keyword has the same behavior as an empty object. +2. The following subschema will be added to the Applicator Vocabulary schema, `https://json-schema.org///meta/applicator` at `/properties/propertyDependencies`: + ```json + { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "$dynamicRef": "#meta", + "default": true + }, + "default": {} + } + } + ``` + +## [Appendix] Change Log + +* [March 2021] - Initially proposed +* [October 2021] Added to specification document +* [May 2024] Extracted from specification document as experimental feature + +## Champions + +| Champion | Company | Email | URI | +|----------------------------|---------|----------------------|----------------------------------| +| Jason Desrosiers | Postman | | |