Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fail on missing properties. #342

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- Added support to read outputs from requests in tests([#324](https://github.com/opensearch-project/opensearch-api-specification/pull/324))
- Added `eslint-plugin-eslint-comments` ([#333](https://github.com/opensearch-project/opensearch-api-specification/pull/333))
- Added `distribution` field to `OpenSearchVersionInfo` ([#336](https://github.com/opensearch-project/opensearch-api-specification/pull/336))
- Added `created_time` and `last_updated_time` to `ml.get_model_group@200` ([#342](https://github.com/opensearch-project/opensearch-api-specification/pull/342))

### Changed

Expand Down
27 changes: 15 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@eslint/js": "^9.1.1",
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"ajv-errors": "^3.0.0",
"eslint": "^8.57.0",
"eslint-config-standard-with-typescript": "^43.0.1",
"eslint-plugin-eslint-comments": "^3.2.0",
Expand Down
6 changes: 6 additions & 0 deletions spec/namespaces/ml.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ components:
access:
type: string
description: The model group access.
created_time:
type: integer
format: int64
last_updated_time:
type: integer
format: int64
required:
- name
- latest_version
Expand Down
6 changes: 5 additions & 1 deletion tools/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,12 @@ export function write_yaml (file_path: string, content: any): void {
}))
}

export function to_json(content: any, replacer?: (this: any, key: string, value: any) => any): string {
return JSON.stringify(content, replacer, 2)
}

export function write_json (file_path: string, content: any, replacer?: (this: any, key: string, value: any) => any): void {
write_text(file_path, JSON.stringify(content, replacer, 2))
write_text(file_path, to_json(content, replacer))
}

export async function sleep (ms: number): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion tools/src/linter/SchemaRefsValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default class SchemaRefsValidator {

#find_refs_in_namespaces_folder (): void {
const search = (obj: any): void => {
const ref: string = obj.$ref ?? ''
const ref: string = obj?.$ref ?? ''
if (ref !== '') {
const file = ref.split('#')[0].replace('../', '')
const name = ref.split('/').pop() ?? ''
Expand Down
4 changes: 2 additions & 2 deletions tools/src/linter/components/base/FileValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import ValidatorBase from './ValidatorBase'
import { type ValidationError } from 'types'
import { type OpenAPIV3 } from 'openapi-types'
import { read_yaml } from '../../../helpers'
import { read_yaml, to_json } from '../../../helpers'
import AJV from 'ajv'
import addFormats from 'ajv-formats'

Expand Down Expand Up @@ -65,7 +65,7 @@ export default class FileValidator extends ValidatorBase {
addFormats(ajv)
const validator = ajv.compile(schema)
if (!validator(this.spec())) {
return this.error(`File content does not match JSON schema found in '${json_schema_path}':\n ${JSON.stringify(validator.errors, null, 2)}`)
return this.error(`File content does not match JSON schema found in '${json_schema_path}':\n ${to_json(validator.errors)}`)
}
}
}
6 changes: 4 additions & 2 deletions tools/src/merger/OpenApiMerger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ export default class OpenApiMerger {
const spec = read_yaml(`${folder}/${file}`)
const category = file.split('.yaml')[0]
this.redirect_refs_in_schema(category, spec)
this.schemas[category] = spec.components.schemas as Record<string, OpenAPIV3.SchemaObject>
if (spec.components?.schemas !== undefined) {
this.schemas[category] = spec.components?.schemas
}
nhtruong marked this conversation as resolved.
Show resolved Hide resolved
})

Object.entries(this.schemas).forEach(([category, schemas]) => {
Expand All @@ -97,7 +99,7 @@ export default class OpenApiMerger {

// Redirect schema references in schema files to local references in single-file spec.
redirect_refs_in_schema (category: string, obj: any): void {
const ref: string = obj.$ref ?? ''
const ref: string = obj?.$ref ?? ''
if (ref !== '') {
if (ref.startsWith('#/components/schemas')) { obj.$ref = `#/components/schemas/${category}:${ref.split('/').pop()}` } else {
const other_category = ref.match(/(.*)\.yaml/)?.[1] ?? ''
Expand Down
14 changes: 12 additions & 2 deletions tools/src/tester/ChapterReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,41 @@
import { type ChapterRequest, type ActualResponse, type Parameter } from './types/story.types'
import { type OpenSearchHttpClient } from '../OpenSearchHttpClient'
import { type StoryOutputs } from './StoryOutputs'
import { Logger } from 'Logger'
import { to_json } from '../helpers'

// A lightweight client for testing the API
export default class ChapterReader {
private readonly _client: OpenSearchHttpClient
private readonly logger: Logger

constructor (client: OpenSearchHttpClient) {
constructor (client: OpenSearchHttpClient, logger: Logger) {
this._client = client
this.logger = logger
}

async read (chapter: ChapterRequest, story_outputs: StoryOutputs): Promise<ActualResponse> {
const response: Record<string, any> = {}
const resolved_params = story_outputs.resolve_params(chapter.parameters ?? {})
const [url_path, params] = this.#parse_url(chapter.path, resolved_params)
const request_data = chapter.request_body?.payload !== undefined ? story_outputs.resolve_value(chapter.request_body.payload) : undefined
this.logger.info(`=> ${chapter.method} ${url_path} (${to_json(params)}) | ${to_json(request_data)}`)
await this._client.request({
url: url_path,
method: chapter.method,
params,
data: request_data
}).then(r => {
this.logger.info(`<= ${r.status} (${r.headers['content-type']}) | ${to_json(r.data)}`)
response.status = r.status
response.content_type = r.headers['content-type'].split(';')[0]
response.payload = r.data
}).catch(e => {
if (e.response == null) throw e
if (e.response == null) {
this.logger.info(`<= ERROR: ${e}`)
throw e
}
this.logger.info(`<= ${e.response.status} (${e.response.headers['content-type']}) | ${to_json(e.response.data)}`)
response.status = e.response.status
response.content_type = e.response.headers['content-type'].split(';')[0]
response.payload = e.response.data?.error
Expand Down
49 changes: 49 additions & 0 deletions tools/src/tester/MergedOpenApiSpec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

import { type OpenAPIV3 } from 'openapi-types'
import { Logger } from '../Logger'
import { SpecificationContext } from '../linter/utils';
import { SchemaVisitor } from '../linter/utils/SpecificationVisitor';
import OpenApiMerger from '../merger/OpenApiMerger';

// An augmented spec with additionalProperties: false.
export default class MergedOpenApiSpec {
logger: Logger
file_path: string
protected _spec: OpenAPIV3.Document | undefined

constructor (spec_path: string, logger: Logger = new Logger()) {
this.logger = logger
this.file_path = spec_path
}

spec (): OpenAPIV3.Document {
if (this._spec) return this._spec
const spec = (new OpenApiMerger(this.file_path, this.logger)).merge()
const ctx = new SpecificationContext(this.file_path)
this.inject_additional_properties(ctx, spec)
this._spec = spec
return this._spec
}

private inject_additional_properties(ctx: SpecificationContext, spec: OpenAPIV3.Document): void {
const visitor = new SchemaVisitor((_ctx, schema: any) => {
if (schema.required !== undefined && schema.properties !== undefined && schema.additionalProperties === undefined) {
// causes any undeclared field in the response to produce an error
schema.additionalProperties = {
not: true,
errorMessage: "property is not defined in the spec"
}
}
})

visitor.visit_specification(ctx, spec)
}
}
5 changes: 0 additions & 5 deletions tools/src/tester/ResultLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,6 @@ export class ConsoleResultLogger implements ResultLogger {
const result = ansi.padding(this.#result(evaluation.result), 0, prefix)
const message = evaluation.message != null ? `${ansi.gray('(' + evaluation.message + ')')}` : ''
console.log(`${result} ${title} ${message}`)
if (evaluation.error != null && this._verbose) {
console.log('-'.repeat(100))
console.error(evaluation.error)
console.log('-'.repeat(100))
}
nhtruong marked this conversation as resolved.
Show resolved Hide resolved
}

#result (r: Result): string {
Expand Down
13 changes: 12 additions & 1 deletion tools/src/tester/SchemaValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,22 @@
*/

import AJV from 'ajv'
import ajv_errors from 'ajv-errors'
import addFormats from 'ajv-formats'
import { type OpenAPIV3 } from 'openapi-types'
import { type Evaluation, Result } from './types/eval.types'
import { Logger } from 'Logger'
import { to_json } from '../helpers'

export default class SchemaValidator {
private readonly ajv: AJV
constructor (spec: OpenAPIV3.Document) {
private readonly logger: Logger

constructor (spec: OpenAPIV3.Document, logger: Logger) {
this.logger = logger
this.ajv = new AJV({ allErrors: true, strict: true })
addFormats(this.ajv)
ajv_errors(this.ajv, { singleError: true })
this.ajv.addKeyword('discriminator')
const schemas = spec.components?.schemas ?? {}
for (const key in schemas) this.ajv.addSchema(schemas[key], `#/components/schemas/${key}`)
Expand All @@ -25,6 +32,10 @@ export default class SchemaValidator {
validate (schema: OpenAPIV3.SchemaObject, data: any): Evaluation {
const validate = this.ajv.compile(schema)
const valid = validate(data)
if (! valid) {
this.logger.info(`# ${to_json(schema)}`)
this.logger.info(`* ${to_json(data)}`)
}
return {
result: valid ? Result.PASSED : Result.FAILED,
message: valid ? undefined : this.ajv.errorsText(validate.errors)
Expand Down
3 changes: 1 addition & 2 deletions tools/src/tester/StoryEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export default class StoryEvaluator {
StoryEvaluator.#extract_request_body_variables(chapter.request_body?.payload ?? {}, variables)
for (const { chapter_id, output_name } of variables) {
if (!story_outputs.has_chapter(chapter_id)) {
return StoryEvaluator.#failed_evaluation(title, `Chapter makes reference to non existent chapter "${chapter_id}`)
return StoryEvaluator.#failed_evaluation(title, `Chapter makes reference to non existent chapter "${chapter_id}`)
}
if (!story_outputs.has_output_value(chapter_id, output_name)) {
return StoryEvaluator.#failed_evaluation(title, `Chapter makes reference to non existent output "${output_name}" in chapter "${chapter_id}"`)
Expand Down Expand Up @@ -202,5 +202,4 @@ export default class StoryEvaluator {
static #failed_evaluation(title: string, message: string): ChapterEvaluation {
return { title, overall: { result: Result.FAILED, message } }
}

}
9 changes: 4 additions & 5 deletions tools/src/tester/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
* compatible open source license.
*/

import OpenApiMerger from '../merger/OpenApiMerger'
import { LogLevel, Logger } from '../Logger'
import TestRunner from './TestRunner'
import { Command, Option } from '@commander-js/extra-typings'
Expand All @@ -26,6 +25,7 @@ import StoryEvaluator from './StoryEvaluator'
import { ConsoleResultLogger } from './ResultLogger'
import * as process from 'node:process'
import SupplementalChapterEvaluator from './SupplementalChapterEvaluator'
import MergedOpenApiSpec from './MergedOpenApiSpec'

const command = new Command()
.description('Run test stories against the OpenSearch spec.')
Expand All @@ -48,11 +48,10 @@ const command = new Command()
const opts = command.opts()
const logger = new Logger(opts.verbose ? LogLevel.info : LogLevel.warn)

const spec = (new OpenApiMerger(opts.specPath, logger)).merge()

const spec = (new MergedOpenApiSpec(opts.specPath, logger)).spec()
const http_client = new OpenSearchHttpClient(get_opensearch_opts_from_cli(opts))
const chapter_reader = new ChapterReader(http_client)
const chapter_evaluator = new ChapterEvaluator(new OperationLocator(spec), chapter_reader, new SchemaValidator(spec))
const chapter_reader = new ChapterReader(http_client, logger)
const chapter_evaluator = new ChapterEvaluator(new OperationLocator(spec), chapter_reader, new SchemaValidator(spec, logger))
const supplemental_chapter_evaluator = new SupplementalChapterEvaluator(chapter_reader)
const story_evaluator = new StoryEvaluator(chapter_evaluator, supplemental_chapter_evaluator)
const result_logger = new ConsoleResultLogger(opts.tabWidth, opts.verbose)
Expand Down
36 changes: 36 additions & 0 deletions tools/tests/tester/MergedOpenApiSpec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

import { Logger } from 'Logger'
import MergedOpenApiSpec from "tester/MergedOpenApiSpec"

describe('additionalProperties', () => {
const spec = new MergedOpenApiSpec('tools/tests/tester/fixtures/specs/complete', new Logger())
const responses: any = spec.spec().components?.responses

test('is added with required fields', () => {
const schema = responses['info@200'].content['application/json'].schema
expect(schema.additionalProperties).toEqual({ not: true, errorMessage: 'property is not defined in the spec' })
})

test('is not added when true', () => {
const schema = responses['info@201'].content['application/json'].schema
expect(schema.additionalProperties).toEqual(true)
})

test('is not added when object', () => {
const schema = responses['info@404'].content['application/json'].schema
expect(schema.additionalProperties).toEqual({ type: 'object' })
})

test('is not added unless required is present', () => {
const schema = responses['info@500'].content['application/json'].schema
expect(schema.additionalProperties).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
openapi: 3.1.0
info:
title: ''
version: ''
5 changes: 5 additions & 0 deletions tools/tests/tester/fixtures/specs/complete/_info.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
$schema: should-be-ignored

title: OpenSearch API
description: OpenSearch API
version: 1.0.0
Loading
Loading