Skip to content

Commit

Permalink
Validate all schemas defined in the spec as valid JSON schemas
Browse files Browse the repository at this point in the history
- Updated default arg values for merge.ts
- Added silencing feature for merger tool
- Added SchemasValidator

Signed-off-by: Theo Truong <[email protected]>
  • Loading branch information
nhtruong committed May 13, 2024
1 parent deeb400 commit 91daa9c
Show file tree
Hide file tree
Showing 21 changed files with 357 additions and 13 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"merge": "ts-node tools/src/merger/merge.ts",
"lint:spec": "ts-node tools/src/linter/lint.ts",
"lint": "eslint .",
"lint--fix": "eslint . --fix",
"test": "jest"
},
"dependencies": {
Expand Down
5 changes: 2 additions & 3 deletions spec/schemas/security._common.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -422,15 +422,14 @@ components:
description:
type: string
description: Contains the description supplied by the user to describe the token.
required: true
service:
type: string
description: A name of the service if generating a token for that service.
required: false
duration:
type: string
description: Value in seconds.
required: optional
required:
- description

Ok:
type: object
Expand Down
100 changes: 100 additions & 0 deletions tools/src/linter/SchemasValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import AJV from 'ajv'
import addFormats from 'ajv-formats'
import OpenApiMerger from '../merger/OpenApiMerger'
import { type ValidationError } from '../types'

export default class SchemasValidator {
root_folder: string
spec: Record<string, any> = {}
ajv: AJV

constructor (root_folder: string) {
this.root_folder = root_folder
this.ajv = new AJV()
addFormats(this.ajv)
}

validate (): ValidationError[] {
this.spec = new OpenApiMerger(this.root_folder, true).merge().components as Record<string, any>
const named_schemas_errors = this.validate_named_schemas()
if (named_schemas_errors.length > 0) return named_schemas_errors
return [
...this.validate_parameter_schemas(),
...this.validate_request_body_schemas(),
...this.validate_response_schemas()
]
}

validate_named_schemas (): ValidationError[] {
return Object.entries(this.spec.schemas as Record<string, any>).map(([key, _schema]) => {
const schema = _schema as Record<string, any>
const error = this.validate_schema(schema)
if (error == null) {
this.ajv.addSchema(schema, `#/components/schemas/${key}`)
return null
}

const file = `schemas/${key.split(':')[0]}.yaml`
const location = `#/components/schemas/${key.split(':')[1]}`
return this.error(file, location, error)
}).filter((error) => error != null) as ValidationError[]
}

validate_parameter_schemas (): ValidationError[] {
return Object.entries(this.spec.parameters as Record<string, any>).map(([key, param]) => {
const error = this.validate_schema(param.schema as Record<string, any>)
if (error == null) return

const namespace = this.group_to_namespace(key.split('::')[0])
const file = namespace === '_global' ? '_global_parameters.yaml' : `namespaces/${namespace}.yaml`
const location = namespace === '_global' ? param.name as string : `#/components/parameters/${key}`
return this.error(file, location, error)
}).filter((error) => error != null) as ValidationError[]
}

validate_request_body_schemas (): ValidationError[] {
return Object.entries(this.spec.requestBodies as Record<string, any>).flatMap(([namespace, body]) => {
const file = `namespaces/${namespace}.yaml`
const location = `#/components/requestBodies/${namespace}`
return this.validate_content_schemas(file, location, body.content as Record<string, any>)
})
}

validate_response_schemas (): ValidationError[] {
return Object.entries(this.spec.responses as Record<string, any>).flatMap(([key, response]) => {
const namespace = this.group_to_namespace(key.split('@')[0])
const file = `namespaces/${namespace}.yaml`
const location = `#/components/responses/${key}`
const content = response.content as Record<string, any>
return this.validate_content_schemas(file, location, content)
})
}

validate_content_schemas (file: string, location: string, content: Record<string, any> | undefined): ValidationError[] {
return Object.entries(content ?? {}).map(([media_type, value]) => {
const schema = value.schema as Record<string, any>
const error = this.validate_schema(schema)
if (error != null) return this.error(file, `${location}/content/${media_type}`, error)
}).filter(e => e != null) as ValidationError[]
}

validate_schema (schema: Record<string, any>): Error | undefined {
if (schema == null || schema.$ref != null) return
try {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.ajv.validateSchema(schema, true)
} catch (error) {
return error as Error
}
}

group_to_namespace (group: string): string {
if (group === '_global') return '_global'
const [, namespace] = group.split('.').reverse()
return namespace ?? '_core'
}

error (file: string, location: string, error: Error): ValidationError {
return { file, location, message: error.message }
}
}
6 changes: 5 additions & 1 deletion tools/src/linter/SpecValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import SchemaRefsValidator from './SchemaRefsValidator'
import SupersededOperationsFile from './components/SupersededOperationsFile'
import InfoFile from './components/InfoFile'
import InlineObjectSchemaValidator from './InlineObjectSchemaValidator'
import SchemasValidator from './SchemasValidator'

export default class SpecValidator {
superseded_ops_file: SupersededOperationsFile
info_file: InfoFile
namespaces_folder: NamespacesFolder
schemas_folder: SchemasFolder
schemas_validator: SchemasValidator
schema_refs_validator: SchemaRefsValidator
inline_object_schema_validator: InlineObjectSchemaValidator

Expand All @@ -19,6 +21,7 @@ export default class SpecValidator {
this.info_file = new InfoFile(`${root_folder}/_info.yaml`)
this.namespaces_folder = new NamespacesFolder(`${root_folder}/namespaces`)
this.schemas_folder = new SchemasFolder(`${root_folder}/schemas`)
this.schemas_validator = new SchemasValidator(root_folder)
this.schema_refs_validator = new SchemaRefsValidator(this.namespaces_folder, this.schemas_folder)
this.inline_object_schema_validator = new InlineObjectSchemaValidator(this.namespaces_folder, this.schemas_folder)
}
Expand All @@ -34,7 +37,8 @@ export default class SpecValidator {
...this.schema_refs_validator.validate(),
...this.superseded_ops_file.validate(),
...this.info_file.validate(),
...this.inline_object_schema_validator.validate()
...this.inline_object_schema_validator.validate(),
...this.schemas_validator.validate()
]
}
}
6 changes: 4 additions & 2 deletions tools/src/merger/OpenApiMerger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import GlobalParamsGenerator from './GlobalParamsGenerator'
export default class OpenApiMerger {
root_folder: string
spec: Record<string, any>
silent: boolean

paths: Record<string, Record<string, OpenAPIV3.PathItemObject>> = {} // namespace -> path -> path_item_object
schemas: Record<string, Record<string, OpenAPIV3.SchemaObject>> = {} // category -> schema -> schema_object

constructor (root_folder: string) {
constructor (root_folder: string, silent: boolean = false) {
this.silent = silent
this.root_folder = fs.realpathSync(root_folder)
this.spec = {
openapi: '3.1.0',
Expand Down Expand Up @@ -117,6 +119,6 @@ export default class OpenApiMerger {
// Generate superseded operations from _superseded_operations.yaml file.
#generate_superseded_ops (): void {
const gen = new SupersededOpsGenerator(this.root_folder)
gen.generate(this.spec)
gen.generate(this.spec, this.silent)
}
}
6 changes: 3 additions & 3 deletions tools/src/merger/SupersededOpsGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ export default class SupersededOpsGenerator {
delete this.superseded_ops.$schema
}

generate (spec: Record<string, any>): void {
generate (spec: Record<string, any>, silent: boolean): void {
for (const [path, { superseded_by, operations }] of _.entries(this.superseded_ops)) {
const regex = this.path_to_regex(superseded_by)
const operation_keys = operations.map(op => op.toLowerCase())
const superseded_path = this.copy_params(superseded_by, path)
const path_entry = _.entries(spec.paths as Document).find(([path, _]) => regex.test(path))
if (!path_entry) console.log(`Path not found: ${superseded_by}`)
else spec.paths[superseded_path] = this.path_object(path_entry[1], operation_keys)
if (path_entry != null) spec.paths[superseded_path] = this.path_object(path_entry[1], operation_keys)
else if (!silent) console.log(`Path not found: ${superseded_by}`)
}
}

Expand Down
2 changes: 1 addition & 1 deletion tools/src/merger/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { resolve } from 'path'
const command = new Command()
.description('Merges the multi-file OpenSearch spec into a single file for programmatic use.')
.addOption(new Option('-s, --source <path>', 'path to the root folder of the multi-file spec').default(resolve(__dirname, '../../../spec')))
.addOption(new Option('-o, --output <path>', 'output file name').default(resolve(__dirname, '../../../build/opensearch-openapi.yaml')))
.addOption(new Option('-o, --output <path>', 'output file name').default(resolve(__dirname, '../../opensearch-openapi.yaml')))
.allowExcessArguments(false)
.parse()

Expand Down
43 changes: 43 additions & 0 deletions tools/tests/linter/SchemasValidator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import SchemasValidator from '../../src/linter/SchemasValidator'

test('validate() - named_schemas', () => {
const validator = new SchemasValidator('./tools/tests/linter/fixtures/schemas_validator/named_schemas')
expect(validator.validate()).toEqual([
{
file: 'schemas/actions.yaml',
location: '#/components/schemas/Bark',
message: 'schema is invalid: data/type must be equal to one of the allowed values, data/type must be array, data/type must match a schema in anyOf'
},
{
file: 'schemas/animals.yaml',
location: '#/components/schemas/Dog',
message: 'schema is invalid: data/type must be equal to one of the allowed values, data/type must be array, data/type must match a schema in anyOf'
}
])
})

test('validate() - anonymous_schemas', () => {
const validator = new SchemasValidator('./tools/tests/linter/fixtures/schemas_validator/anonymous_schemas')
expect(validator.validate()).toEqual([
{
file: '_global_parameters.yaml',
location: 'human',
message: 'schema is invalid: data/type must be equal to one of the allowed values, data/type must be array, data/type must match a schema in anyOf'
},
{
file: 'namespaces/_core.yaml',
location: '#/components/parameters/adopt::path.docket',
message: 'schema is invalid: data/type must be equal to one of the allowed values, data/type must be array, data/type must match a schema in anyOf'
},
{
file: 'namespaces/adopt.yaml',
location: '#/components/requestBodies/adopt/content/application/json',
message: 'schema is invalid: data/type must be equal to one of the allowed values, data/type must be array, data/type must match a schema in anyOf'
},
{
file: 'namespaces/_core.yaml',
location: '#/components/responses/adopt@200/content/application/json',
message: 'schema is invalid: data/type must be equal to one of the allowed values, data/type must be array, data/type must match a schema in anyOf'
}
])
})
8 changes: 5 additions & 3 deletions tools/tests/linter/SpecValidator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import SpecValidator from 'linter/SpecValidator'

test('validate()', () => {
const validator = new SpecValidator('./tools/tests/linter/fixtures/empty')
expect(validator.validate()).toEqual([])

validator.namespaces_folder.validate = jest.fn().mockReturnValue([{ file: 'namespaces/', message: 'namespace error' }])
validator.schemas_folder.validate = jest.fn().mockReturnValue([{ file: 'schemas/', message: 'schema error' }])
validator.schema_refs_validator.validate = jest.fn().mockReturnValue([{ file: 'schema_refs', message: 'schema refs error' }])
validator.schemas_validator.validate = jest.fn().mockReturnValue([{ file: 'schemas/', message: 'schema error' }])
validator.inline_object_schema_validator.validate = jest.fn().mockReturnValue([{ file: 'inline_file', message: 'inline_object_schema_validator error' }])

expect(validator.validate()).toEqual([
{ file: 'namespaces/', message: 'namespace error' },
Expand All @@ -17,6 +17,8 @@ test('validate()', () => {
validator.schemas_folder.validate = jest.fn().mockReturnValue([])

expect(validator.validate()).toEqual([
{ file: 'schema_refs', message: 'schema refs error' }
{ file: 'schema_refs', message: 'schema refs error' },
{ file: 'inline_file', message: 'inline_object_schema_validator error' },
{ file: 'schemas/', message: 'schema error' }
])
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
openapi: 3.1.0
info:
title: ''
version: ''
components:
parameters:
human:
name: human
in: query
description: Whether to return human readable values for statistics.
schema:
type: bogus
default: true
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
openapi: 3.1.0
info:
title: OpenSearch API
description: OpenSearch API
version: 1.0.0
paths:
'/adopt/{animal}/dockets/{docket}':
get:
operationId: adopt.0
parameters:
- $ref: '#/components/parameters/adopt::path.animal'
- $ref: '#/components/parameters/adopt::path.docket'
responses:
'200':
$ref: '#/components/responses/adopt@200'
post:
operationId: adopt.1
parameters:
- $ref: '#/components/parameters/adopt::path.animal'
- $ref: '#/components/parameters/adopt::path.docket'
requestBody:
$ref: '#/components/requestBodies/adopt'
responses:
'200':
$ref: '#/components/responses/adopt@200'
components:
requestBodies:
adopt: {
content: {
application/json: {
schema: {
type: object2
}
}
}
}
parameters:
adopt::path.animal:
name: animal
in: path
schema:
$ref: '../schemas/animals.yaml#/components/schemas/Animal'
adopt::path.docket:
name: docket
in: path
schema:
type: number2
responses:
adopt@200:
description: ''
content:
application/json:
schema:
type: object2
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
openapi: 3.1.0
info:
title: OpenSearch API
description: OpenSearch API
version: 1.0.0
components:
schemas:
Bark:
type: string
Meow:
type: string
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
openapi: 3.1.0
info:
title: OpenSearch API
description: OpenSearch API
version: 1.0.0
components:
schemas:
Animal:
oneOf:
- $ref: '#/components/schemas/Dog'
- $ref: '#/components/schemas/Cat'
Dog:
type: object
properties:
bark:
$ref: 'actions.yaml#/components/schemas/Bark'
Cat:
type: object
properties:
meow:
$ref: 'actions.yaml#/components/schemas/Meow'
Loading

0 comments on commit 91daa9c

Please sign in to comment.