diff --git a/packages/oas/README.md b/packages/oas/README.md index fd2dc45f..fc7dda1d 100644 --- a/packages/oas/README.md +++ b/packages/oas/README.md @@ -57,6 +57,18 @@ const requests = await oas2har(response.data); console.log(requests); ``` +Some specifications may incorporate example values for parameters provided in vendor extension fields, to include such examples in output use the `oas2har` function as follows: + +```js +import schema from './swagger.json' assert { type: 'json' }; +import { oas2har } from '@har-sdk/oas'; + +const requests = await oas2har(schema, { includeVendorExamples: true }); +console.log(requests); +``` + +Notice the `includeVendorExamples` option affects Swagger specifications only. + ## License Copyright © 2023 [Bright Security](https://brightsec.com/). diff --git a/packages/oas/src/converter/Sampler.ts b/packages/oas/src/converter/Sampler.ts index 6faa13e7..f50e251d 100644 --- a/packages/oas/src/converter/Sampler.ts +++ b/packages/oas/src/converter/Sampler.ts @@ -1,9 +1,17 @@ import { ConvertError } from '../errors'; -import { sample, Schema } from '@har-sdk/openapi-sampler'; +import { isOASV2 } from '../utils'; +import { + Options, + sample, + Schema, + VendorExtensions +} from '@har-sdk/openapi-sampler'; import pointer from 'json-pointer'; import type { OpenAPI } from '@har-sdk/core'; export class Sampler { + constructor(private readonly options: Options) {} + public sampleParam( param: OpenAPI.Parameter, context: { @@ -12,23 +20,15 @@ export class Sampler { tokens: string[]; } ): any { - return this.sample( - 'schema' in param - ? { - ...param.schema, - ...(param.example !== undefined ? { example: param.example } : {}) - } - : param, - { - spec: context.spec, - jsonPointer: pointer.compile([ - ...context.tokens, - 'parameters', - context.idx.toString(), - ...('schema' in param ? ['schema'] : []) - ]) - } - ); + return this.sample(this.createParamSchema(param), { + spec: context.spec, + jsonPointer: pointer.compile([ + ...context.tokens, + 'parameters', + context.idx.toString(), + ...('schema' in param ? ['schema'] : []) + ]) + }); } /** @@ -43,9 +43,44 @@ export class Sampler { } ): any | undefined { try { - return sample(schema, { skipReadOnly: true, quiet: true }, context?.spec); + const { includeVendorExamples } = this.options; + const options = { + ...this.options, + skipReadOnly: true, + quiet: true, + includeVendorExamples: + context?.spec && isOASV2(context?.spec) + ? includeVendorExamples + : false + }; + + return sample(schema, options, context?.spec); } catch (e) { throw new ConvertError(e.message, context?.jsonPointer); } } + + private createParamSchema(param: OpenAPI.Parameter): Schema { + if ('schema' in param) { + const { schema, example, ...rest } = param; + + return { + ...schema, + ...(example !== undefined ? { example } : {}), + ...this.extractVendorExamples(rest) + }; + } + + return param as Schema; + } + + private extractVendorExamples(param: OpenAPI.Parameter) { + return [VendorExtensions.X_EXAMPLE, VendorExtensions.X_EXAMPLES].reduce( + (acc, prop) => ({ + ...acc, + ...(param[prop] !== undefined ? { [prop]: param[prop] } : {}) + }), + {} + ); + } } diff --git a/packages/oas/src/index.ts b/packages/oas/src/index.ts index 7a7a0f4d..5907bedc 100644 --- a/packages/oas/src/index.ts +++ b/packages/oas/src/index.ts @@ -10,12 +10,15 @@ import type { OpenAPI, Request } from '@har-sdk/core'; export * from './errors'; -export const oas2har = (collection: OpenAPI.Document): Promise => { +export const oas2har = ( + collection: OpenAPI.Document, + options: { includeVendorExamples?: boolean } = {} +): Promise => { if (!collection) { throw new TypeError('Please provide a valid OAS specification.'); } - const sampler = new Sampler(); + const sampler = new Sampler(options); const baseUrlParser = new BaseUrlParser(sampler); const subConverterFactory = new SubConverterFactory(sampler); const subConverterRegistry = new SubConverterRegistry(subConverterFactory); diff --git a/packages/oas/tests/DefaultConverter.spec.ts b/packages/oas/tests/DefaultConverter.spec.ts index 7a77d8c4..b0fed690 100644 --- a/packages/oas/tests/DefaultConverter.spec.ts +++ b/packages/oas/tests/DefaultConverter.spec.ts @@ -1,11 +1,32 @@ import { ConvertError, oas2har } from '../src'; -import yaml, { load } from 'js-yaml'; +import { load } from 'js-yaml'; import { OpenAPIV2, Request } from '@har-sdk/core'; import { resolve } from 'path'; -import { readFile, readFileSync } from 'fs'; +import { readFile } from 'fs'; import { promisify } from 'util'; describe('DefaultConverter', () => { + const readFileAsync = promisify(readFile); + + const loadFile = async (fileName: string) => { + const filePath = resolve(__dirname, fileName); + + const content = await readFileAsync(filePath, 'utf-8'); + + return content.endsWith('.json') ? JSON.parse(content) : load(content); + }; + + const createFixture = async ({ + inputFile, + expectedFile + }: { + inputFile: string; + expectedFile: string; + }) => ({ + inputDoc: await loadFile(inputFile), + expectedDoc: await loadFile(expectedFile) + }); + describe('convert', () => { [ { @@ -183,24 +204,14 @@ describe('DefaultConverter', () => { } ].forEach(({ input: inputFile, expected: expectedFile, message }) => { it(message, async () => { - const content = readFileSync( - resolve(__dirname, `./fixtures/${inputFile}`), - 'utf-8' - ); - const input = inputFile.endsWith('json') - ? JSON.parse(content) - : load(content); - - const expected = JSON.parse( - readFileSync( - resolve(__dirname, `./fixtures/${expectedFile}`), - 'utf-8' - ) - ); + const { inputDoc, expectedDoc } = await createFixture({ + inputFile: `./fixtures/${inputFile}`, + expectedFile: `./fixtures/${expectedFile}` + }); - const result: Request[] = await oas2har(input as any); + const result: Request[] = await oas2har(inputDoc as any); - expect(result).toStrictEqual(expected); + expect(result).toStrictEqual(expectedDoc); }); }); @@ -271,12 +282,9 @@ describe('DefaultConverter', () => { /^convert-error-on-(.+)\.(.+)\.yaml$/, '$1 ($2)' )}`, async () => { - const content: string = await promisify(readFile)( - resolve(__dirname, `./fixtures/${input}`), - 'utf8' - ); + const inputDoc = await loadFile(`./fixtures/${input}`); - const result = oas2har(yaml.load(content) as OpenAPIV2.Document); + const result = oas2har(inputDoc as OpenAPIV2.Document); await expect(result).rejects.toThrow(ConvertError); await expect(result).rejects.toMatchObject({ @@ -284,5 +292,97 @@ describe('DefaultConverter', () => { }); }) ); + + it('should ignore x-example when includeVendorExamples is true (oas)', async () => { + // arrange + const { inputDoc, expectedDoc } = await createFixture({ + inputFile: `./fixtures/x-example.oas.yaml`, + expectedFile: `./fixtures/x-example.oas.result.json` + }); + + // act + const result: Request[] = await oas2har(inputDoc as any, { + includeVendorExamples: true + }); + + // assert + expect(result).toStrictEqual(expectedDoc); + }); + + it.each(['path', 'query', 'header', 'form-data'])( + 'should ignore %s parameter vendor example when vendor examples inclusion disabled (swagger)', + async (input) => { + // arrange + const { inputDoc, expectedDoc } = await createFixture({ + inputFile: `./fixtures/x-example.${input}.swagger.yaml`, + expectedFile: `./fixtures/x-example.${input}.disabled.swagger.result.json` + }); + + // act + const result: Request[] = await oas2har(inputDoc as any, { + includeVendorExamples: false + }); + + // assert + expect(result).toStrictEqual(expectedDoc); + } + ); + + it.each(['path', 'query', 'header', 'form-data'])( + 'should use %s parameter vendor example when vendor examples inclusion enabled (swagger)', + async (input) => { + // arrange + const { inputDoc, expectedDoc } = await createFixture({ + inputFile: `./fixtures/x-example.${input}.swagger.yaml`, + expectedFile: `./fixtures/x-example.${input}.swagger.result.json` + }); + + // act + const result: Request[] = await oas2har(inputDoc as any, { + includeVendorExamples: true + }); + + // assert + expect(result).toStrictEqual(expectedDoc); + } + ); + + it.each(['schemathesis', 'redocly', 'api-connect', 'smartbear'])( + 'should ignore body parameter vendor example when vendor examples inclusion disabled (swagger, %s)', + async (input) => { + // arrange + const { inputDoc, expectedDoc } = await createFixture({ + inputFile: `./fixtures/x-example.body.${input}.swagger.yaml`, + expectedFile: `./fixtures/x-example.body.disabled.swagger.result.json` + }); + + // act + const result: Request[] = await oas2har(inputDoc as any, { + includeVendorExamples: false + }); + + // assert + expect(result).toStrictEqual(expectedDoc); + } + ); + + it.each(['schemathesis', 'redocly', 'api-connect', 'smartbear'])( + 'should use body parameter vendor example when vendor examples inclusion enabled (swagger, %s)', + async (input) => { + // arrange + const { inputDoc, expectedDoc } = await createFixture({ + inputFile: `./fixtures/x-example.body.${input}.swagger.yaml`, + expectedFile: `./fixtures/x-example.body.${input}.swagger.result.json` + }); + + // act + const result: Request[] = await oas2har(inputDoc as any, { + includeVendorExamples: true + }); + + // assert + expect(result).toStrictEqual(expectedDoc); + } + ); }); }); diff --git a/packages/oas/tests/fixtures/x-example.body.api-connect.swagger.result.json b/packages/oas/tests/fixtures/x-example.body.api-connect.swagger.result.json new file mode 100644 index 00000000..8ccbaa69 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.body.api-connect.swagger.result.json @@ -0,0 +1,48 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "POST", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "[{\"name\":\"x_example_name\",\"age\":30}]" + }, + "url": "https://example.com/sample" + }, + { + "queryString": [], + "cookies": [], + "method": "PUT", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "{\"name\":\"x_example_name\",\"age\":30}" + }, + "url": "https://example.com/sample/123" + } +] diff --git a/packages/oas/tests/fixtures/x-example.body.api-connect.swagger.yaml b/packages/oas/tests/fixtures/x-example.body.api-connect.swagger.yaml new file mode 100644 index 00000000..38160cea --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.body.api-connect.swagger.yaml @@ -0,0 +1,66 @@ +swagger: '2.0' +info: + title: Sample API + version: 1.0.0 +host: example.com +schemes: + - https +paths: + /sample: + post: + consumes: + - application/json + produces: + - application/json + parameters: + - name: payload + in: body + required: true + schema: + type: array + items: + type: object + properties: + name: + type: string + default: 'default_name' + age: + type: integer + default: 10 + x-example: + - name: x_example_name + age: 30 + responses: + '201': + description: '' + /sample/{id}: + put: + consumes: + - application/json + produces: + - application/json + parameters: + - name: id + in: path + required: true + type: integer + default: 0 + x-example: 123 + - name: payload + in: body + required: true + schema: + type: object + properties: + name: + type: string + default: 'default_name' + age: + type: integer + default: 10 + x-example: + name: x_example_name + age: 30 + responses: + '201': + description: '' diff --git a/packages/oas/tests/fixtures/x-example.body.disabled.swagger.result.json b/packages/oas/tests/fixtures/x-example.body.disabled.swagger.result.json new file mode 100644 index 00000000..dc2a4f99 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.body.disabled.swagger.result.json @@ -0,0 +1,48 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "POST", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "[{\"name\":\"default_name\",\"age\":10}]" + }, + "url": "https://example.com/sample" + }, + { + "queryString": [], + "cookies": [], + "method": "PUT", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "{\"name\":\"default_name\",\"age\":10}" + }, + "url": "https://example.com/sample/0" + } +] diff --git a/packages/oas/tests/fixtures/x-example.body.redocly.swagger.result.json b/packages/oas/tests/fixtures/x-example.body.redocly.swagger.result.json new file mode 100644 index 00000000..8ccbaa69 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.body.redocly.swagger.result.json @@ -0,0 +1,48 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "POST", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "[{\"name\":\"x_example_name\",\"age\":30}]" + }, + "url": "https://example.com/sample" + }, + { + "queryString": [], + "cookies": [], + "method": "PUT", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "{\"name\":\"x_example_name\",\"age\":30}" + }, + "url": "https://example.com/sample/123" + } +] diff --git a/packages/oas/tests/fixtures/x-example.body.redocly.swagger.yaml b/packages/oas/tests/fixtures/x-example.body.redocly.swagger.yaml new file mode 100644 index 00000000..8436fa67 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.body.redocly.swagger.yaml @@ -0,0 +1,68 @@ +swagger: '2.0' +info: + title: Sample API + version: 1.0.0 +host: example.com +schemes: + - https +paths: + /sample: + post: + consumes: + - application/json + produces: + - application/json + parameters: + - name: payload + in: body + required: true + schema: + type: array + items: + type: object + properties: + name: + type: string + default: 'default_name' + age: + type: integer + default: 10 + x-example: + application/json: + - name: x_example_name + age: 30 + responses: + '201': + description: '' + /sample/{id}: + put: + consumes: + - application/json + produces: + - application/json + parameters: + - name: id + in: path + required: true + type: integer + default: 0 + x-example: 123 + - name: payload + in: body + required: true + schema: + type: object + properties: + name: + type: string + default: 'default_name' + age: + type: integer + default: 10 + x-example: + example-name: + name: x_example_name + age: 30 + responses: + '201': + description: '' diff --git a/packages/oas/tests/fixtures/x-example.body.schemathesis.swagger.result.json b/packages/oas/tests/fixtures/x-example.body.schemathesis.swagger.result.json new file mode 100644 index 00000000..8ccbaa69 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.body.schemathesis.swagger.result.json @@ -0,0 +1,48 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "POST", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "[{\"name\":\"x_example_name\",\"age\":30}]" + }, + "url": "https://example.com/sample" + }, + { + "queryString": [], + "cookies": [], + "method": "PUT", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "{\"name\":\"x_example_name\",\"age\":30}" + }, + "url": "https://example.com/sample/123" + } +] diff --git a/packages/oas/tests/fixtures/x-example.body.schemathesis.swagger.yaml b/packages/oas/tests/fixtures/x-example.body.schemathesis.swagger.yaml new file mode 100644 index 00000000..fc77633f --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.body.schemathesis.swagger.yaml @@ -0,0 +1,67 @@ +swagger: '2.0' +info: + title: Sample API + version: 1.0.0 +host: example.com +schemes: + - https +paths: + /sample: + post: + consumes: + - application/json + produces: + - application/json + parameters: + - name: payload + in: body + required: true + schema: + type: array + items: + type: object + properties: + name: + type: string + default: 'default_name' + age: + type: integer + default: 10 + x-example: + - name: x_example_name + age: 30 + responses: + '201': + description: '' + /sample/{id}: + put: + consumes: + - application/json + produces: + - application/json + parameters: + - name: id + in: path + required: true + type: integer + default: 0 + x-example: 123 + - name: payload + in: body + required: true + schema: + type: object + properties: + name: + type: string + default: 'default_name' + age: + type: integer + default: 10 + x-examples: + application/json: + name: x_example_name + age: 30 + responses: + '201': + description: '' diff --git a/packages/oas/tests/fixtures/x-example.body.smartbear.swagger.result.json b/packages/oas/tests/fixtures/x-example.body.smartbear.swagger.result.json new file mode 100644 index 00000000..8ccbaa69 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.body.smartbear.swagger.result.json @@ -0,0 +1,48 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "POST", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "[{\"name\":\"x_example_name\",\"age\":30}]" + }, + "url": "https://example.com/sample" + }, + { + "queryString": [], + "cookies": [], + "method": "PUT", + "headers": [ + { + "value": "application/json", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "{\"name\":\"x_example_name\",\"age\":30}" + }, + "url": "https://example.com/sample/123" + } +] diff --git a/packages/oas/tests/fixtures/x-example.body.smartbear.swagger.yaml b/packages/oas/tests/fixtures/x-example.body.smartbear.swagger.yaml new file mode 100644 index 00000000..38160cea --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.body.smartbear.swagger.yaml @@ -0,0 +1,66 @@ +swagger: '2.0' +info: + title: Sample API + version: 1.0.0 +host: example.com +schemes: + - https +paths: + /sample: + post: + consumes: + - application/json + produces: + - application/json + parameters: + - name: payload + in: body + required: true + schema: + type: array + items: + type: object + properties: + name: + type: string + default: 'default_name' + age: + type: integer + default: 10 + x-example: + - name: x_example_name + age: 30 + responses: + '201': + description: '' + /sample/{id}: + put: + consumes: + - application/json + produces: + - application/json + parameters: + - name: id + in: path + required: true + type: integer + default: 0 + x-example: 123 + - name: payload + in: body + required: true + schema: + type: object + properties: + name: + type: string + default: 'default_name' + age: + type: integer + default: 10 + x-example: + name: x_example_name + age: 30 + responses: + '201': + description: '' diff --git a/packages/oas/tests/fixtures/x-example.form-data.disabled.swagger.result.json b/packages/oas/tests/fixtures/x-example.form-data.disabled.swagger.result.json new file mode 100644 index 00000000..4bcf62a6 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.form-data.disabled.swagger.result.json @@ -0,0 +1,25 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "POST", + "headers": [ + { + "value": "application/x-www-form-urlencoded", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/x-www-form-urlencoded", + "text": "name=default_name" + }, + "url": "https://example.com/sample" + } +] diff --git a/packages/oas/tests/fixtures/x-example.form-data.swagger.result.json b/packages/oas/tests/fixtures/x-example.form-data.swagger.result.json new file mode 100644 index 00000000..34597e0d --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.form-data.swagger.result.json @@ -0,0 +1,25 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "POST", + "headers": [ + { + "value": "application/x-www-form-urlencoded", + "name": "content-type" + }, + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/x-www-form-urlencoded", + "text": "name=x_example_name" + }, + "url": "https://example.com/sample" + } +] diff --git a/packages/oas/tests/fixtures/x-example.form-data.swagger.yaml b/packages/oas/tests/fixtures/x-example.form-data.swagger.yaml new file mode 100644 index 00000000..9680cbe1 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.form-data.swagger.yaml @@ -0,0 +1,23 @@ +swagger: '2.0' +info: + title: Sample API + version: 1.0.0 +host: example.com +schemes: + - https +paths: + /sample: + post: + consumes: + - application/x-www-form-urlencoded + produces: + - application/json + parameters: + - name: name + in: formData + type: string + default: default_name + x-example: x_example_name + responses: + '201': + description: '' diff --git a/packages/oas/tests/fixtures/x-example.header.disabled.swagger.result.json b/packages/oas/tests/fixtures/x-example.header.disabled.swagger.result.json new file mode 100644 index 00000000..dd164a74 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.header.disabled.swagger.result.json @@ -0,0 +1,21 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "GET", + "headers": [ + { + "value": "application/json", + "name": "accept" + }, + { + "value": "Bearer default_jwt_token", + "name": "authorization" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "url": "https://example.com/sample" + } +] diff --git a/packages/oas/tests/fixtures/x-example.header.swagger.result.json b/packages/oas/tests/fixtures/x-example.header.swagger.result.json new file mode 100644 index 00000000..3b9c0956 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.header.swagger.result.json @@ -0,0 +1,21 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "GET", + "headers": [ + { + "value": "application/json", + "name": "accept" + }, + { + "value": "Bearer x_example_jwt_token", + "name": "authorization" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "url": "https://example.com/sample" + } +] diff --git a/packages/oas/tests/fixtures/x-example.header.swagger.yaml b/packages/oas/tests/fixtures/x-example.header.swagger.yaml new file mode 100644 index 00000000..41d3899b --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.header.swagger.yaml @@ -0,0 +1,24 @@ +swagger: '2.0' +info: + title: Sample API + version: 1.0.0 +host: example.com +schemes: + - https +paths: + /sample: + get: + consumes: + - application/json + produces: + - application/json + parameters: + - name: Authorization + in: header + required: true + type: string + default: 'Bearer default_jwt_token' + x-example: 'Bearer x_example_jwt_token' + responses: + '201': + description: '' diff --git a/packages/oas/tests/fixtures/x-example.oas.result.json b/packages/oas/tests/fixtures/x-example.oas.result.json new file mode 100644 index 00000000..a1c80e23 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.oas.result.json @@ -0,0 +1,68 @@ +[ + { + "queryString": [ + { + "name": "filter", + "value": "default_filter" + } + ], + "cookies": [], + "method": "PUT", + "headers": [ + { + "value": "application/x-www-form-urlencoded", + "name": "content-type" + }, + { + "value": "Bearer default_jwt_token", + "name": "authorization" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/x-www-form-urlencoded", + "text": "name=default_name" + }, + "url": "https://example.com/sample/0?filter=default_filter" + }, + { + "queryString": [], + "cookies": [], + "method": "POST", + "headers": [ + { + "value": "application/json", + "name": "content-type" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "{\"name\":\"default_name\",\"age\":42}" + }, + "url": "https://example.com/sample" + }, + { + "queryString": [], + "cookies": [], + "method": "PATCH", + "headers": [ + { + "value": "application/json", + "name": "content-type" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "postData": { + "mimeType": "application/json", + "text": "{\"name\":\"example_name\",\"age\":20}" + }, + "url": "https://example.com/sample" + } +] diff --git a/packages/oas/tests/fixtures/x-example.oas.yaml b/packages/oas/tests/fixtures/x-example.oas.yaml new file mode 100644 index 00000000..7718ea6e --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.oas.yaml @@ -0,0 +1,89 @@ +openapi: 3.0.1 +info: + title: Sample API + version: 1.0.0 +servers: + - url: https://example.com/ +paths: + /sample/{id}: + put: + parameters: + - name: id + in: path + required: true + schema: + type: integer + default: 0 + x-example: 123 + - name: filter + in: query + schema: + type: string + default: default_filter + x-example: x_example_filter + - name: Authorization + in: header + required: true + schema: + type: string + default: Bearer default_jwt_token + x-example: Bearer x_example_jwt_token + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + type: string + x-example: x_example_name + default: default_name + responses: + '201': + description: '' + content: {} + /sample: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + default: default_name + age: + type: integer + x-example: + name: x_example_name + age: 30 + required: true + responses: + '201': + description: '' + content: {} + x-codegen-request-body-name: payload + patch: + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + default: default_name + age: + type: integer + example: + name: example_name + age: 20 + x-example: + name: x_example_name + age: 30 + required: true + responses: + '201': + description: '' + content: {} diff --git a/packages/oas/tests/fixtures/x-example.path.disabled.swagger.result.json b/packages/oas/tests/fixtures/x-example.path.disabled.swagger.result.json new file mode 100644 index 00000000..04efd4e0 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.path.disabled.swagger.result.json @@ -0,0 +1,17 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "GET", + "headers": [ + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "url": "https://example.com/sample/0" + } +] diff --git a/packages/oas/tests/fixtures/x-example.path.swagger.result.json b/packages/oas/tests/fixtures/x-example.path.swagger.result.json new file mode 100644 index 00000000..8b0e7754 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.path.swagger.result.json @@ -0,0 +1,17 @@ +[ + { + "queryString": [], + "cookies": [], + "method": "GET", + "headers": [ + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "url": "https://example.com/sample/123" + } +] diff --git a/packages/oas/tests/fixtures/x-example.path.swagger.yaml b/packages/oas/tests/fixtures/x-example.path.swagger.yaml new file mode 100644 index 00000000..df57449e --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.path.swagger.yaml @@ -0,0 +1,24 @@ +swagger: '2.0' +info: + title: Sample API + version: 1.0.0 +host: example.com +schemes: + - https +paths: + /sample/{id}: + get: + consumes: + - application/json + produces: + - application/json + parameters: + - name: id + in: path + required: true + type: integer + default: 0 + x-example: 123 + responses: + '201': + description: '' diff --git a/packages/oas/tests/fixtures/x-example.query.disabled.swagger.result.json b/packages/oas/tests/fixtures/x-example.query.disabled.swagger.result.json new file mode 100644 index 00000000..dd23d29d --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.query.disabled.swagger.result.json @@ -0,0 +1,22 @@ +[ + { + "queryString": [ + { + "name": "filter", + "value": "default_filter" + } + ], + "cookies": [], + "method": "GET", + "headers": [ + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "url": "https://example.com/sample?filter=default_filter" + } +] diff --git a/packages/oas/tests/fixtures/x-example.query.swagger.result.json b/packages/oas/tests/fixtures/x-example.query.swagger.result.json new file mode 100644 index 00000000..1b905fe4 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.query.swagger.result.json @@ -0,0 +1,22 @@ +[ + { + "queryString": [ + { + "name": "filter", + "value": "x_example_filter" + } + ], + "cookies": [], + "method": "GET", + "headers": [ + { + "value": "application/json", + "name": "accept" + } + ], + "httpVersion": "HTTP/1.1", + "headersSize": 0, + "bodySize": 0, + "url": "https://example.com/sample?filter=x_example_filter" + } +] diff --git a/packages/oas/tests/fixtures/x-example.query.swagger.yaml b/packages/oas/tests/fixtures/x-example.query.swagger.yaml new file mode 100644 index 00000000..e1a460f5 --- /dev/null +++ b/packages/oas/tests/fixtures/x-example.query.swagger.yaml @@ -0,0 +1,24 @@ +swagger: '2.0' +info: + title: Sample API + version: 1.0.0 +host: example.com +schemes: + - https +paths: + /sample: + get: + consumes: + - application/json + produces: + - application/json + parameters: + - name: filter + in: query + required: false + type: string + default: 'default_filter' + x-example: 'x_example_filter' + responses: + '201': + description: '' diff --git a/packages/openapi-sampler/README.md b/packages/openapi-sampler/README.md index a56c3fb6..dfbb94e1 100644 --- a/packages/openapi-sampler/README.md +++ b/packages/openapi-sampler/README.md @@ -138,6 +138,19 @@ Also, the library logs console warning messages when it encounters an unsupporte sample(schema, { quiet: true }); ``` +When the schema comes from the specification which does not allow the `example` node to exist e.g. OAS 2.0 parameter definition, some vendors may provide such schema example value in OAS vendor extension nodes namely `x-example` or `x-examples`. If you want to include such kind of example values into the output, you can use the `includeVendorExamples` as shown below: + +```js +sample( + { + 'type': 'string', + 'x-example': 'some_value' + }, + { includeVendorExamples: true } +); +// some_value +``` + ## License Copyright © 2023 [Bright Security](https://brightsec.com/). diff --git a/packages/openapi-sampler/src/index.ts b/packages/openapi-sampler/src/index.ts index 4c5bba9c..e42042b6 100644 --- a/packages/openapi-sampler/src/index.ts +++ b/packages/openapi-sampler/src/index.ts @@ -16,11 +16,13 @@ export const sample = ( options?: Options, spec?: Specification ): any | undefined => { - const opts = Object.assign( - {}, - { skipReadOnly: false, maxSampleDepth: 1 }, - options - ); + const { skipReadOnly, maxSampleDepth } = options ?? {}; + + const opts = { + ...options, + maxSampleDepth: maxSampleDepth ?? 1, + skipReadOnly: skipReadOnly ?? false + }; const traverse = new DefaultTraverse(); @@ -38,4 +40,4 @@ export const sample = ( return traverse.traverse(schema, opts, spec)?.value; }; -export { Schema, Options, Specification } from './traverse'; +export { Schema, Options, Specification, VendorExtensions } from './traverse'; diff --git a/packages/openapi-sampler/src/samplers/ArraySampler.ts b/packages/openapi-sampler/src/samplers/ArraySampler.ts index 2eaa0d6c..ceb36623 100644 --- a/packages/openapi-sampler/src/samplers/ArraySampler.ts +++ b/packages/openapi-sampler/src/samplers/ArraySampler.ts @@ -1,6 +1,6 @@ import { Options, Sample, Specification, Traverse } from '../traverse'; import { Sampler, OpenAPISchema } from './Sampler'; -import { OpenAPIV2, OpenAPIV3 } from '@har-sdk/core'; +import { hasItems } from '../utils'; export class ArraySampler implements Sampler { constructor(private readonly traverse: Traverse) {} @@ -12,21 +12,21 @@ export class ArraySampler implements Sampler { ): any[] { let arrayLength = schema.minItems || 1; - if (this.isItemsExists(schema) && Array.isArray(schema.items)) { + if (hasItems(schema) && Array.isArray(schema.items)) { arrayLength = Math.max(arrayLength, schema.items.length); } const itemSchemaGetter = (itemNumber: number) => { - if (this.isItemsExists(schema) && Array.isArray(schema.items)) { + if (hasItems(schema) && Array.isArray(schema.items)) { return schema.items[itemNumber] || {}; } - return this.isItemsExists(schema) ? schema.items : {}; + return hasItems(schema) ? schema.items : {}; }; const res: Sample[] = []; - if (!this.isItemsExists(schema)) { + if (!hasItems(schema)) { return res; } @@ -37,13 +37,4 @@ export class ArraySampler implements Sampler { return res; } - - private isItemsExists( - schema: OpenAPISchema - ): schema is OpenAPIV2.SchemaObject | OpenAPIV3.ArraySchemaObject { - return ( - (schema as OpenAPIV2.SchemaObject | OpenAPIV3.ArraySchemaObject).items !== - undefined - ); - } } diff --git a/packages/openapi-sampler/src/traverse/DefaultTraverse.ts b/packages/openapi-sampler/src/traverse/DefaultTraverse.ts index 0b5ff674..ad5218e4 100644 --- a/packages/openapi-sampler/src/traverse/DefaultTraverse.ts +++ b/packages/openapi-sampler/src/traverse/DefaultTraverse.ts @@ -1,12 +1,14 @@ import { Options, Sample, Schema, Specification, Traverse } from './Traverse'; +import { SchemaExampleExtractor } from './SchemaExampleExtractor'; import { firstArrayElement, getReplacementForCircular, + isReference, mergeDeep } from '../utils'; import { OpenAPISchema, Sampler } from '../samplers'; import JsonPointer from 'json-pointer'; -import { OpenAPIV2, OpenAPIV3 } from '@har-sdk/core'; +import { IJsonSchema, OpenAPIV2, OpenAPIV3 } from '@har-sdk/core'; const schemaKeywordTypes = { multipleOf: 'number', @@ -48,6 +50,10 @@ export class DefaultTraverse implements Traverse { this._samplers = samplers; } + constructor( + private readonly sampleValueExtractor: SchemaExampleExtractor = new SchemaExampleExtractor() + ) {} + public clearCache(): void { this.refCache = {}; this.schemasStack = []; @@ -71,41 +77,97 @@ export class DefaultTraverse implements Traverse { this.pushSchemaStack(schema); - if (this.isRefExists(schema)) { + if (isReference(schema)) { return this.inferRef(spec, schema, options); } - if (this.isExampleExists(schema)) { - this.popSchemaStack(); + return ( + this.findSchemaExample(schema, options) ?? + this.tryTraverseSubSchema(schema, options, spec) ?? + this.createSchemaExample(schema, options, spec) + ); + } - return { - value: schema.example, - readOnly: schema.readOnly, - writeOnly: schema.writeOnly, - type: schema.type - }; + private createSchemaExample( + schema: Schema, + options?: Options, + spec?: Specification + ): Sample | undefined { + const type = (schema as any).type as string; + + let value = this.sampleValueExtractor.extractFromProperties(schema); + + value = + value === undefined + ? this.createSampleValueFromInferredType(schema, options, spec) + : value; + + this.popSchemaStack(); + + const { readOnly, writeOnly } = schema as OpenAPISchema; + + return { + type, + readOnly, + writeOnly, + value + }; + } + + private createSampleValueFromInferredType( + schema: Schema, + options?: Options, + spec?: Specification + ): unknown { + let type = (schema as any).type as string; + + if (!type) { + type = this.inferType(schema as OpenAPISchema); } + const sampler = this.samplers.get(type || 'null'); + + let value; + if (sampler) { + value = sampler.sample(schema as OpenAPISchema, spec, options); + } + + return value; + } + + private tryTraverseSubSchema( + schema: IJsonSchema, + options?: Options, + spec?: Specification + ): Sample | undefined { + return ( + this.tryTraverseAllOf(schema, options, spec) ?? + this.tryTraverseOneOf(schema, options, spec) ?? + this.tryTraverseAnyOf(schema, options, spec) + ); + } + private tryTraverseAllOf( + schema: IJsonSchema, + options?: Options, + spec?: Specification + ): Sample | undefined { if (schema.allOf) { this.popSchemaStack(); return this.allOfSample( - { ...schema, allOf: undefined } as - | OpenAPIV3.ReferenceObject - | OpenAPIV2.ReferenceObject - | OpenAPIV3.SchemaObject - | OpenAPIV2.SchemaObject, - schema.allOf as ( - | OpenAPIV3.ReferenceObject - | OpenAPIV2.ReferenceObject - | OpenAPIV3.SchemaObject - | OpenAPIV2.SchemaObject - )[], + { ...schema, allOf: undefined } as Exclude, + schema.allOf as Exclude[], options, spec ); } + } + private tryTraverseOneOf( + schema: IJsonSchema, + options?: Options, + spec?: Specification + ): Sample | undefined { if (schema.oneOf && schema.oneOf.length) { if (schema.anyOf && !options.quiet) { // eslint-disable-next-line no-console @@ -116,52 +178,51 @@ export class DefaultTraverse implements Traverse { this.popSchemaStack(); - return this.traverse(firstArrayElement(schema.oneOf), options, spec); + return this.traverse( + firstArrayElement(schema.oneOf as Exclude), + options, + spec + ); } + } + private tryTraverseAnyOf( + schema: IJsonSchema, + options?: Options, + spec?: Specification + ): Sample | undefined { if (schema.anyOf && schema.anyOf.length) { this.popSchemaStack(); - return this.traverse(firstArrayElement(schema.anyOf), options, spec); + return this.traverse( + firstArrayElement(schema.anyOf as Exclude), + options, + spec + ); } + } - let example: any; - let type: string; - - if (this.isDefaultExists(schema)) { - example = schema.default; - } else if ((schema as any).const !== undefined) { - example = (schema as any).const; - } else if (schema.enum && schema.enum.length) { - example = firstArrayElement(schema.enum); - } else if ((schema as any).examples && (schema as any).examples.length) { - example = firstArrayElement((schema as any).examples); - } else { - type = schema.type as string; + private findSchemaExample( + schema: Schema, + options: Options + ): Sample | undefined { + const value = this.sampleValueExtractor.extractFromExamples( + schema, + options + ); - if (!type) { - type = this.inferType( - schema as OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject - ); - } + if (value !== undefined) { + const { type, readOnly, writeOnly } = schema as OpenAPISchema; - const sampler = this.samplers.get(type || 'null'); + this.popSchemaStack(); - if (sampler) { - example = sampler.sample(schema as OpenAPISchema, spec, options); - } + return { + type, + readOnly, + writeOnly, + value + }; } - - this.popSchemaStack(); - - return { - type, - value: example, - readOnly: (schema as OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject) - .readOnly, - writeOnly: (schema as OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject) - .writeOnly - }; } private pushSchemaStack(schema: Schema) { @@ -307,31 +368,4 @@ export class DefaultTraverse implements Traverse { return res; } - - private isRefExists( - schema: Schema - ): schema is OpenAPIV3.ReferenceObject | OpenAPIV2.ReferenceObject { - return ( - (schema as OpenAPIV3.ReferenceObject | OpenAPIV2.ReferenceObject).$ref !== - undefined - ); - } - - private isExampleExists( - schema: Schema - ): schema is OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject { - return ( - (schema as OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject).example !== - undefined - ); - } - - private isDefaultExists( - schema: Schema - ): schema is OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject { - return ( - (schema as OpenAPIV3.SchemaObject | OpenAPIV2.SchemaObject).default !== - undefined - ); - } } diff --git a/packages/openapi-sampler/src/traverse/SchemaExampleExtractor.ts b/packages/openapi-sampler/src/traverse/SchemaExampleExtractor.ts new file mode 100644 index 00000000..4f508dff --- /dev/null +++ b/packages/openapi-sampler/src/traverse/SchemaExampleExtractor.ts @@ -0,0 +1,52 @@ +import { VendorExampleExtractor } from './VendorExampleExtractor'; +import { Options, Schema } from './Traverse'; +import { firstArrayElement, hasDefault, hasExample } from '../utils'; + +export class SchemaExampleExtractor { + constructor( + protected readonly vendorExampleExtractor: VendorExampleExtractor = new VendorExampleExtractor() + ) {} + + public extractFromExamples(schema: Schema, options: Options): unknown { + let value = this.extractFromSchemaExamples(schema); + + value = + value === undefined + ? this.extractFromVendorExamples(schema, options) + : value; + + return value; + } + public extractFromProperties(schema: Schema): unknown { + let value; + if (hasDefault(schema)) { + value = schema.default; + } else if ((schema as any).const !== undefined) { + value = (schema as any).const; + } else if ((schema as any).enum && (schema as any).enum.length) { + value = firstArrayElement((schema as any).enum); + } + + return value; + } + + private extractFromSchemaExamples(schema: Schema): unknown { + if (hasExample(schema)) { + return schema.example; + } else if ( + (schema as any).examples !== undefined && + (schema as any).examples.length > 0 + ) { + return firstArrayElement((schema as any).examples); + } + } + + private extractFromVendorExamples( + schema: Schema, + { includeVendorExamples }: Options + ): unknown { + return includeVendorExamples + ? this.vendorExampleExtractor.extract(schema) + : undefined; + } +} diff --git a/packages/openapi-sampler/src/traverse/Traverse.ts b/packages/openapi-sampler/src/traverse/Traverse.ts index e7a806c1..414ec584 100644 --- a/packages/openapi-sampler/src/traverse/Traverse.ts +++ b/packages/openapi-sampler/src/traverse/Traverse.ts @@ -4,6 +4,7 @@ export interface Options { skipReadOnly?: boolean; skipWriteOnly?: boolean; skipNonRequired?: boolean; + includeVendorExamples?: boolean; quiet?: boolean; maxSampleDepth?: number; } diff --git a/packages/openapi-sampler/src/traverse/VendorExampleExtractor.ts b/packages/openapi-sampler/src/traverse/VendorExampleExtractor.ts new file mode 100644 index 00000000..3ac7fb8f --- /dev/null +++ b/packages/openapi-sampler/src/traverse/VendorExampleExtractor.ts @@ -0,0 +1,106 @@ +import { Schema } from './Traverse'; +import { VendorExtensions } from './VendorExtensions'; + +interface ExampleShape { + arrayDepth: number; + objectKeys: string[]; +} + +export class VendorExampleExtractor { + public extract(schema: Schema) { + return [ + schema[VendorExtensions.X_EXAMPLE], + schema[VendorExtensions.X_EXAMPLES] + ].reduce( + (acc, example) => (acc ? acc : this.findExampleByShape(example, schema)), + undefined + ); + } + + private findExampleByShape(example: unknown, schema: Schema) { + const exampleShape = this.getExampleShape(schema); + const isPrimitiveType = + 0 === exampleShape.objectKeys.length && exampleShape.arrayDepth === 0; + + if (isPrimitiveType) { + return example; + } + + return this.traverse(example, exampleShape); + } + + private getExampleShape(schema: Schema, depth: number = 0): ExampleShape { + if ('items' in schema) { + return this.getExampleShape(schema.items, 1 + depth); + } + + return { + arrayDepth: depth, + objectKeys: + 'properties' in schema && schema.properties + ? Object.keys(schema.properties) + : [] + }; + } + + private traverse( + example: unknown, + exampleShape: ExampleShape, + possibleExample: unknown[] = [] + ): unknown { + if (!example || typeof example !== 'object') { + return undefined; + } + + if (exampleShape.arrayDepth > 0 && Array.isArray(example)) { + return this.traverseArray(example, exampleShape, possibleExample); + } + + return Array.isArray(example) + ? undefined + : this.traverseObject(example, exampleShape, possibleExample); + } + + private traverseArray( + example: unknown, + exampleShape: ExampleShape, + possibleExample: unknown[] + ): unknown { + if (exampleShape.arrayDepth > 0 && Array.isArray(example)) { + possibleExample.push(example); + + return this.traverseArray( + [...example, undefined].shift(), + { + ...exampleShape, + arrayDepth: exampleShape.arrayDepth - 1 + }, + possibleExample + ); + } + + return !!example && (Array.isArray(example) || exampleShape.arrayDepth > 0) + ? undefined + : this.traverseObject(example, exampleShape, possibleExample); + } + + private traverseObject( + example: unknown, + exampleShape: ExampleShape, + possibleExample: unknown[] + ): unknown { + const objectKeys = Object.keys(example ?? {}); + + if ( + exampleShape.arrayDepth === 0 && + objectKeys.every((key) => exampleShape.objectKeys.includes(key)) + ) { + return possibleExample.length > 0 ? possibleExample.shift() : example; + } + + return objectKeys + .map((key) => this.traverse(example[key], exampleShape)) + .filter((obj) => !!obj) + .shift(); + } +} diff --git a/packages/openapi-sampler/src/traverse/VendorExtensions.ts b/packages/openapi-sampler/src/traverse/VendorExtensions.ts new file mode 100644 index 00000000..94aa6de2 --- /dev/null +++ b/packages/openapi-sampler/src/traverse/VendorExtensions.ts @@ -0,0 +1,4 @@ +export enum VendorExtensions { + X_EXAMPLE = 'x-example', + X_EXAMPLES = 'x-examples' +} diff --git a/packages/openapi-sampler/src/traverse/index.ts b/packages/openapi-sampler/src/traverse/index.ts index 41463884..ad87627f 100644 --- a/packages/openapi-sampler/src/traverse/index.ts +++ b/packages/openapi-sampler/src/traverse/index.ts @@ -1,2 +1,3 @@ export * from './Traverse'; export * from './DefaultTraverse'; +export * from './VendorExtensions'; diff --git a/packages/openapi-sampler/src/utils.ts b/packages/openapi-sampler/src/utils.ts index 3637d7c1..7ca7c8c3 100644 --- a/packages/openapi-sampler/src/utils.ts +++ b/packages/openapi-sampler/src/utils.ts @@ -1,3 +1,15 @@ +import { Schema } from './traverse'; +import { OpenAPISchema } from './samplers'; +import { OpenAPIV2, OpenAPIV3 } from '@har-sdk/core'; + +export type OpenAPIArraySchemaObject = + | OpenAPIV3.ArraySchemaObject + | OpenAPIV2.SchemaObject; + +export type OpenAPIReferenceObject = + | OpenAPIV3.ReferenceObject + | OpenAPIV2.ReferenceObject; + const isObject = >(obj: T): obj is T => obj && typeof obj === 'object'; @@ -27,3 +39,15 @@ export const firstArrayElement = (x: T[]): T | undefined => x[0]; export const getReplacementForCircular = (type: string) => ({ value: type === 'object' ? {} : type === 'array' ? [] : undefined }); + +export const hasExample = (schema: Schema): schema is OpenAPISchema => + (schema as any).example !== undefined; + +export const hasDefault = (schema: Schema): schema is OpenAPISchema => + (schema as any).default !== undefined; + +export const hasItems = (schema: Schema): schema is OpenAPIArraySchemaObject => + (schema as any).items !== undefined; + +export const isReference = (schema: Schema): schema is OpenAPIReferenceObject => + (schema as any).$ref !== undefined; diff --git a/packages/openapi-sampler/tests/VendorExampleExtractor.spec.ts b/packages/openapi-sampler/tests/VendorExampleExtractor.spec.ts new file mode 100644 index 00000000..bb45bffa --- /dev/null +++ b/packages/openapi-sampler/tests/VendorExampleExtractor.spec.ts @@ -0,0 +1,422 @@ +import { VendorExtensions } from '../src'; +import { VendorExampleExtractor } from '../src/traverse/VendorExampleExtractor'; + +describe('VendorExampleExtractor', () => { + describe('find', () => { + let sut!: VendorExampleExtractor; + + beforeEach(() => { + sut = new VendorExampleExtractor(); + }); + + it.each([ + { + [VendorExtensions.X_EXAMPLE]: { + name: 'name', + age: 30 + } + }, + { + [VendorExtensions.X_EXAMPLE]: { + key1: { + name: 'nameOfKey1', + age: 30, + points: 100 + }, + key2: { + name: 'name', + age: 30 + } + } + }, + { + [VendorExtensions.X_EXAMPLE]: { + 'text/plain': { + name: 'name', + age: 30 + } + } + }, + { + [VendorExtensions.X_EXAMPLE]: { + 'application/json': { + key1: { + name: 'nameOfKey1', + age: 30, + points: 100 + }, + key2: { + name: 'name', + age: 30 + } + } + } + }, + { + [VendorExtensions.X_EXAMPLES]: { + 'application/json': { + key1: { + name: 'nameOfKey1', + age: 30, + points: 100 + }, + key2: { + name: 'name', + age: 30 + } + } + } + }, + { + [VendorExtensions.X_EXAMPLE]: { + 'example-name': { + name: 'name', + age: 30 + } + } + }, + { + [VendorExtensions.X_EXAMPLES]: { + 'example-name': { + key1: { + name: 'nameOfKey1', + age: 30, + points: 100 + }, + key2: { + name: 'name', + age: 30 + } + } + } + }, + { + [VendorExtensions.X_EXAMPLE]: { + 'example-name': [ + { + name: 'nameOfExampleName', + age: 30 + } + ] + }, + [VendorExtensions.X_EXAMPLES]: { + 'example-name': { + key1: { + name: 'nameOfKey1', + age: 30, + points: 100 + }, + key2: { + name: 'name', + age: 30 + } + } + } + } + ])('should match %j object vendor example', (input) => { + // arrange + const expected = { + name: 'name', + age: 30 + }; + + const schema = { + type: 'object', + properties: { + name: { + type: 'string' + }, + age: { + type: 'integer' + } + }, + ...input + }; + + // act + const result = sut.extract(schema); + + // assert + expect(result).toEqual(expected); + }); + + it('debug', () => { + const input = { + [VendorExtensions.X_EXAMPLE]: { + 'example-name': [ + { + name: 'nameOfExampleName', + age: 30 + } + ] + }, + [VendorExtensions.X_EXAMPLES]: { + 'example-name': { + key1: { + name: 'nameOfKey1', + age: 30, + points: 100 + }, + key2: { + name: 'name', + age: 30 + } + } + } + }; + // arrange + const expected = { + name: 'name', + age: 30 + }; + + const schema = { + type: 'object', + properties: { + name: { + type: 'string' + }, + age: { + type: 'integer' + } + }, + ...input + }; + + // act + const result = sut.extract(schema); + + // assert + expect(result).toEqual(expected); + }); + + it.each(['some-string', { name: 'some-name', points: 100 }])( + 'should ignore %j when example shape does not satisfy the object schema', + (input) => { + // arrange + const schema = { + type: 'object', + properties: { + name: { + type: 'string' + }, + age: { + type: 'integer' + } + }, + [VendorExtensions.X_EXAMPLES]: { + 'some-example': input + } + }; + + // act + const result = sut.extract(schema); + + // assert + expect(result).toBe(undefined); + } + ); + + it.each([ + { + [VendorExtensions.X_EXAMPLE]: [ + { + name: 'name', + age: 30 + } + ] + }, + { + [VendorExtensions.X_EXAMPLE]: { + key1: [ + { + name: 'nameOfKey1', + age: 30, + points: 100 + } + ], + key2: [ + { + name: 'name', + age: 30 + } + ] + } + }, + { + [VendorExtensions.X_EXAMPLE]: { + 'text/plain': [ + { + name: 'name', + age: 30 + } + ] + } + }, + { + [VendorExtensions.X_EXAMPLE]: { + 'application/json': { + key1: [ + { + name: 'nameOfKey1', + age: 30, + points: 100 + } + ], + key2: [ + { + name: 'name', + age: 30 + } + ] + } + } + }, + { + [VendorExtensions.X_EXAMPLES]: { + 'application/json': { + key1: [ + { + name: 'nameOfKey1', + age: 30, + points: 100 + } + ], + key2: [ + { + name: 'name', + age: 30 + } + ] + } + } + }, + { + [VendorExtensions.X_EXAMPLE]: { + 'example-name': [ + { + name: 'name', + age: 30 + } + ] + } + }, + { + [VendorExtensions.X_EXAMPLES]: { + 'example-name': { + key1: [ + { + name: 'nameOfKey1', + age: 30, + points: 100 + } + ], + key2: [ + { + name: 'name', + age: 30 + } + ] + } + } + }, + { + [VendorExtensions.X_EXAMPLE]: { + 'example-name': [ + { + name: 'nameOfExampleName', + age: 30, + points: 100 + } + ] + }, + [VendorExtensions.X_EXAMPLES]: { + 'example-name': { + key0: { + name: 'nameOfKey0', + age: 30 + }, + key1: [ + { + name: 'nameOfKey1', + age: 30, + points: 100 + } + ], + key2: [ + { + name: 'name', + age: 30 + } + ] + } + } + } + ])('should match %j array vendor example', (input) => { + // arrange + const expected = [ + { + name: 'name', + age: 30 + } + ]; + + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { + type: 'string' + }, + age: { + type: 'integer' + } + } + }, + ...input + }; + + // act + const result = sut.extract(schema); + + // assert + expect(result).toEqual(expected); + }); + + it.each([ + [[{ name: 'some-name', age: 30 }]], + 2, + 'some-string', + { name: 'some-name', points: 100 } + ])( + 'should ignore %j when example shape does not satisfy the array schema', + (input) => { + // arrange + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { + type: 'string' + }, + age: { + type: 'integer' + } + } + }, + [VendorExtensions.X_EXAMPLES]: { + 'some-example': input + } + }; + + // act + const result = sut.extract(schema); + + // assert + expect(result).toBe(undefined); + } + ); + }); +}); diff --git a/packages/openapi-sampler/tests/example.spec.ts b/packages/openapi-sampler/tests/example.spec.ts index 9ed43698..65c782c5 100644 --- a/packages/openapi-sampler/tests/example.spec.ts +++ b/packages/openapi-sampler/tests/example.spec.ts @@ -1,7 +1,8 @@ -import { sample } from '../src'; +import { VendorExtensions, sample } from '../src'; describe('Example', () => { it('should use example', () => { + // arrange const obj = { test: 'test', properties: { @@ -14,26 +15,87 @@ describe('Example', () => { type: 'object', example: obj }; + + // act const result = sample(schema); + + // assert expect(result).toEqual(obj); }); it('should use falsy example', () => { + // arrange const schema = { type: 'string', example: false }; + + // act const result = sample(schema); + + // assert expect(result).toEqual(false); }); it('should use enum', () => { + // arrange const enumList = ['test1', 'test2']; const schema = { type: 'string', enum: enumList }; + + // act const result = sample(schema); + + // assert expect(result).toEqual(enumList[0]); }); + + it.each([false, undefined])( + 'should not use vendor example when includeVendorExamples is %s', + (input) => { + // arrange + const schema = { + type: 'string', + [VendorExtensions.X_EXAMPLE]: 'foo' + }; + + // act + const result = sample(schema, { includeVendorExamples: input }); + + // assert + expect(result).toEqual('lorem'); + } + ); + + it('should prefer schema example over vendor example when includeVendorExamples is true', () => { + // arrange + const schema = { + type: 'string', + [VendorExtensions.X_EXAMPLE]: 'foo', + example: 'bar' + }; + + // act + const result = sample(schema, { includeVendorExamples: true }); + + // assert + expect(result).toEqual('bar'); + }); + + it('should prefer vendor example over default when includeVendorExamples is true', () => { + // arrange + const schema = { + type: 'string', + [VendorExtensions.X_EXAMPLE]: 'foo', + default: 'bar' + }; + + // act + const result = sample(schema, { includeVendorExamples: true }); + + // assert + expect(result).toEqual('foo'); + }); });