Skip to content

Commit 1d1a8fe

Browse files
authored
discriminator keyword, closes #1119 (#1494)
* discriminator keyword, #1119 (WIP) * OpenAPI discriminator, tests, #1119 * docs: discriminator
1 parent 9a89eb4 commit 1d1a8fe

File tree

13 files changed

+444
-18
lines changed

13 files changed

+444
-18
lines changed

docs/guide/modifying-data.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,10 @@ If `removeAdditional` option in the example above were `"all"` then both `additi
7373
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).
7474

7575
::: warning Unexpected results when using removeAdditional with anyOf/oneOf
76-
If you use `removeAdditional` option with `additionalProperties` keyword inside `anyOf`/`oneOf` keywords your validation can fail with this schema
76+
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).
7777
:::
7878

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

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

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

123+
For discriminated unions you could schemas with [discriminator](../json-schema.md#discriminator) keyword (it requires `discriminator: true` option):
124+
125+
```javascript
126+
{
127+
type: "object",
128+
discriminator: {propertyName: "tag"},
129+
required: ["tag"],
130+
oneOf: [
131+
{
132+
properties: {
133+
tag: {const: "foo"},
134+
foo: {type: "string"}
135+
},
136+
required: ["foo"],
137+
additionalProperties: false
138+
},
139+
{
140+
properties: {
141+
tag: {const: "bar"},
142+
bar: {type: "integer"}
143+
},
144+
required: ["bar"],
145+
additionalProperties: false
146+
}
147+
]
148+
}
149+
```
150+
151+
With this schema, only one subschema in `oneOf` will be evaluated, so `removeAdditional` option will work as expected.
152+
153+
See [discriminator](../json-schema.md#discriminator) keyword.
154+
123155
## Assigning defaults
124156

125157
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.
@@ -180,6 +212,10 @@ The strict mode option can change the behaviour for these unsupported defaults (
180212

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

215+
::: tip Default with discriminator keyword
216+
Defaults will be assigned in schemas inside `oneOf` in case [discriminator](../json-schema.md#discriminator) keyword is used.
217+
:::
218+
183219
## Coercing data types
184220

185221
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.

docs/json-schema.md

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,15 @@ v7 added support for all new keywords in draft-2019-09:
1919

2020
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`.
2121

