Skip to content

Commit

Permalink
discriminator keyword, closes ajv-validator#1119 (ajv-validator#1494)
Browse files Browse the repository at this point in the history
* discriminator keyword, ajv-validator#1119 (WIP)

* OpenAPI discriminator, tests, ajv-validator#1119

* docs: discriminator
  • Loading branch information
epoberezkin authored and andriyl committed Jun 16, 2021
1 parent 4053312 commit 164a991
Show file tree
Hide file tree
Showing 13 changed files with 444 additions and 18 deletions.
40 changes: 38 additions & 2 deletions docs/guide/modifying-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ If `removeAdditional` option in the example above were `"all"` then both `additi
If the option were `"failing"` then property `additional1` would have been removed regardless of its value and property `additional2` would have been removed only if its value were failing the schema in the inner `additionalProperties` (so in the example above it would have stayed because it passes the schema, but any non-number would have been removed).

::: warning Unexpected results when using removeAdditional with anyOf/oneOf
If you use `removeAdditional` option with `additionalProperties` keyword inside `anyOf`/`oneOf` keywords your validation can fail with this schema
If you use `removeAdditional` option with `additionalProperties` keyword inside `anyOf`/`oneOf` keywords your validation can fail with this schema. To make it work as you expect, you have to use discriminated union with [discriminator](../json-schema.md#discriminator) keyword (requires `discriminator` option).
:::

For example:
For example, with this non-discriminated union you will have unexpected results:

```javascript
{
Expand Down Expand Up @@ -120,6 +120,38 @@ While this behaviour is unexpected (issues [#129](https://github.com/ajv-validat

The schema above is also more efficient - it will compile into a faster function.

For discriminated unions you could schemas with [discriminator](../json-schema.md#discriminator) keyword (it requires `discriminator: true` option):

```javascript
{
type: "object",
discriminator: {propertyName: "tag"},
required: ["tag"],
oneOf: [
{
properties: {
tag: {const: "foo"},
foo: {type: "string"}
},
required: ["foo"],
additionalProperties: false
},
{
properties: {
tag: {const: "bar"},
bar: {type: "integer"}
},
required: ["bar"],
additionalProperties: false
}
]
}
```

With this schema, only one subschema in `oneOf` will be evaluated, so `removeAdditional` option will work as expected.

See [discriminator](../json-schema.md#discriminator) keyword.

## Assigning defaults

With [option `useDefaults`](./api.md#options) Ajv will assign values from `default` keyword in the schemas of `properties` and `items` (when it is the array of schemas) to the missing properties and items.
Expand Down Expand Up @@ -180,6 +212,10 @@ The strict mode option can change the behaviour for these unsupported defaults (

See [Strict mode](./strict-mode.md).

::: tip Default with discriminator keyword
Defaults will be assigned in schemas inside `oneOf` in case [discriminator](../json-schema.md#discriminator) keyword is used.
:::

## Coercing data types

When you are validating user inputs all your data properties are usually strings. The option `coerceTypes` allows you to have your data types coerced to the types specified in your schema `type` keywords, both to pass the validation and to use the correctly typed data afterwards.
Expand Down
96 changes: 95 additions & 1 deletion docs/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ v7 added support for all new keywords in draft-2019-09:

There is also support for [$dynamicAnchor/$dynamicRef](./guide/combining-schemas.md#extending-recursive-schemas) from the next version of JSON Schema draft that will replace `$recursiveAnchor`/`$recursiveRef`.

## `type`
## OpenAPI support

Ajv supports these additional [OpenAPI specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md) keywords:
- [nullable](#nullable) - to avoid using `type` keyword with array of types.
- [discriminator](#discriminator) - to optimize validation and error reporting of tagged unions

## JSON data type

### `type`

`type` keyword requires that the data is of certain type (or some of types). Its value can be a string (the allowed type) or an array of strings (multiple allowed types).

Expand Down Expand Up @@ -51,6 +59,33 @@ Most other keywords apply only to a particular type of data. If the data is of d

In v7 Ajv introduced [Strict types](./strict-mode.md#strict-types) mode that makes these mistakes less likely by requiring that types are constrained with type keyword whenever another keyword that applies to specific type is used.

### nullable <Badge text="OpenAPI" />

This keyword can be used to allow `null` value in addition to the defined `type`.

Ajv supports it by default, without additional options. These two schemas are equivalent, but the first one is better supported by some tools and is also compatible with `strict.types` option (see [Strict types](./strict-mode.md#strict-types))

```json
{
"type": "string",
"nullable": true
}
```

and

```json
{
"type": ["string", "null"]
}
```

::: warning nullable does not extend enum and const
If you use [enum](#enum) or [const](#const) keywords, `"nullable": true` would not extend the list of allowed values - `null` value has to be explicitly added to `enum` (and `const` would fail, unless it is `"const": null`)

This is different from how `nullable` is defined in [JSON Type Definition](./json-type-definition.md), where `"nullable": true` allows `null` value in addition to any data defined by the schema.
:::

## Keywords for numbers

### `maximum` / `minimum` and `exclusiveMaximum` / `exclusiveMinimum`
Expand Down Expand Up @@ -674,6 +709,65 @@ _invalid_:

See [tests](https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/master/tests/draft2019-09/unevaluatedProperties.json) for `unevaluatedProperties` keyword for other examples.

### discriminator <Badge text="NEW: OpenAPI" />

Ajv has a limited support for `discriminator` keyword: to optimize validation, error handling, and [modifying data](./guide/modifying-data.md) with [oneOf](#oneof) keyword.

Its value should be an object with a property `propertyName` - the name of the property used to discriminate between union members.

When using discriminator keyword only one subschema in `oneOf` will be used, determined by the value of discriminator property.

::: warning Use option discriminator
To use `discriminator` keyword you have to use option `discriminator: true` with Ajv constructor - it is not enabled by default.
:::

**Example**

_schema_:

```javascript
{
type: "object",
discriminator: {propertyName: "foo"},
required: ["foo"],
oneOf: [
{
properties: {
foo: {const: "x"},
a: {type: "string"},
},
required: ["a"],
},
{
properties: {
foo: {enum: ["y", "z"]},
b: {type: "string"},
},
required: ["b"],
},
],
}
```

_valid_: `{foo: "x", a: "any"}`, `{foo: "y", b: "any"}`, `{foo: "z", b: "any"}`

_invalid_:

- `{}`, `{foo: 1}` - discriminator tag must be string
- `{foo: "bar"}` - discriminator tag value must be in oneOf subschema
- `{foo: "x", b: "b"}`, `{foo: "y", a: "a"}` - invalid object

From the perspective of validation result `discriminator` is defined as no-op (that is, removing discriminator will not change the validity of the data), but errors reported in case of invalid data will be different.

There are following requirements and limitations of using `discriminator` keyword:
- `mapping` in discriminator object is not supported.
- [oneOf](#oneof) keyword must be present in the same schema.
- discriminator property should be [requried](#required) either on the top level, as in the example, or in all `oneOf` subschemas.
- each `oneOf` subschema must have [properties](#properties) keyword with discriminator property.
- schema for discriminator property in each `oneOf` subschema must be [const](#const) or [enum](#enum), with unique values across all subschemas.

Not meeting any of these requirements would fail schema compilation.

## Keywords for all types

### `enum`
Expand Down
5 changes: 5 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const defaultOptions = {
$data: false, // *
allErrors: false,
verbose: false,
discriminator: false, // *
$comment: false, // *
formats: {},
keywords: {},
Expand Down Expand Up @@ -123,6 +124,10 @@ Check all rules collecting all errors. Default is to return after the first erro

Include the reference to the part of the schema (`schema` and `parentSchema`) and validated data in errors (false by default).

### discriminator

Support [discriminator keyword](./json-schema.md#discriminator) from [OpenAPI specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md).

### $comment

Log or pass the value of `$comment` keyword to a function.
Expand Down
2 changes: 2 additions & 0 deletions lib/2019.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import draft7Vocabularies from "./vocabularies/draft7"
import dynamicVocabulary from "./vocabularies/dynamic"
import nextVocabulary from "./vocabularies/next"
import unevaluatedVocabulary from "./vocabularies/unevaluated"
import discriminator from "./vocabularies/discriminator"
import addMetaSchema2019 from "./refs/json-schema-2019-09"

const META_SCHEMA_ID = "https://json-schema.org/draft/2019-09/schema"
Expand All @@ -25,6 +26,7 @@ class Ajv2019 extends AjvCore {
draft7Vocabularies.forEach((v) => this.addVocabulary(v))
this.addVocabulary(nextVocabulary)
this.addVocabulary(unevaluatedVocabulary)
if (this.opts.discriminator) this.addKeyword(discriminator)
}

_addDefaultMetaSchema(): void {
Expand Down
2 changes: 2 additions & 0 deletions lib/ajv.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {AnySchemaObject} from "./types"
import AjvCore from "./core"
import draft7Vocabularies from "./vocabularies/draft7"
import discriminator from "./vocabularies/discriminator"
import * as draft7MetaSchema from "./refs/json-schema-draft-07.json"

const META_SUPPORT_DATA = ["/properties"]
Expand All @@ -11,6 +12,7 @@ class Ajv extends AjvCore {
_addVocabularies(): void {
super._addVocabularies()
draft7Vocabularies.forEach((v) => this.addVocabulary(v))
if (this.opts.discriminator) this.addKeyword(discriminator)
}

_addDefaultMetaSchema(): void {
Expand Down
9 changes: 8 additions & 1 deletion lib/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export interface CurrentOptions {
$data?: boolean
allErrors?: boolean
verbose?: boolean
discriminator?: boolean
$comment?:
| true
| ((comment: string, schemaPath?: string, rootSchema?: AnySchemaObject) => unknown)
Expand Down Expand Up @@ -174,6 +175,9 @@ interface RemovedOptions {
strictDefaults?: boolean
strictKeywords?: boolean
strictNumbers?: boolean
strictTypes?: boolean
strictTuples?: boolean
strictRequired?: boolean
uniqueItems?: boolean
unknownFormats?: true | string[] | "ignore"
cache?: any
Expand All @@ -194,10 +198,13 @@ const removedOptions: OptionsInfo<RemovedOptions> = {
missingRefs: "Pass empty schema with $id that should be ignored to ajv.addSchema.",
processCode: "Use option `code: {process: (code, schemaEnv: object) => string}`",
sourceCode: "Use option `code: {source: true}`",
schemaId: "JSON Schema draft-04 is not supported in Ajv v7.",
schemaId: "JSON Schema draft-04 is not supported in Ajv v7/8.",
strictDefaults: "It is default now, see option `strict`.",
strictKeywords: "It is default now, see option `strict`.",
strictNumbers: "It is default now, see option `strict`.",
strictTypes: "Use option `strict.types`.",
strictTuples: "Use option `strict.tuples`.",
strictRequired: "Use option `strict.required`.",
uniqueItems: '"uniqueItems" keyword is always validated.',
unknownFormats: "Disable strict mode or pass `true` to `ajv.addFormat` (or `formats` option).",
cache: "Map is used as cache, schema object as key.",
Expand Down
3 changes: 2 additions & 1 deletion lib/vocabularies/applicator/oneOf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ const def: CodeKeywordDefinition = {
trackErrors: true,
error,
code(cxt: KeywordCxt) {
const {gen, schema, it} = cxt
const {gen, schema, parentSchema, it} = cxt
/* istanbul ignore if */
if (!Array.isArray(schema)) throw new Error("ajv implementation error")
if (it.opts.discriminator && parentSchema.discriminator) return
const schArr: AnySchema[] = schema
const valid = gen.let("valid", false)
const passing = gen.let("passing", null)
Expand Down
102 changes: 102 additions & 0 deletions lib/vocabularies/discriminator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type {CodeKeywordDefinition, AnySchemaObject, KeywordErrorDefinition} from "../../types"
import type {KeywordCxt} from "../../compile/validate"
import {_, getProperty, Name} from "../../compile/codegen"
import {DiscrError, DiscrErrorObj} from "../discriminator/types"

export type DiscriminatorError = DiscrErrorObj<DiscrError.Tag> | DiscrErrorObj<DiscrError.Mapping>

const error: KeywordErrorDefinition = {
message: ({params: {discrError, tagName}}) =>
discrError === DiscrError.Tag
? `tag "${tagName}" must be string`
: `value of tag "${tagName}" must be in oneOf`,
params: ({params: {discrError, tag, tagName}}) =>
_`{error: ${discrError}, tag: ${tagName}, tagValue: ${tag}}`,
}

const def: CodeKeywordDefinition = {
keyword: "discriminator",
type: "object",
schemaType: "object",
error,
code(cxt: KeywordCxt) {
const {gen, data, schema, parentSchema, it} = cxt
const {oneOf} = parentSchema
if (!it.opts.discriminator) {
throw new Error("discriminator: requires discriminator option")
}
const tagName = schema.propertyName
if (typeof tagName != "string") throw new Error("discriminator: requires propertyName")
if (schema.mapping) throw new Error("discriminator: mapping is not supported")
if (!oneOf) throw new Error("discriminator: requires oneOf keyword")
const valid = gen.let("valid", false)
const tag = gen.const("tag", _`${data}${getProperty(tagName)}`)
gen.if(
_`typeof ${tag} == "string"`,
() => validateMapping(),
() => cxt.error(false, {discrError: DiscrError.Tag, tag, tagName})
)
cxt.ok(valid)

function validateMapping(): void {
const mapping = getMapping()
gen.if(false)
for (const tagValue in mapping) {
gen.elseIf(_`${tag} === ${tagValue}`)
gen.assign(valid, applyTagSchema(mapping[tagValue]))
}
gen.else()
cxt.error(false, {discrError: DiscrError.Mapping, tag, tagName})
gen.endIf()
}

function applyTagSchema(schemaProp?: number): Name {
const _valid = gen.name("valid")
const schCxt = cxt.subschema({keyword: "oneOf", schemaProp}, _valid)
cxt.mergeEvaluated(schCxt, Name)
return _valid
}

function getMapping(): {[T in string]?: number} {
const oneOfMapping: {[T in string]?: number} = {}
const topRequired = hasRequired(parentSchema)
let tagRequired = true
for (let i = 0; i < oneOf.length; i++) {
const sch = oneOf[i]
const propSch = sch.properties?.[tagName]
if (typeof propSch != "object") {
throw new Error(`discriminator: oneOf schemas must have "properties/${tagName}"`)
}
tagRequired = tagRequired && (topRequired || hasRequired(sch))
addMappings(propSch, i)
}
if (!tagRequired) throw new Error(`discriminator: "${tagName}" must be required`)
return oneOfMapping

function hasRequired({required}: AnySchemaObject): boolean {
return Array.isArray(required) && required.includes(tagName)
}

function addMappings(sch: AnySchemaObject, i: number): void {
if (sch.const) {
addMapping(sch.const, i)
} else if (sch.enum) {
for (const tagValue of sch.enum) {
addMapping(tagValue, i)
}
} else {
throw new Error(`discriminator: "properties/${tagName}" must have "const" or "enum"`)
}
}

function addMapping(tagValue: unknown, i: number): void {
if (typeof tagValue != "string" || tagValue in oneOfMapping) {
throw new Error(`discriminator: "${tagName}" values must be unique strings`)
}
oneOfMapping[tagValue] = i
}
}
},
}

export default def
12 changes: 12 additions & 0 deletions lib/vocabularies/discriminator/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type {ErrorObject} from "../../types"

export enum DiscrError {
Tag = "tag",
Mapping = "mapping",
}

export type DiscrErrorObj<E extends DiscrError> = ErrorObject<
"discriminator",
{error: E; tag: string; tagValue: unknown},
string
>
Loading

0 comments on commit 164a991

Please sign in to comment.