diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fb7e4bdf..cc3d127cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Added `plugins` to NodeInfoSettings ([#442](https://github.com/opensearch-project/opensearch-api-specification/pull/442)) - Added test coverage ([#443](https://github.com/opensearch-project/opensearch-api-specification/pull/443)) - Added `--opensearch-version` to `merger` that excludes schema elements per semver ([#428](https://github.com/opensearch-project/opensearch-api-specification/pull/428)) -- Added `retry` to `tester` to support asynchronous tasks ([453](https://github.com/opensearch-project/opensearch-api-specification/pull/453)) +- Added `retry` to `tester` to support asynchronous tasks ([#453](https://github.com/opensearch-project/opensearch-api-specification/pull/453)) +- Added support for request headers in tests [#461](https://github.com/opensearch-project/opensearch-api-specification/pull/461) ### Changed diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index b5f8e0ea2..c3fb949b8 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -75,6 +75,8 @@ chapters: parameters: # All parameters are validated against their schemas in the spec. index: books request: # The request. + headers: # Optional headers. + user-agent: OpenSearch API Spec/1.0 payload: # The request body is validated against the schema of the requestBody in the spec. mappings: properties: diff --git a/json_schemas/test_story.schema.yaml b/json_schemas/test_story.schema.yaml index 11233a31f..249d653d2 100644 --- a/json_schemas/test_story.schema.yaml +++ b/json_schemas/test_story.schema.yaml @@ -123,9 +123,12 @@ definitions: content_type: type: string default: application/json + headers: + type: object + additionalProperties: + $ref: '#/definitions/Header' payload: $ref: '#/definitions/Payload' - required: [payload] additionalProperties: false ExpectedResponse: @@ -161,6 +164,12 @@ definitions: required: [content_type, payload, status] additionalProperties: false + Header: + anyOf: + - type: string + - type: number + - type: boolean + Payload: anyOf: - type: object diff --git a/tests/_core/info.yaml b/tests/_core/info.yaml index 6635498bc..d248fbbd1 100644 --- a/tests/_core/info.yaml +++ b/tests/_core/info.yaml @@ -13,6 +13,9 @@ chapters: method: GET parameters: pretty: false + request: + headers: + foo: bar response: status: 200 payload: diff --git a/tools/src/tester/ChapterEvaluator.ts b/tools/src/tester/ChapterEvaluator.ts index 2b6aa22ac..e06fca445 100644 --- a/tools/src/tester/ChapterEvaluator.ts +++ b/tools/src/tester/ChapterEvaluator.ts @@ -110,7 +110,7 @@ export default class ChapterEvaluator { } #evaluate_request(chapter: Chapter, operation: ParsedOperation): Evaluation { - if (!chapter.request) return { result: Result.PASSED } + if (chapter.request?.payload === undefined) return { result: Result.PASSED } const content_type = chapter.request.content_type ?? APPLICATION_JSON const schema = operation.requestBody?.content[content_type]?.schema if (schema == null) return { result: Result.FAILED, message: `Schema for "${content_type}" request body not found in the spec.` } diff --git a/tools/src/tester/ChapterReader.ts b/tools/src/tester/ChapterReader.ts index b4fa7b48d..ae26e9920 100644 --- a/tools/src/tester/ChapterReader.ts +++ b/tools/src/tester/ChapterReader.ts @@ -17,6 +17,7 @@ import YAML from 'yaml' import CBOR from 'cbor' import SMILE from 'smile-js' import { APPLICATION_CBOR, APPLICATION_JSON, APPLICATION_SMILE, APPLICATION_YAML, TEXT_PLAIN } from "./MimeTypes"; +import _ from 'lodash' export default class ChapterReader { private readonly _client: OpenSearchHttpClient @@ -31,16 +32,16 @@ export default class ChapterReader { const response: Record = {} const resolved_params = story_outputs.resolve_params(chapter.parameters ?? {}) const [url_path, params] = this.#parse_url(chapter.path, resolved_params) - const content_type = chapter.request?.content_type ?? APPLICATION_JSON + const [headers, content_type] = this.#serialize_headers(chapter.request?.headers, chapter.request?.content_type) const request_data = chapter.request?.payload !== undefined ? this.#serialize_payload( story_outputs.resolve_value(chapter.request.payload), content_type ) : undefined - this.logger.info(`=> ${chapter.method} ${url_path} (${to_json(params)}) [${content_type}] | ${to_json(request_data)}`) + this.logger.info(`=> ${chapter.method} ${url_path} (${to_json(params)}) [${content_type}] ${_.compact([to_json(headers), to_json(request_data)]).join(' | ')}`) await this._client.request({ url: url_path, method: chapter.method, - headers: { 'Content-Type' : content_type }, + headers: { 'Content-Type' : content_type, ...headers }, params, data: request_data, paramsSerializer: (params) => { // eslint-disable-line @typescript-eslint/naming-convention @@ -49,7 +50,8 @@ export default class ChapterReader { }).then(r => { response.status = r.status response.content_type = r.headers['content-type']?.split(';')[0] - response.payload = this.#deserialize_payload(r.data, response.content_type) + const payload = this.#deserialize_payload(r.data, response.content_type) + if (payload !== undefined) response.payload = payload this.logger.info(`<= ${r.status} (${r.headers['content-type']}) | ${to_json(response.payload)}`) }).catch(e => { if (e.response == null) { @@ -59,7 +61,7 @@ export default class ChapterReader { response.status = e.response.status response.content_type = e.response.headers['content-type']?.split(';')[0] const payload = this.#deserialize_payload(e.response.data, response.content_type) - response.payload = payload?.error + if (payload !== undefined) response.payload = payload.error response.message = payload.error?.reason ?? e.response.statusText response.error = e @@ -68,6 +70,19 @@ export default class ChapterReader { return response as ActualResponse } + #serialize_headers(headers?: Record, content_type?: string): [Record | undefined, string] { + headers = _.cloneDeep(headers) + content_type = content_type ?? APPLICATION_JSON + if (!headers) return [headers, content_type] + _.forEach(headers, (v, k) => { + if (k.toLowerCase() == 'content-type') { + content_type = v.toString() + if (headers) delete headers[k] + } + }) + return [headers, content_type] + } + #serialize_payload(payload: any, content_type: string): any { if (payload === undefined) return undefined switch (content_type) { diff --git a/tools/src/tester/types/story.types.ts b/tools/src/tester/types/story.types.ts index 2a98e5aab..54334a670 100644 --- a/tools/src/tester/types/story.types.ts +++ b/tools/src/tester/types/story.types.ts @@ -32,6 +32,11 @@ export type SupplementalChapter = ChapterRequest & { * via the `definition` "Parameter". */ export type Parameter = (string | number | boolean)[] | string | number | boolean; +/** + * This interface was referenced by `Story`'s JSON-Schema + * via the `definition` "Header". + */ +export type Header = string | number | boolean; /** * This interface was referenced by `Story`'s JSON-Schema * via the `definition` "Payload". @@ -114,7 +119,10 @@ export interface ChapterRequest { */ export interface Request { content_type?: string; - payload: Payload; + headers?: { + [k: string]: Header; + }; + payload?: Payload; } /** * Describes output for a chapter. diff --git a/tools/tests/tester/ChapterReader.test.ts b/tools/tests/tester/ChapterReader.test.ts index 223cad765..7e1776bb2 100644 --- a/tools/tests/tester/ChapterReader.test.ts +++ b/tools/tests/tester/ChapterReader.test.ts @@ -48,7 +48,7 @@ describe('ChapterReader', () => { output: undefined }, new StoryOutputs()) - expect(result).toEqual({ status: 200, content_type: 'application/json', payload: undefined }) + expect(result).toStrictEqual({ status: 200, content_type: 'application/json' }) expect(mocked_axios.request.mock.calls).toEqual([ [{ url: 'path', @@ -71,15 +71,15 @@ describe('ChapterReader', () => { output: undefined }, new StoryOutputs()) - expect(result).toEqual({ status: 200, content_type: 'application/json', payload: undefined }) + expect(result).toEqual({ status: 200, content_type: 'application/json' }) expect(mocked_axios.request.mock.calls).toEqual([ [{ url: 'books/path', method: 'GET', + data: undefined, headers: { 'Content-Type': 'application/json' }, params: {}, - paramsSerializer: expect.any(Function), - data: undefined + paramsSerializer: expect.any(Function) }] ]) }) @@ -94,7 +94,7 @@ describe('ChapterReader', () => { output: undefined }, new StoryOutputs()) - expect(result).toEqual({ status: 200, content_type: 'application/json', payload: undefined }) + expect(result).toEqual({ status: 200, content_type: 'application/json' }) expect(mocked_axios.request.mock.calls).toEqual([ [{ url: '/path', @@ -120,7 +120,7 @@ describe('ChapterReader', () => { output: undefined }, new StoryOutputs()) - expect(result).toEqual({ status: 200, content_type: 'application/json', payload: undefined }) + expect(result).toEqual({ status: 200, content_type: 'application/json' }) expect(mocked_axios.request.mock.calls).toEqual([ [{ url: 'path', @@ -146,7 +146,7 @@ describe('ChapterReader', () => { output: undefined }, new StoryOutputs()) - expect(result).toEqual({ status: 200, content_type: 'application/json', payload: undefined }) + expect(result).toEqual({ status: 200, content_type: 'application/json' }) expect(mocked_axios.request.mock.calls).toEqual([ [{ url: 'path', @@ -158,6 +158,67 @@ describe('ChapterReader', () => { }] ]) }) + + it('sends headers', async () => { + const result = await reader.read({ + id: 'id', + path: 'path', + method: 'GET', + request: { + headers: { + 'string': 'bar', + 'number': 1, + 'boolean': true + }, + }, + output: undefined + }, new StoryOutputs()) + + expect(result).toStrictEqual({ status: 200, content_type: 'application/json' }) + expect(mocked_axios.request.mock.calls).toStrictEqual([ + [{ + url: 'path', + method: 'GET', + data: undefined, + headers: { + 'Content-Type': 'application/json', + 'string': 'bar', + 'number': 1, + 'boolean': true + }, + params: {}, + paramsSerializer: expect.any(Function) + }] + ]) + }) + + it('overwrites case-insensitive content-type', async () => { + const result = await reader.read({ + id: 'id', + path: 'path', + method: 'GET', + request: { + headers: { + 'content-type': 'application/overwritten' + }, + }, + output: undefined + }, new StoryOutputs()) + + expect(result).toStrictEqual({ status: 200, content_type: 'application/json' }) + expect(mocked_axios.request.mock.calls).toStrictEqual([ + [{ + url: 'path', + method: 'GET', + data: undefined, + headers: { + 'Content-Type': 'application/overwritten', + }, + params: {}, + paramsSerializer: expect.any(Function) + }] + ]) + }) }) describe('deserialize_payload', () => { diff --git a/tools/tests/tester/fixtures/evals/passed.yaml b/tools/tests/tester/fixtures/evals/passed.yaml index 23158404c..abc633490 100644 --- a/tools/tests/tester/fixtures/evals/passed.yaml +++ b/tools/tests/tester/fixtures/evals/passed.yaml @@ -43,6 +43,23 @@ chapters: result: PASSED output_values: result: SKIPPED + - title: This GET /_cat chapter with a header should pass. + overall: + result: PASSED + path: GET /_cat + request: + parameters: {} + request: + result: PASSED + response: + status: + result: PASSED + payload_body: + result: PASSED + payload_schema: + result: PASSED + output_values: + result: SKIPPED - title: This GET /_cat/health chapter returns application/json and should pass. overall: result: PASSED diff --git a/tools/tests/tester/fixtures/stories/passed.yaml b/tools/tests/tester/fixtures/stories/passed.yaml index 65b6a3826..6d4c82211 100644 --- a/tools/tests/tester/fixtures/stories/passed.yaml +++ b/tools/tests/tester/fixtures/stories/passed.yaml @@ -17,6 +17,15 @@ chapters: response: status: 200 content_type: text/plain + - synopsis: This GET /_cat chapter with a header should pass. + path: /_cat + method: GET + request: + headers: + User-Agent: OpenSearch API Spec/1.0 + response: + status: 200 + content_type: text/plain - synopsis: This GET /_cat/health chapter returns application/json and should pass. path: /_cat/health parameters: