Skip to content

Commit 188413c

Browse files
authored
Switch validating to use unevaluatedProperties (#412)
* Re-add `_all` as a valid input for nodes.info metrics Signed-off-by: Thomas Farr <[email protected]> * Correct nodes.info ResponseBase Signed-off-by: Thomas Farr <[email protected]> * Switch to unevaluatedProperties for validating unknown properties Signed-off-by: Thomas Farr <[email protected]> * Spec fixes Signed-off-by: Thomas Farr <[email protected]> * Extra test case Signed-off-by: Thomas Farr <[email protected]> --------- Signed-off-by: Thomas Farr <[email protected]>
1 parent d5ca873 commit 188413c

File tree

16 files changed

+122
-69
lines changed

16 files changed

+122
-69
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,5 @@ _site/
1717
build/
1818

1919
# coverage output
20-
coverage/
20+
/coverage/
2121

spec/namespaces/cluster.yaml

+4-8
Original file line numberDiff line numberDiff line change
@@ -514,12 +514,10 @@ components:
514514
properties:
515515
persistent:
516516
type: object
517-
additionalProperties:
518-
type: object
517+
additionalProperties: { }
519518
transient:
520519
type: object
521-
additionalProperties:
522-
type: object
520+
additionalProperties: { }
523521
description: The settings to be updated. Can be either `transient` or `persistent` (survives cluster restart).
524522
required: true
525523
cluster.reroute:
@@ -702,12 +700,10 @@ components:
702700
type: boolean
703701
persistent:
704702
type: object
705-
additionalProperties:
706-
type: object
703+
additionalProperties: { }
707704
transient:
708705
type: object
709-
additionalProperties:
710-
type: object
706+
additionalProperties: { }
711707
required:
712708
- acknowledged
713709
- persistent

spec/namespaces/indices.yaml

+3-4
Original file line numberDiff line numberDiff line change
@@ -1878,7 +1878,7 @@ components:
18781878
template:
18791879
$ref: '../schemas/indices.put_index_template.yaml#/components/schemas/IndexTemplateMapping'
18801880
data_stream:
1881-
$ref: '../schemas/indices._common.yaml#/components/schemas/DataStreamVisibility'
1881+
$ref: '../schemas/indices._common.yaml#/components/schemas/IndexTemplateDataStreamConfiguration'
18821882
priority:
18831883
description: |-
18841884
Priority to determine index template precedence when a new data stream or index is created.
@@ -2060,7 +2060,7 @@ components:
20602060
template:
20612061
$ref: '../schemas/indices.put_index_template.yaml#/components/schemas/IndexTemplateMapping'
20622062
data_stream:
2063-
$ref: '../schemas/indices._common.yaml#/components/schemas/DataStreamVisibility'
2063+
$ref: '../schemas/indices._common.yaml#/components/schemas/IndexTemplateDataStreamConfiguration'
20642064
priority:
20652065
description: |-
20662066
Priority to determine index template precedence when a new data stream or index is created.
@@ -2092,8 +2092,7 @@ components:
20922092
settings:
20932093
description: Configuration options for the target index.
20942094
type: object
2095-
additionalProperties:
2096-
type: object
2095+
additionalProperties: { }
20972096
description: The configuration for the target index (`settings` and `aliases`)
20982097
indices.update_aliases:
20992098
content:

spec/schemas/_common.query_dsl.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ components:
7373
additionalProperties:
7474
anyOf:
7575
- $ref: '#/components/schemas/MatchQuery'
76-
- true
76+
- { }
7777
minProperties: 1
7878
maxProperties: 1
7979
match_all:

spec/schemas/cat.health.yaml

+3
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,6 @@ components:
4949
active_shards_percent:
5050
description: active number of shards in percent
5151
type: string
52+
discovered_cluster_manager:
53+
description: cluster manager is discovered or not
54+
type: string

spec/schemas/indices._common.yaml

+3-5
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ components:
137137
$ref: '#/components/schemas/IndexingPressure'
138138
store:
139139
$ref: '#/components/schemas/Storage'
140+
additionalProperties: { }
140141
description: The index settings to be updated
141142
SoftDeletes:
142143
type: object
@@ -980,6 +981,8 @@ components:
980981
allow_custom_routing:
981982
description: If true, the data stream supports custom routing.
982983
type: boolean
984+
timestamp_field:
985+
$ref: '#/components/schemas/DataStreamTimestampField'
983986
TemplateMapping:
984987
type: object
985988
properties:
@@ -1007,8 +1010,3 @@ components:
10071010
- mappings
10081011
- order
10091012
- settings
1010-
DataStreamVisibility:
1011-
type: object
1012-
properties:
1013-
hidden:
1014-
type: boolean

spec/schemas/nodes.info.yaml

+11-15
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ components:
2121
- aggregations
2222
- indices
2323
- search_pipelines
24+
- _all
2425
ResponseBase:
2526
allOf:
27+
- $ref: 'nodes._common.yaml#/components/schemas/NodesResponseBase'
2628
- type: object
2729
properties:
2830
cluster_name:
@@ -31,12 +33,9 @@ components:
3133
type: object
3234
additionalProperties:
3335
$ref: '#/components/schemas/NodeInfo'
34-
_nodes:
35-
$ref: 'nodes._common.yaml#/components/schemas/NodesResponseBase'
3636
required:
3737
- cluster_name
3838
- nodes
39-
- _nodes
4039
NodeInfo:
4140
type: object
4241
properties:
@@ -328,7 +327,7 @@ components:
328327
repositories:
329328
$ref: '#/components/schemas/NodeInfoRepositories'
330329
discovery:
331-
$ref: '#/components/schemas/NodeInfoDiscover'
330+
$ref: '#/components/schemas/NodeInfoDiscovery'
332331
action:
333332
$ref: '#/components/schemas/NodeInfoAction'
334333
client:
@@ -365,6 +364,8 @@ components:
365364
$ref: 'indices._common.yaml#/components/schemas/IndexRouting'
366365
election:
367366
$ref: '#/components/schemas/NodeInfoSettingsClusterElection'
367+
initial_cluster_manager_nodes:
368+
type: string
368369
initial_master_nodes:
369370
type: string
370371
deprecation_indexing:
@@ -438,9 +439,11 @@ components:
438439
type: string
439440
required:
440441
- shard_indexing_pressure_enabled
441-
NodeInfoDiscover:
442+
NodeInfoDiscovery:
442443
type: object
443444
properties:
445+
type:
446+
type: string
444447
seed_hosts:
445448
type: string
446449
NodeInfoAction:
@@ -694,18 +697,11 @@ components:
694697
response_processors:
695698
type: array
696699
items:
697-
$ref: '#/components/schemas/NodeInfoIngestProcessor2'
700+
$ref: '#/components/schemas/NodeInfoIngestProcessor'
698701
request_processors:
699702
type: array
700703
items:
701-
$ref: '#/components/schemas/NodeInfoIngestProcessor2'
704+
$ref: '#/components/schemas/NodeInfoIngestProcessor'
702705
required:
703706
- response_processors
704-
- request_processors
705-
NodeInfoIngestProcessor2:
706-
type: object
707-
properties:
708-
type:
709-
type: string
710-
required:
711-
- type
707+
- request_processors
File renamed without changes.

tools/src/_utils/index.ts

+35-2
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@
77
* compatible open source license.
88
*/
99

10-
import { type OpenAPIV3 } from 'openapi-types'
10+
import { OpenAPIV3 } from 'openapi-types'
1111
import { type ValidationError } from 'types'
12+
import _ from "lodash";
13+
import { to_json } from "../helpers";
14+
15+
export const HTTP_METHODS: OpenAPIV3.HttpMethods[] = Object.values(OpenAPIV3.HttpMethods)
16+
export type SchemaObjectType = OpenAPIV3.ArraySchemaObjectType | OpenAPIV3.NonArraySchemaObjectType
17+
export const SCHEMA_OBJECT_TYPES: SchemaObjectType[] = ['array', 'boolean', 'object', 'number', 'string', 'integer']
1218

1319
export function is_ref<O extends object> (o: MaybeRef<O>): o is OpenAPIV3.ReferenceObject {
14-
return typeof (o) === 'object' && '$ref' in o
20+
return o != null && typeof o === 'object' && '$ref' in o
1521
}
1622

1723
export function is_array_schema (schema: OpenAPIV3.SchemaObject): schema is OpenAPIV3.ArraySchemaObject {
@@ -25,6 +31,33 @@ export function is_primitive_schema (schema: OpenAPIV3.SchemaObject): boolean {
2531
schema.type === 'string'
2632
}
2733

34+
export function determine_possible_schema_types (doc: OpenAPIV3.Document, schema: MaybeRef<OpenAPIV3.SchemaObject>): SchemaObjectType[] {
35+
while (is_ref(schema)) {
36+
const key = schema.$ref.split('/').pop()
37+
if (key === undefined) throw new Error(`Invalid $ref: '${schema.$ref}'`)
38+
const resolved = doc.components?.schemas?.[key]
39+
if (resolved === undefined) throw new Error(`Invalid $ref: '${schema.$ref}'`)
40+
schema = resolved
41+
}
42+
43+
if (schema?.type !== undefined) return [schema.type]
44+
45+
const collect_all = (schemas: MaybeRef<OpenAPIV3.SchemaObject>[]): SchemaObjectType[] =>
46+
_.uniq(schemas.flatMap(s => determine_possible_schema_types(doc, s)))
47+
48+
if (schema?.allOf !== undefined) {
49+
const types = collect_all(schema.allOf)
50+
if (types.length > 1) throw new Error(`allOf schema should resolve to a single type, but was: ${types.join(", ")}`)
51+
return types
52+
}
53+
if (schema?.anyOf !== undefined) return collect_all(schema.anyOf)
54+
if (schema?.oneOf !== undefined) return collect_all(schema.oneOf)
55+
56+
if (schema == null || Object.keys(schema).filter(k => k !== 'description').length == 0) return SCHEMA_OBJECT_TYPES
57+
58+
throw new Error(`Unable to determine possible types of schema: ${to_json(schema)}`)
59+
}
60+
2861
export class SpecificationContext {
2962
private readonly _file: string
3063
private readonly _location: string[]

tools/src/coverage/CoverageCalculator.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
*/
99

1010
import { type OpenAPIV3 } from 'openapi-types'
11-
import { HTTP_METHODS, read_yaml, write_json } from '../helpers'
11+
import { read_yaml, write_json } from '../helpers'
12+
import { HTTP_METHODS } from "../_utils";
1213

1314
export default class CoverageCalculator {
1415
private readonly _cluster_spec: OpenAPIV3.Document

tools/src/helpers.ts

-3
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@ import fs from 'fs'
1111
import path from 'path'
1212
import YAML from 'yaml'
1313
import _ from 'lodash'
14-
import { OpenAPIV3 } from 'openapi-types'
15-
16-
export const HTTP_METHODS: OpenAPIV3.HttpMethods[] = Object.values(OpenAPIV3.HttpMethods)
1714

1815
export function resolve_ref (ref: string, root: Record<string, any>): Record<string, any> | undefined {
1916
const paths = ref.replace('#/', '').split('/')

tools/src/tester/MergedOpenApiSpec.ts

+23-9
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
* compatible open source license.
88
*/
99

10-
import { type OpenAPIV3 } from 'openapi-types'
10+
import { OpenAPIV3 } from 'openapi-types'
1111
import { Logger } from '../Logger'
12-
import { SpecificationContext } from '../_utils';
12+
import { determine_possible_schema_types, SpecificationContext } from '../_utils';
1313
import { SchemaVisitor } from '../_utils/SpecificationVisitor';
1414
import OpenApiMerger from '../merger/OpenApiMerger';
1515

@@ -38,13 +38,27 @@ export default class MergedOpenApiSpec {
3838
}
3939

4040
private inject_additional_properties(ctx: SpecificationContext, spec: OpenAPIV3.Document): void {
41-
const visitor = new SchemaVisitor((_ctx, schema: any) => {
42-
if (schema.required !== undefined && schema.properties !== undefined && schema.additionalProperties === undefined) {
43-
// causes any undeclared field in the response to produce an error
44-
schema.additionalProperties = {
45-
not: true,
46-
errorMessage: "property is not defined in the spec"
47-
}
41+
const visitor = new SchemaVisitor((ctx, schema) => {
42+
// If already has unevaluatedProperties then don't set
43+
if ((schema as any).unevaluatedProperties !== undefined) return;
44+
45+
// Don't apply `unevaluatedProperties` to component schemas as we will apply it at usage points (i.e. $ref's)
46+
// Also don't apply to sub-schemas of an allOf as it will conflict with other sub-schemas as we'll apply it to the upper level
47+
if (ctx.parent().location === '#/components/schemas' || ctx.parent().key === 'allOf') return;
48+
49+
const types = determine_possible_schema_types(spec, schema)
50+
51+
// Don't apply to multi-type or non-object schemas
52+
if (types.length > 1 || types[0] !== 'object') return;
53+
54+
// Don't apply to basic { type: object } schemas
55+
if (Object.keys(schema).filter(k => k !== 'type' && k !== 'description').length === 0) return;
56+
57+
(schema as any).type = 'object';
58+
// causes any undeclared field in the response to produce an error
59+
(schema as any).unevaluatedProperties = {
60+
not: true,
61+
errorMessage: "property is not defined in the spec"
4862
}
4963
})
5064

tools/src/tester/ResultLogger.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -91,21 +91,21 @@ export class ConsoleResultLogger implements ResultLogger {
9191
#log_evaluation (evaluation: Evaluation, title: string, prefix: number = 0): void {
9292
const result = ansi.padding(this.#result(evaluation.result), 0, prefix)
9393

94-
var message = evaluation.message
95-
96-
if (message !== undefined && message?.length > 128 && !this._verbose) {
97-
const message_part = message.split(',')[0]
98-
message = message_part === message ? message : message_part + ', ...'
99-
}
94+
const message = this.#maybe_shorten_error_message(evaluation.message);
10095

10196
if (message !== undefined) {
102-
message = ansi.gray(`(${message})`)
103-
console.log(`${result} ${title} ${message}`)
97+
console.log(`${result} ${title} ${ansi.gray(`(${message})`)}`)
10498
} else {
10599
console.log(`${result} ${title}`)
106100
}
107101
}
108102

103+
#maybe_shorten_error_message(message: string | undefined): string | undefined {
104+
if (message === undefined || message.length <= 128 || this._verbose) return message
105+
const part = message.split(',')[0]
106+
return part + (part !== message ? ', ...' : '')
107+
}
108+
109109
#result (r: Result): string {
110110
const text = ansi.padding(r, 7)
111111
switch (r) {

tools/src/tester/SchemaValidator.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ export default class SchemaValidator {
3030
this.logger = logger
3131
const component_schemas = spec.components?.schemas ?? {}
3232
const reference_schemas = _.mapKeys(component_schemas, (_, name) => `#/components/schemas/${name}`)
33-
this.json_validator = new JsonSchemaValidator(undefined, { reference_schemas, additional_keywords: ADDITIONAL_KEYWORDS, ajv_errors_opts: { singleError: true } })
33+
this.json_validator = new JsonSchemaValidator(undefined, {
34+
reference_schemas,
35+
additional_keywords: ADDITIONAL_KEYWORDS,
36+
ajv_errors_opts: { singleError: true },
37+
errors_text_opts: { separator: ', ' }
38+
})
3439
}
3540

3641
validate (schema: OpenAPIV3.SchemaObject, data: any): Evaluation {

tools/tests/tester/MergedOpenApiSpec.test.ts

+14-9
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import { Logger } from 'Logger'
1111
import MergedOpenApiSpec from "tester/MergedOpenApiSpec"
1212

13-
describe('additionalProperties', () => {
13+
describe('unevaluatedProperties', () => {
1414
const spec = new MergedOpenApiSpec('tools/tests/tester/fixtures/specs/complete', new Logger())
1515
const responses: any = spec.spec().components?.responses
1616

@@ -20,21 +20,26 @@ describe('additionalProperties', () => {
2020

2121
test('is added with required fields', () => {
2222
const schema = responses['info@200'].content['application/json'].schema
23-
expect(schema.additionalProperties).toEqual({ not: true, errorMessage: 'property is not defined in the spec' })
23+
expect(schema.unevaluatedProperties).toEqual({ not: true, errorMessage: 'property is not defined in the spec' })
24+
})
25+
26+
test('is added when no required fields', () => {
27+
const schema = responses['info@500'].content['application/json'].schema
28+
expect(schema.unevaluatedProperties).toEqual({ not: true, errorMessage: 'property is not defined in the spec' })
29+
})
30+
31+
test('is not added to empty object schema', () => {
32+
const schema = responses['info@503'].content['application/json'].schema
33+
expect(schema.unevaluatedProperties).toBeUndefined()
2434
})
2535

2636
test('is not added when true', () => {
2737
const schema = responses['info@201'].content['application/json'].schema
28-
expect(schema.additionalProperties).toEqual(true)
38+
expect(schema.unevaluatedProperties).toEqual(true)
2939
})
3040

3141
test('is not added when object', () => {
3242
const schema = responses['info@404'].content['application/json'].schema
33-
expect(schema.additionalProperties).toEqual({ type: 'object' })
34-
})
35-
36-
test('is not added unless required is present', () => {
37-
const schema = responses['info@500'].content['application/json'].schema
38-
expect(schema.additionalProperties).toBeUndefined()
43+
expect(schema.unevaluatedProperties).toEqual({ type: 'object' })
3944
})
4045
})

0 commit comments

Comments
 (0)