22-
## `type`
22+
## OpenAPI support
23+
24+
Ajv supports these additional [OpenAPI specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md) keywords:
25+
- [nullable](#nullable) - to avoid using `type` keyword with array of types.
26+
- [discriminator](#discriminator) - to optimize validation and error reporting of tagged unions
27+
28+
## JSON data type
29+
30+
### `type`
2331

2432
`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).
2533

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

5260
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.
5361

62+
### nullable <Badge text="OpenAPI" />
63+
64+
This keyword can be used to allow `null` value in addition to the defined `type`.
65+
66+
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))
67+
68+
```json
69+
{
70+
"type": "string",
71+
"nullable": true
72+
}
73+
```
74+
75+
and
76+
77+
```json
78+
{
79+
"type": ["string", "null"]
80+
}
81+
```
82+
83+
::: warning nullable does not extend enum and const
84+
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`)
85+
86+
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.
87+
:::
88+
5489
## Keywords for numbers
5590

5691
### `maximum` / `minimum` and `exclusiveMaximum` / `exclusiveMinimum`
@@ -674,6 +709,65 @@ _invalid_:
674709

675710
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.
676711

712+
### discriminator <Badge text="NEW: OpenAPI" />
713+
714+
Ajv has a limited support for `discriminator` keyword: to optimize validation, error handling, and [modifying data](./guide/modifying-data.md) with [oneOf](#oneof) keyword.
715+
716+
Its value should be an object with a property `propertyName` - the name of the property used to discriminate between union members.
717+
718+
When using discriminator keyword only one subschema in `oneOf` will be used, determined by the value of discriminator property.
719+
720+
::: warning Use option discriminator
721+
To use `discriminator` keyword you have to use option `discriminator: true` with Ajv constructor - it is not enabled by default.
722+
:::
723+
724+
**Example**
725+
726+
_schema_:
727+
728+
```javascript
729+
{
730+
type: "object",
731+
discriminator: {propertyName: "foo"},
732+
required: ["foo"],
733+
oneOf: [
734+
{
735+
properties: {
736+
foo: {const: "x"},
737+
a: {type: "string"},
738+
},
739+
required: ["a"],
740+
},
741+
{
742+
properties: {
743+
foo: {enum: ["y", "z"]},
744+
b: {type: "string"},
745+
},
746+
required: ["b"],
747+
},
748+
],
749+
}
750+
```
751+
752+
_valid_: `{foo: "x", a: "any"}`, `{foo: "y", b: "any"}`, `{foo: "z", b: "any"}`
753+
754+
_invalid_:
755+
756+
- `{}`, `{foo: 1}` - discriminator tag must be string
757+
- `{foo: "bar"}` - discriminator tag value must be in oneOf subschema
758+
- `{foo: "x", b: "b"}`, `{foo: "y", a: "a"}` - invalid object
759+
760+
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.
761+
762+
There are following requirements and limitations of using `discriminator` keyword:
763+
- `mapping` in discriminator object is not supported.
764+
- [oneOf](#oneof) keyword must be present in the same schema.
765+
- discriminator property should be [requried](#required) either on the top level, as in the example, or in all `oneOf` subschemas.
766+
- each `oneOf` subschema must have [properties](#properties) keyword with discriminator property.
767+
- schema for discriminator property in each `oneOf` subschema must be [const](#const) or [enum](#enum), with unique values across all subschemas.
768+
769+
Not meeting any of these requirements would fail schema compilation.
770+
677771
## Keywords for all types
678772

679773
### `enum`

docs/options.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const defaultOptions = {
3838
$data: false, // *
3939
allErrors: false,
4040
verbose: false,
41+
discriminator: false, // *
4142
$comment: false, // *
4243
formats: {},
4344
keywords: {},
@@ -123,6 +124,10 @@ Check all rules collecting all errors. Default is to return after the first erro
123124

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

127+
### discriminator
128+
129+
Support [discriminator keyword](./json-schema.md#discriminator) from [OpenAPI specification](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md).
130+
126131
### $comment
127132

128133
Log or pass the value of `$comment` keyword to a function.

lib/2019.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import draft7Vocabularies from "./vocabularies/draft7"
55
import dynamicVocabulary from "./vocabularies/dynamic"
66
import nextVocabulary from "./vocabularies/next"
77
import unevaluatedVocabulary from "./vocabularies/unevaluated"
8+
import discriminator from "./vocabularies/discriminator"
89
import addMetaSchema2019 from "./refs/json-schema-2019-09"
910

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

3032
_addDefaultMetaSchema(): void {

lib/ajv.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {AnySchemaObject} from "./types"
22
import AjvCore from "./core"
33
import draft7Vocabularies from "./vocabularies/draft7"
4+
import discriminator from "./vocabularies/discriminator"
45
import * as draft7MetaSchema from "./refs/json-schema-draft-07.json"
56

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

1618
_addDefaultMetaSchema(): void {

lib/core.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export interface CurrentOptions {
9090
$data?: boolean
9191
allErrors?: boolean
9292
verbose?: boolean
93+
discriminator?: boolean
9394
$comment?:
9495
| true
9596
| ((comment: string, schemaPath?: string, rootSchema?: AnySchemaObject) => unknown)
@@ -174,6 +175,9 @@ interface RemovedOptions {
174175
strictDefaults?: boolean
175176
strictKeywords?: boolean
176177
strictNumbers?: boolean
178+
strictTypes?: boolean
179+
strictTuples?: boolean
180+
strictRequired?: boolean
177181
uniqueItems?: boolean
178182
unknownFormats?: true | string[] | "ignore"
179183
cache?: any
@@ -194,10 +198,13 @@ const removedOptions: OptionsInfo<RemovedOptions> = {
194198
missingRefs: "Pass empty schema with $id that should be ignored to ajv.addSchema.",
195199
processCode: "Use option `code: {process: (code, schemaEnv: object) => string}`",
196200
sourceCode: "Use option `code: {source: true}`",
197-
schemaId: "JSON Schema draft-04 is not supported in Ajv v7.",
201+
schemaId: "JSON Schema draft-04 is not supported in Ajv v7/8.",
198202
strictDefaults: "It is default now, see option `strict`.",
199203
strictKeywords: "It is default now, see option `strict`.",
200204
strictNumbers: "It is default now, see option `strict`.",
205+
strictTypes: "Use option `strict.types`.",
206+
strictTuples: "Use option `strict.tuples`.",
207+
strictRequired: "Use option `strict.required`.",
201208
uniqueItems: '"uniqueItems" keyword is always validated.',
202209
unknownFormats: "Disable strict mode or pass `true` to `ajv.addFormat` (or `formats` option).",
203210
cache: "Map is used as cache, schema object as key.",

lib/vocabularies/applicator/oneOf.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ const def: CodeKeywordDefinition = {
2626
trackErrors: true,
2727
error,
2828
code(cxt: KeywordCxt) {
29-
const {gen, schema, it} = cxt
29+
const {gen, schema, parentSchema, it} = cxt
3030
/* istanbul ignore if */
3131
if (!Array.isArray(schema)) throw new Error("ajv implementation error")
32+
if (it.opts.discriminator && parentSchema.discriminator) return
3233
const schArr: AnySchema[] = schema
3334
const valid = gen.let("valid", false)
3435
const passing = gen.let("passing", null)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type {CodeKeywordDefinition, AnySchemaObject, KeywordErrorDefinition} from "../../types"
2+
import type {KeywordCxt} from "../../compile/validate"
3+
import {_, getProperty, Name} from "../../compile/codegen"
4+
import {DiscrError, DiscrErrorObj} from "../discriminator/types"
5+
6+
export type DiscriminatorError = DiscrErrorObj<DiscrError.Tag> | DiscrErrorObj<DiscrError.Mapping>
7+
8+
const error: KeywordErrorDefinition = {
9+
message: ({params: {discrError, tagName}}) =>
10+
discrError === DiscrError.Tag
11+
? `tag "${tagName}" must be string`
12+
: `value of tag "${tagName}" must be in oneOf`,
13+
params: ({params: {discrError, tag, tagName}}) =>
14+
_`{error: ${discrError}, tag: ${tagName}, tagValue: ${tag}}`,
15+
}
16+
17+
const def: CodeKeywordDefinition = {
18+
keyword: "discriminator",
19+
type: "object",
20+
schemaType: "object",
21+
error,
22+
code(cxt: KeywordCxt) {
23+
const {gen, data, schema, parentSchema, it} = cxt
24+
const {oneOf} = parentSchema
25+
if (!it.opts.discriminator) {
26+
throw new Error("discriminator: requires discriminator option")
27+
}
28+
const tagName = schema.propertyName
29+
if (typeof tagName != "string") throw new Error("discriminator: requires propertyName")
30+
if (schema.mapping) throw new Error("discriminator: mapping is not supported")
31+
if (!oneOf) throw new Error("discriminator: requires oneOf keyword")
32+
const valid = gen.let("valid", false)
33+
const tag = gen.const("tag", _`${data}${getProperty(tagName)}`)
34+
gen.if(
35+
_`typeof ${tag} == "string"`,
36+
() => validateMapping(),
37+
() => cxt.error(false, {discrError: DiscrError.Tag, tag, tagName})
38+
)
39+
cxt.ok(valid)
40+
41+
function validateMapping(): void {
42+
const mapping = getMapping()
43+
gen.if(false)
44+
for (const tagValue in mapping) {
45+
gen.elseIf(_`${tag} === ${tagValue}`)
46+
gen.assign(valid, applyTagSchema(mapping[tagValue]))
47+
}
48+
gen.else()
49+
cxt.error(false, {discrError: DiscrError.Mapping, tag, tagName})
50+
gen.endIf()
51+
}
52+
53+
function applyTagSchema(schemaProp?: number): Name {
54+
const _valid = gen.name("valid")
55+
const schCxt = cxt.subschema({keyword: "oneOf", schemaProp}, _valid)
56+
cxt.mergeEvaluated(schCxt, Name)
57+
return _valid
58+
}
59+
60+
function getMapping(): {[T in string]?: number} {
61+
const oneOfMapping: {[T in string]?: number} = {}
62+
const topRequired = hasRequired(parentSchema)
63+
let tagRequired = true
64+
for (let i = 0; i < oneOf.length; i++) {
65+
const sch = oneOf[i]
66+
const propSch = sch.properties?.[tagName]
67+
if (typeof propSch != "object") {
68+
throw new Error(`discriminator: oneOf schemas must have "properties/${tagName}"`)
69+
}
70+
tagRequired = tagRequired && (topRequired || hasRequired(sch))
71+
addMappings(propSch, i)
72+
}
73+
if (!tagRequired) throw new Error(`discriminator: "${tagName}" must be required`)
74+
return oneOfMapping
75+
76+
function hasRequired({required}: AnySchemaObject): boolean {
77+
return Array.isArray(required) && required.includes(tagName)
78+
}
79+
80+
function addMappings(sch: AnySchemaObject, i: number): void {
81+
if (sch.const) {
82+
addMapping(sch.const, i)
83+
} else if (sch.enum) {
84+
for (const tagValue of sch.enum) {
85+
addMapping(tagValue, i)
86+
}
87+
} else {
88+
throw new Error(`discriminator: "properties/${tagName}" must have "const" or "enum"`)
89+
}
90+
}
91+
92+
function addMapping(tagValue: unknown, i: number): void {
93+
if (typeof tagValue != "string" || tagValue in oneOfMapping) {
94+
throw new Error(`discriminator: "${tagName}" values must be unique strings`)
95+
}
96+
oneOfMapping[tagValue] = i
97+
}
98+
}
99+
},
100+
}
101+
102+
export default def
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type {ErrorObject} from "../../types"
2+
3+
export enum DiscrError {
4+
Tag = "tag",
5+
Mapping = "mapping",
6+
}
7+
8+
export type DiscrErrorObj<E extends DiscrError> = ErrorObject<
9+
"discriminator",
10+
{error: E; tag: string; tagValue: unknown},
11+
string
12+
>

0 commit comments

Comments
 (0)